A graphics bug that turned out to be a CD-ROM bug
Debugging Legend of Mana on a static recompiler, and the difference between a hypothesis and a measurement.
Legend of Mana's land-creation sequence is one of the game's signature effects. You place an artifact on the map, which is the mechanic for placing stages on the map, and the stage's image transforms from the artifact's image during a sequence involving a few flashes and a soft bloom of light.
On psxrecomp's runtime engine, it looked like this:
psx-runtime. Hard-edged wedges, saturated colours, spokes running off the screen.
Here's the same effect on DuckStation, which I use as a reference emulator:
DuckStation. A soft additive bloom, which is what the effect is supposed to be.
My initial assumption was that this was a render bug, but it wasn't manifesting with other animations/effects that felt similar in other titles which seemed strange. So instead of trying to fix it, I set out trying to diagnose it extremely specifically and with 100% confidence before attempting a fix.
I built a number of python test scripts, and new features into psxrecomp and my own personal studio application for testing and debugging this, by patching a debug server into duckstation, and building psxrecomp with it's own, and slowly adding diagnostics to these and checking the RAM live for comparisons.
The thing it wasn't
The recompiler emits C from MIPS, and the runtime implements the PlayStation hardware around it. A wrong-looking triangle could come from almost anywhere in that stack, so I worked down it.
The rasteriser. All four semi-transparency modes matched hardware — B/2+F/2, B+F, B-F clamped at zero, B+F/4. Textured pixels correctly blended only when the texel's bit 15 was set. The Gouraud triangle's colour interpolation paired its endpoints with the right edges. Nothing.
The display list. The game builds an ordering table in RAM and DMAs it to the GPU, so I walked it out of guest memory on both emulators and compared primitive classes. Identical: 584 opaque textured quads, 184 subtractive flat quads, 176 subtractive Gouraud triangles, 64 additive Gouraud quads, 32 additive triangles. Same effect, same primitives, same counts.
The CPU. Lockstep between the compiled backend and the interpreter ran clean over 5.6 million basic blocks. The GTE issued zero INTPL operations and never saturated.
The arithmetic. The colour routine at 0x8006844C computes source × scale >> 7 per vertex. I sampled its registers mid-effect: source (248,136,8), scale ramping smoothly 128→124→120→116→112→108, one value per frame. 248 × 80 >> 7 = 155. Exactly right, every time.
So: correct geometry, correct primitives, correct arithmetic, correct inputs — and a wrong picture.
The number that mattered
The 64 additive quads are the glow. Comparing what each emulator put in them:
| distinct vertex colours | |
|---|---|
| DuckStation | 5 |
| psx-runtime | 216 |
Both drew the same 64 quads across the same 599-line span. DuckStation gave them five colours. I gave them 216, including saturated reds like (117,0,0) that cannot be produced by scaling (248,136,8) by anything — scale it to R=117 and G lands on 63, not 0.
Those colours aren't computed. They're file data being read as colours.
The effect reads its palette from a table in RAM. Comparing that table byte for byte at the same screen: DuckStation held 5 distinct colour words; mine held 216, with 1067 of 1712 bytes differing.
Following the bytes backwards
The table is filled by CD-ROM DMA at scene load. The runtime logs every channel-3 transfer with its destination and the sector it drained, so I taught the DuckStation build to log the same thing and lined the two up:
| destination buffer | psx-runtime | DuckStation |
|---|---|---|
0x0E1F18 |
sector 125111 | sector 125111 |
0x0E2718 |
sector 125113 | sector 125112 |
0x0DCF18 |
sector 125113 | sector 125113 |
There it is. One buffer, one sector apart.
Sector 125112 carries the palette. I pulled it straight off the disc image to be sure: its first words are 0x0888F8 and 0xB0F8F8 — (248,136,8) and (248,248,176), the exact golds in DuckStation's table.
On my runtime that buffer received sector 125113 instead: raw file bytes, which the colour routine then faithfully scaled into 216 vertex colours. The wedges were a palette made of whatever happened to be one sector further into the file.
Why the sector went missing
Both emulators issued byte-identical CD command streams. Both delivered every requested sector exactly, with matching data and no lost interrupts. Same asks, same answers, different result — so the fault was between them.
Per-sector cycle timestamps showed the shape of it. The game streams a file by issuing Setloc(next) + ReadN about 10,800 guest cycles after each sector lands. It's fast and it's regular. But on my runtime, sectors it explicitly requested arrived ~456,000 cycles apart, where sectors arriving as continuations of an in-progress read came 225,792 cycles apart — exactly one double-speed sector period.
That difference, 451,584 cycles, is precisely the read-start latency the runtime charges when a read begins.
Every ReadN was starting a new read. And starting a read calls start_read_stream(), which clears the sector buffer.
So the sequence was:
- Sector 125111 arrives, the guest is told, it drains it.
- Sector 125112 arrives as a continuation — delivered, announced, sitting in the buffer.
- ~10,800 cycles later the guest issues
Setloc(125113) + ReadNfor the next one. - That
ReadNclears the buffer, destroying 125112 before the guest ever collected it. - The guest's DMA then drained 125113 into the buffer that should have held 125112.
The game destroyed its own palette by asking for the next sector.
DuckStation doesn't do this. Its read handler only calls BeginReading() — the function that clears buffers and re-arms the read-start latency — when the request actually moves the drive:
if ((!setloc_pending || setloc_position.ToLBA() == GetNextSectorToBeRead()) &&
(drive_state == Reading || ...))
DEV_LOG("Ignoring read command with pending/same setloc, already reading");
else
BeginReading();
A Read issued while the drive is already streaming the sector the pending Setloc names means keep going, not start over.
The fix
About fifteen lines. If the drive is already reading and the pending Setloc targets the sector the stream is about to deliver anyway, acknowledge the command and don't restart anything.
Everything fell out of that at once:
- The un-drained sector survives, so the game receives consecutive sectors (109, 110, 111, 112, 113) instead of duplicates (109, 111, 111, 113, 113).
- Inter-sector gaps collapse from ~456,000 cycles to 225,792 — the hardware cadence — because reads stop restarting.
- The load takes 2 guest frames instead of 13, matching DuckStation.
- Pended interrupts go from 10 to 0.
- The palette table holds 5 colour words instead of 216.
And the effect renders correctly.
psx-runtime after the fix.
What I'd take from it
The bug was three layers below the symptom. A glow rendered as coloured wedges was a CD-ROM streaming bug. Nothing about the picture pointed there; only elimination did, and the elimination only worked because each step was a measurement rather than an opinion.
Hypotheses are cheap and mostly wrong. I lost count somewhere past a dozen. Blend modes, Gouraud interpolation, unaligned load/store helpers, scratchpad writes, the fade scale, savestate contamination, drive speed. Each one was plausible. Each one died to a number. The ones that hurt were the ones I acted on before measuring — I once shipped a "correctness" change to the sector-buffer code because the reasoning was clean, and it cost four debugging rounds before I noticed the regression was mine and not the game's.
A reference implementation is worth more than any amount of reasoning. Being able to ask "what does DuckStation put in that buffer?" turned an open-ended search into a diff. Most of the work was building enough instrumentation on both sides to make the comparison honest: matching CD DMA logs, per-sector delivery records, guest cycle stamps, and — repeatedly — guards that made the tools refuse to answer when they were about to compare unlike things.
That last part mattered more than it sounds. Several of my worst detours came from tools that produced a confident number from a degraded session, a stale process, or two different moments of an animation. A tool that says "I can't answer that" is worth several that will answer anything.
Faithfulness is not optional even when it looks like it. The sector-buffer model was "close enough" for years of games. It took a game that streams a file by re-requesting the sector already in flight to find the edge — and once found, the fix was the hardware behaviour all along.