Piping Production Logs Into Cyberpunk 2077
A live SSH session on a phone, bridged over a WebSocket, rasterised into a render target and bound at runtime into REDengine's material system. Not an overlay. The billboard itself, respecting depth and occlusion like any other object in the scene.
Night City already looks like a server room that decided to become a city. Every billboard is scrolling ad copy, every alley is lit by some corporation's neon logo, and the entire aesthetic is built around the idea that information is physically plastered onto the environment. So the question that started this project wasn't "can I mod this game," it was much pettier than that: could one of those billboards show my actual production logs, live, streaming out of a real FastAPI backend, while I walked past it in-game. Not a screenshot. Not a mockup. A genuinely live SSH session, tailing real logs, rendered as a texture on a wall in a video game.
The answer turned out to be yes, and getting there was less about hacking in the dramatic sense and more about three separate, unglamorous engineering problems stacked on top of each other: getting a live text stream out of a phone, getting that stream into the game's render pipeline, and finding the right piece of the game's memory to hijack without crashing it.
Problem One: Getting the Logs Out of the Phone
The actual log source was boring by design, which is the entire point of the zero-maintenance architecture the backend runs on. A FastAPI service, logging structured output to stdout, captured by the process supervisor and tailed over SSH from a Termux session on Android. That session already existed as part of the normal development workflow, the same terminal used for day-to-day work on the backend, just pointed at production instead of a local dev server.
The wrinkle was getting that live text stream out of a phone's terminal session and into a process running on a desktop machine, which is where the game itself lived. Rather than trying to pipe raw SSH output across devices, which gets messy fast with terminal escape codes and PTY quirks, the terminal output got mirrored through a lightweight local WebSocket bridge: a small Python script on the Termux side, reading the SSH session's output stream, forwarding each new line as a plain-text WebSocket message to a listener running alongside the game.
import asyncio
import websockets
async def stream_logs(websocket):
proc = await asyncio.create_subprocess_exec(
"ssh", "prod-server", "tail -f /var/log/backend/app.log",
stdout=asyncio.subprocess.PIPE
)
while True:
line = await proc.stdout.readline()
if line:
await websocket.send(line.decode().strip())
asyncio.run(websockets.serve(stream_logs, "0.0.0.0", 8765))
Simple, and deliberately so. Anything more elaborate than "read a line, forward a line" was solving a problem this project didn't actually have.
Problem Two: Getting Text Into the Game's Render Pipeline
Cyberpunk 2077 runs on REDengine 4, and the modding community around it has, over several years, built genuinely excellent tooling for exactly this kind of intervention: Cyber Engine Tweaks (CET), which injects a Lua scripting runtime directly into the running game process and exposes hooks into large parts of the engine, including ImGui-based overlay rendering that draws directly on top of the game's own frame output.
The first working version didn't touch the billboard's actual in-world texture at all, it used CET's ImGui overlay layer to draw a text panel anchored to the screen position of the billboard mesh, updated every frame based on the player's camera position relative to the billboard's world coordinates. That's a legitimate technique, screen-space overlay anchored to a 3D world position, and it got text visibly "attached" to the billboard reasonably quickly.
registerForEvent("onDraw", function()
local billboardWorldPos = Vector4.new(1024.5, -892.3, 34.0, 1.0)
local screenPos = Game.WorldToScreen(billboardWorldPos)
if screenPos.onScreen then
ImGui.SetNextWindowPos(screenPos.x, screenPos.y)
ImGui.Begin("log_overlay", true, ImGuiWindowFlags.NoDecoration)
for _, line in ipairs(recentLogLines) do
ImGui.Text(line)
end
ImGui.End()
end
end)
This worked, but it was cheating in a way that bothered me more than it should have. An overlay isn't actually part of the billboard, it's a screen-space sticker floating in front of the frame, and it breaks immediately the moment the billboard is partially occluded by a building or an NPC walking past it, since the overlay has no concept of depth or occlusion against the actual 3D scene. If the goal was a billboard that genuinely displayed the logs, not a floating label pretending to, the text needed to actually live inside the billboard's material as a texture, respecting depth, lighting, and occlusion like any other object in the scene.
Problem Three: Actually Replacing the Texture
This is where the project stopped being "write a mod using documented APIs" and became "go find the specific piece of memory holding a texture reference and swap it out from underneath the renderer," which is a meaningfully different, much less forgiving kind of work.
Billboards in REDengine are backed by standard material and texture assets, referenced by a resource path baked into the level's static mesh data. The approach that ended up working combined WolvenKit, the community's asset extraction and inspection tool, to identify the exact texture resource path bound to the billboard's material slot, with a runtime texture swap performed through CET's lower-level resource hooks, intercepting the material's bound texture at load time and substituting a dynamically generated one instead of the original static asset.
The dynamic texture itself was the actual payload: a render target, generated fresh, or at least at a throttled refresh interval, by rasterizing the latest handful of log lines as text onto a texture buffer, matching the resolution and aspect ratio the original billboard asset expected so the material's UV mapping didn't distort or tile the text incorrectly across the mesh.
local function updateLogTexture()
local canvas = RenderTarget.new(1024, 512)
canvas:Clear(0, 0, 0, 200)
local y = 10
for _, line in ipairs(recentLogLines) do
canvas:DrawText(line, 10, y, "monospace", 18, 0, 255, 120)
y = y + 22
end
materialInstance:SetTextureParameter("BaseColor", canvas)
end
Throttling mattered more than expected. Regenerating and rebinding the texture every single frame introduced a visible stutter, the same texture-upload cost any game engine pays for streaming a new texture to the GPU, just paid sixty times a second instead of the handful of times a second actually needed for legible scrolling logs. Capping the refresh to roughly four updates per second was indistinguishable from live to the eye, and made the frame time cost negligible.
What Actually Broke, Repeatedly
The overlay version was forgiving. The material-level texture swap was not, and the two failure modes that ate the most debugging time were both memory-adjacent in the way reverse-engineering work usually is. First, holding a stale reference to the material instance across a scene reload, when the billboard's chunk streamed out and back in, the original pointer no longer referred to a valid object, and writing to it produced exactly the crash you'd expect from touching freed memory. The fix was re-resolving the material reference on every relevant streaming event instead of caching it once and assuming permanence.
Second, and more subtly, text legibility at in-game viewing distance turned out to be a rendering problem, not a data problem. The billboard sat far enough from typical player paths that a naively rendered 18-point monospace font on the texture became an illegible smear once mapped onto the mesh and viewed from thirty meters away in-engine. The eventual fix was rendering the text at a much larger effective size on the texture itself, oversized deliberately, relying on the billboard's actual in-world scale to bring it back down to a legible size at typical viewing distance, rather than trying to match a "natural" font size that made sense on a flat 1024x512 canvas but not on a ten-meter advertising panel.
Standing in Night City, Reading My Own Stack Trace
The finished result is, admittedly, a very silly thing to have built: walking down a rain-slicked street in a cyberpunk dystopia and glancing up at an advertising billboard to see, in real time, that a background job on the actual production server just finished processing a batch, or that a request took 340ms longer than it should have. But underneath the silliness sits a genuinely complete, working pipeline: a live SSH session on a phone, bridged over a local socket, rendered as a dynamically generated texture, bound at runtime into a AAA game engine's material system by walking past the intended asset pipeline entirely.
None of the individual pieces were exotic. WebSockets, a render-to-texture call, a resource hook. What made it feel like reverse engineering rather than scripting was the absence of any documentation for the specific thing being attempted, texture resources are meant to be static assets shipped with the game, not live buffers rewritten from an external data source sixty times a minute. Getting there meant reading disassembled engine calls, testing against a live process, and accepting that half the "documentation" was a crash log explaining exactly which assumption had just been violated.
Night City doesn't actually know it's showing production logs. It just knows a texture changed, the same way it would if I'd painted over the billboard by hand. Which, in a sense, is exactly what happened.