monkeylashes

Learnings

The things that were only obvious afterwards — collected here rather than buried in the project they came from, because most of them turned out not to be about that project at all.

Homeworld: Unbound

About this project →
A scene that looks lit from everywhere is a colour bug, not a lighting bug
Ships came out flat, as though lit from all sides, and the shading code was correct the whole time. The swapchain was handed linear colour and treated as sRGB, which lifts every mid-tone and pulls shadowed faces up towards lit ones. The tell is that it affects the entire scene uniformly: one wrong model means bad normals, but a whole flat scene means the pipeline.
A frame hook on a general-purpose flush runs at times you did not intend
The VR frame rides the engine's rndFlush, which is deliberate. The loading bar also calls rndFlush every time it advances - so each tick of the progress bar rendered a full stereo frame while the texture registry was midway through freeing and reloading every texture. Hyperspacing out of any mission crashed, fifteen frames deep in the renderer, nowhere near the VR code that caused it.
A helper with a hidden side effect will fold it back in behind you
Rebuilding the game camera on its own meant calling the engine's own rgluLookAt with an identity matrix. That helper quietly multiplies the headset pose in during an eye pass, so the "pure" matrix carried the head after all. The render then cancelled the head against itself, and a thing that does not respond to head movement is a thing welded to your face - which is exactly how it looked.
Two derived numbers can cross without either looking wrong
Drawn flight paths evaporated the moment they were committed. Waypoint spacing was path length over twelve; the arrival radius was three hull widths plus a tenth of the world scale. Each is reasonable alone. Because a stroke is about a metre of arm movement, the arrival radius was wider than the gap between waypoints for essentially every path a person could draw, so the whole route was consumed in twelve frames.
Suppressed compiler warnings hide the bugs you cannot see
The build passes -Wno-implicit-function-declaration, inherited from a 1999 codebase. A call to a function returning a float was therefore assumed to return int, silently, and produced garbage distances. It compiled clean and ran wrong. Old codebases earn their warning suppressions honestly, and then charge interest.
Compare log lines from the same frame, or do not compare them
Three wrong diagnoses in a row came from reading two debug lines that were ninety seconds apart as though they described one moment. The symptom that finally located the bug was a value that should be exactly zero for a game camera and was not - a single line, self-consistent, needing nothing else to interpret it.
A version number nothing ever increments is worse than no version number
The APK declared itself 1.2.0 build 1 - the version of the engine it was built on, inherited and never touched - across seven releases. Sideloaders and app listings use exactly that number and nothing else to decide whether a build is newer, so not one of those seven ever registered as an update to anything downstream. It failed silently in the worst direction: a number that is present and plausible is trusted, where a missing one would have been questioned.
Nothing standing in the way is not the same as having tried it
Relaying traffic through the host removed every known obstacle to playing with more than two people over the internet, and it is tempting to write that down as working. Nobody had sat three machines on three connections and played a game, though, so the honest claim is the smaller one. Two players is tested; three is only unblocked. The gap between those is where bug reports come from.
The network that is convenient to test on is the one that hides the bug
A release shipped internet multiplayer with three separate faults in it, and every one confused the address a machine calls itself with the address someone else can reach it at. On a local network those are the same string, so none of the three could have appeared in testing - the LAN was not merely a weak test, it made the two values identical and so guaranteed the bugs survived it. Worth asking of any test environment which distinctions it collapses.
A default is an assumption about the output it was chosen for
Smoothing the ship textures was a sensible default for a 1999 monitor and it survived untouched into a headset, which magnifies a 64x64 texture far past anything its author had in mind. The same setting went from a finish on the ships to most of what you were looking at, without changing. When the output changes by an order of magnitude, every default chosen against the old one is back open for question, including the ones nobody has had a reason to think about in twenty-five years.
Partial success is what sends the diagnosis to the wrong place
Saving a new game failed with "check disk space" on any install whose saves folder had been copied on by hand, and the disk was fine - the folder was readable by the game but not writable. Everything that would have implicated permissions worked: saves listed, saves loaded. Reading a directory and creating a file in it are separate permissions, so only the narrow operation broke, and a bug that leaves most of a feature working is read as being about something other than access.
Read the data where the user can put it, not where the code prefers it
The game read its data from one privileged folder, so getting a file into that folder was the setup step - and Android 11 had closed it to every file manager on the device, which meant the only route in was a second computer driving the headset over adb. The requirement looked like "copy three files" and was actually "own a PC". Searching the places a person can actually write to, and reading the files in place, deleted the whole procedure rather than smoothing it. Worth checking, for any step users are talked through, whether it exists to serve the program's preference about location.
The person living with the workaround knows which constraint is load-bearing
The copy-it-across step had been in every release and was known to be awkward, and it took a player on r/OculusQuest to say that this was the thing keeping the port from being usable standalone at all - and to point at how other ports had solved it. From inside, a known rough edge and a barrier look identical, because both are things you have already routed around and stopped noticing.
A stand-in device only stands in for the questions it is asked
The headset answered "where is the pointer" well enough that the wrist panel worked, and Homeworld stops asking that the moment a drag begins - from there it asks how far the mouse has moved, and there was no mouse to have moved. So a press worked and a drag silently did nothing, in every build for a month. An emulation is not a device; it is the set of questions someone thought to answer, and the gaps are wherever the original hardware was asked something else.
A uniform treatment is wrong for whichever thing is not like the others
Every one of the game's screens went onto the wrist panel, which is correct for Build and Research - they are lists of ships and costs, and a flat panel is where a list belongs. The Sensors Manager is a 3D scene with its own camera, and flattening it was the one place the uniform choice cost anything. The rule was not wrong so much as applied to a member of the set that did not belong to it, which is the harder kind to notice because the rule keeps looking sound.
An app that dies before its first frame looks exactly like one that is hanging
A crash on startup presented as an indefinite wait in the headset's loading environment, because the compositor keeps showing its own room until an app hands it a frame - and an app that dies before the first one never does. On a desktop a crash at least takes a window away with it; here there was nothing to disappear, so the only visible symptom was waiting. Worth knowing which failures a platform renders as silence.
A decision made at startup outlives the restart the user thinks they performed
Demo or full campaign was settled when the app started and baked into the engine loaded, which is fine until Android reuses a process that is already running instead of starting a fresh one. The app then noticed the new game data correctly and handed it to an engine that could not accept it. Anything decided once at launch is only as durable as the platform's willingness to actually launch again.
Not crashing is worth little if the player sees no difference
The first fix for a stuck launch stopped the crash and left the process exiting without coming back, so what a player met was a stuck loading screen and a second press - identical to the bug, and shipped as fixed on the strength of the log being clean. The check that would have caught it is not "is the fault gone" but "is the symptom gone", and those are different questions asked of different things: one of the system, one of the person using it.
The crash you find first is sometimes a symptom of the one you have not
A crash on the demo-to-full path had a complete and correct explanation - the campaign choice is compiled into the engine and Android had warm-started the process with the wrong one loaded - and it was one case of something wider: the engine cannot start twice in a process at all, whatever the campaign. It died sooner than the general fault, which is exactly why it presented as its own bug with its own tidy story. A diagnosis that explains everything you have seen is not the same as one that explains everything.
Bypassing a layer takes its side effects with it
The VR controls call the engine directly instead of synthesising key presses, which is right - an unbound key would otherwise swallow a command silently. The keyboard path had also been picking up the order acknowledgements on the way through, so the fleet went quiet, and in a headset where the destination is often behind you that callback was most of the evidence an order had registered. What a layer does incidentally is rarely written down anywhere, so it leaves with the layer.

Medal of Honor: Allied Assault in VR

About this project →
"It already speaks OpenGL ES" usually means it nearly does
The renderer was documented as GLES-capable and could not start on one. Its GL 1.1 entry point list carried five functions that exist in no version of GLES, and that list was shared with the ES path, so loading failed on every ES context before a frame was drawn. Separately, the ES 3.0 context was only ever requested inside an Emscripten ifdef, so everywhere else the driver handed back ES 2.0. Neither defect was Android-specific; both would have broken the browser build already in the tree.
A compatibility layer's cache is a place bugs hide
Two days went into a black screen that turned out to be gl4es answering questions from its own state copy rather than the driver's, and holding a stale cull-face setting. The same shape of bug appeared independently in the Homeworld port, where a cached "texturing is already enabled" flag meant the enable call was never issued and a textured quad drew black. Translation layers are fast because they cache; they are confusing for exactly the same reason.
Being core in the spec does not mean being switched on
Framebuffer objects are core in ES 2.0, and the renderer still disabled them on GLES with an early exit that left the whole entry point table null. Worth loading them - but the renderer's internal use stays off in VR deliberately: each eye is already a swapchain image, so routing through an intermediate framebuffer first costs a second full-resolution pass and resolve per eye and buys nothing.
Let the engine create its own directories
A save directory made with adb belongs to the shell user, and no chmod makes it writable by the app, which is merely "other" there. MOH:AA autosaves the moment a mission starts and treats the failure as fatal, so the symptom is a black screen after New Game and one line about a path it could not create. The same trap, in a different shape, cost time on the Homeworld port too.
Prove the renderer can draw before believing any negative result
For a whole session the graphics layer could not draw anything at all, and every experiment run on top of that returned the same answer - nothing appeared - regardless of what it was testing. Five conclusions were drawn and acted on from that invalid baseline: that 32-bit indices were at fault, that a draw path was broken, that compiled vertex arrays were broken, that a buffer call was discarding everything, and at two different points that immediate mode did and did not work. All of it thrown away. The check that finally cracked it takes a minute: ask for a screen clear, then one flat untextured quad, and read the pixels back. A clear needs no shaders; a quad needs the whole pipeline. If the clear lands and the quad does not, stop looking at the engine.
Instrumentation can stop firing without telling you
A diagnostic probe was gated on a condition that an unrelated change quietly made false. Two further rounds of "nothing was drawn" were actually "the probe never ran" - indistinguishable from the outside, and pointing the wrong way. Anything that measures needs to be shown to still reach the thing it measures, every time something it depends on changes.
A library can report success while leaving you unbound
SDL hands back a live OpenGL context in a headset where no window surface exists, and returns success while actually binding nothing. Its own bookkeeping then says the context is current, so asking it to bind again returns early - "we are already current" - and SDL and the driver disagree permanently. Made worse by the compatibility layer answering version queries from its own constants, so the engine's only liveness check passed unconditionally against a context that had never been bound. The fix is what other Quest ports do: bind against a 16x16 offscreen buffer that exists purely so there is something to be current on.
Repeat a result before building on it
The same configuration produced different outcomes on different runs more than once, and a bisect was built on top of that before anyone noticed. Changing code and configuration in the same step destroyed the only known-good baseline and cost several round trips to rebuild it.
A newer renderer is only faster if the content has something to feed it
rend2 ran this game at 10-15 fps and the cost was not on the GPU, which finished in 3 ms - it was 50-60 ms of CPU per eye binding GLSL programs for lighting that art authored in 2002 has no maps to supply. The older fixed-function path asks the content for exactly what the content has, so it wins here, and the per-eye framing is what makes the gap decisive: any cost paid per eye is paid twice. Newer rendering paths assume newer assets, and a port inherits the assets.

29 learnings across 2 projects.