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.

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.

14 learnings across 2 projects.