Dr. Mario (NES, 1990) · bug dossier · found 2026-08-18 · struktured labs

The Bottomless Bottle

A previously undocumented hang in stock Dr. Mario. The vertical match scan has no bottom-of-bottle check, so under one corruption it walks out of the playfield, wraps, and re-scans the same column forever. Found on FPGA silicon during an AI soak test, byte-verified against the retail ROM, reproduced with a killed-mutant pair 128 bytes apart, fixed in 3 bytes.

The bug in one paragraph

When a pill lands, the game scans every column for vertical color matches (checkVerMatch, ROM $9479). The scan steps down a column by adding 8 to the field index and tests for the bottom with AND #$F8 / BEQ — zero only for indices 0–7, i.e. only after the index wraps past 255. Nothing checks the field's end at index 128. A color chain reaching the bottom row reads on into the 128 bytes after the field. If those match its color, the index wraps to the top row, the cursor is written back, and the scan restarts the same column. Forever. The NMI keeps firing, the frame counter runs through all 256 values, and the main loop never queues another update: the picture freezes solid while the machine runs.

Emulator reproduction, side by side. Two runs of the same unpatched cart from the same frame. After half a second of normal play, a RAM image captured from the wedged FPGA rig is restored into both. The two boards differ in one cell: on the left it has been changed from the illegal color $F to a legal color, and that side keeps playing. On the right the captured board is untouched, and the game stops dead while the overlaid readout shows the NMI frame counter still advancing and the match-scan cursor still moving.
One byte decides it. Same unpatched cart on both sides, same captured RAM, including the captured trailing bytes — those are left alone here. The two boards differ in one cell: row 15, column 2, $BF$80, which is color nibble $F changed to a legal 0. That breaks the color chain, and the verdict flips from wedge to no-wedge. Left, one byte changed: the scan terminates and play continues. Right, the captured board: the scan never terminates. Everything before the restore is pixel-identical on both sides. This is the claim above in its smallest form — the trigger reduces to a single cell of an illegal color. Reproduction in an emulator from captured silicon state, not a wild capture. Real time, every third NES frame, no frames altered or synthesised. Nothing on screen here is out of reach of legal play: across the run the field never holds a stray $00, the trailing region never changes, and every cell belongs to a legal family.

Why legal play can't arm it: the chain must be one color all the way down and through the trailing RAM. Empty cells are $FF and the trailing region idles at $FF, so the trigger reduces to one cell with color nibble $Fan illegal color; pills and viruses are 0–2. Four thousand random legal boards: zero hangs. It takes a corrupted byte to arm, then 36 years of luck to stay hidden. An FPGA soak rig playing thousands of unattended games ran the luck out.

The asymmetry that proves it's an oversight

The horizontal scan, a page earlier, is bounded correctly — INC / AND #$07 / BEQ terminates at the last column. Only the vertical twin is unbounded. That is not a design decision. It is a missing line.

;; checkVerMatch [$9479] — as transcribed in the community disassembly.
;; The annotation on the fatal line is the disassemblers' own — the bug
;; was transcribed and labeled without being noticed:

@checkVerColorChain_loop:
        lda fieldPos_tmp
        clc
        adc #rowSize            ; step down one row (+8)
        sta fieldPos_tmp
        lda fieldPos_tmp
        and #mask_fieldPos_row_alt  ; "Check if reached bottom" — it isn't.
        beq @verColorChainBroken    ; zero only AFTER wrapping past $FF
        ldy fieldPos_tmp
        lda (currentP_fieldPointer),Y   ; reads $80-$FF: PAST the field
        and #mask_fieldobject_color
        cmp startingColor
        bne @verColorChainBroken
        inc colorChain
        jmp @checkVerColorChain_loop
field · indices $00–$7F · 8×16 bottle
trailing RAM · $80–$FF · scan reads it anyway

The field pointer addresses 128 cells. The scan indexes all 256. Player 2's trailing region is the fireworks buffer ($0580–$05FF), player 1's is $0480–$04FF. Both idle at $FF — color nibble $F.

The amplifier: garbage is planted unvalidated

One corrupted byte would be a curiosity. A second stock routine makes it an epidemic. In VS play checkReleaseAttack drops attack garbage into the opponent's field as LDA attackColors,X / ORA #$80 / STA (field),Y no color validation. A corrupted $0F lands as $8F: an illegal-color cell at row 0 with nothing beneath it, the minimal trigger in the worst position. It crosses to the other board on every volley. Once seeded, the hang recurs.

The figure below shows it. The captured attack table holds 02 02 0F 0F — two corrupt entries — so two of the four garbage cells come out illegal every volley and drop into the opponent's bottle as multicolored cells. They are planted duringthe run, not restored: player 1's captured field holds no illegal cell.

They don't last — the same matching rule, from its safe side. 78 frames after planting, the game clears them: empty cells are $FF, color nibble $F, so the two illegal cells chain with the empty space beside them into a run of four and erase. The row reads 81 82 82 FF 8F FF 8F FF: five consecutive cells of color $F, three merely empty. Vertically that runs off the bottom of the bottle and hangs. Horizontally the scan is bounded, so it clears and moves on.

Evidence

claimhow it was proven
It's stock Nintendo codeA5 5A 18 69 08 85 5A A5 5A 29 F8 F0 at file offset 0x1498 in retail drmario.nes, and in every derived cart. AI driver, coprocessor and tuck firmware each exonerated by enumerating their store sets.
The hang is this loopCaptured silicon state. Main loop parked at pill-placed step 6, match checking. Scan cursor $5A cycling 2→74→82→2…. Every other game byte frozen, NMI alive: frame counter saw all 256 values. 6 bytes of 2048 moved in 7 minutes.
The trigger is the trailing RAMKilled-mutant pair on the real ROM, arms differing only in the 128 trailing bytes. Captured tail → wedge, never recovers. Zeroed tail → same board plays to a completed match. An offline transcription reproduces the cursor cycle.
Legal play can't arm it4,000 random legal-color boards: zero hangs. The fixed routine produces byte-identical fields on all 4,000 — a provable no-op outside the bug.
Minimal triggerA single $8F cell at row 12 with empty space below hangs. At row 13 the chain is too short and it does not.

The fix — 3 bytes, same length, verified

; replace the wrap test with a real bound:
        lda fieldPos_tmp
        cmp #$80        ; 0x14A1: 29→C9, 0x14A2: F8→80
        bcs @verColorChainBroken  ; 0x14A3: F0→B0
The same emulator reproduction run twice, with the patch as the only variable. Both sides receive the same captured RAM, including the corrupted trailing bytes. Left, the unpatched retail bytes: the game stops dead and its picture never changes again. Right, the same cart with three bytes changed at file offset $14A1: the match resolves and play continues for the rest of the window.
Before and after, patch as the only variable. Both panels get the same captured RAM, including the corrupted trailing bytes that arm the hang. The carts differ in exactly three bytes, byte-compared: 0x14A1: 29 F8 F0 C9 80 B0 (file offsets, not CPU addresses). The 0.6 s before the restore is pixel-identical on both sides. Left, retail bytes: the scan never terminates, picture byte-identical for the rest of the window. Right, bounded scan: the match resolves, play continues. Reproduction in an emulator from captured silicon state, not a wild capture. Real time, every third NES frame, no frames altered or synthesised. The state was restored, not played into — the trigger needs color $F, which legal play cannot produce. The two multicolored cells in the left bottle are the amplifier above, on the patched side only, since player 2 must be alive to attack. The readout line is our annotation, counted from RAM each frame.

Kills every known hang, bounds the clear loop's writes to the field, byte-identical in effect on all 4,000 legal boards. The hang is not the only thing the missing bound produces — the clear loop writing past the field is a second expression of the same unchecked index. Most expressions of it are invisible. gated & shipped to the hardened cart lineage 2026-08-19

The second fix — and why it isn't in the cart

The hang fix stops the crash. It does not stop the corruption being planted, and on an unpatched machine a planted $8F above empty space is the trigger. Closing that means validating the color at the store. checkReleaseAttack has 9 store sites, file 0x1C2C–0x1C89 (CPU $9C1C–$9C76, bank 0), unrolled three times for 2, 3 and 4 garbage cells. Each has two bytes of slack, so per-site validation does not fit. Replace each site's ORA #$80 / STA (field),Y with JSR / NOP — same four bytes, no growth — and add one subroutine:

; the 11-byte validator; each of the 9 store sites becomes  jsr sub / nop
sub:    cmp #$03
        bcc +
        lda #$00       ; illegal color -> a legal one
+       ora #singleHalfPill
        sta (fieldPointer),Y
        rts

11 bytes of new code, 36 bytes of in-place rewiring. Be precise about what that buys: it prevents new illegal cells, it does not erase ones already in RAM. Since those self-clear in 1.3 s, that is the whole job.

The two-byte version is worse than the bug. AND #$03 in place of the clamp maps $0F to color 3. That is not $F, so it cannot chain with empty cells and cannot hang — but it matches nothing legal and nothing empty, so it never clears, where $8F self-clears in 1.3 s. A cheaper patch that converts a transient artifact into a permanent one.

Eleven bytes is more than this cart has. Every run of $00/$FF filler of 11 bytes or more, in both banks the planter can reach — bank 0 where it lives, and bank 3, fixed at $C000–$FFFF — is excluded:

  • $CEEC–$CEFC, 17 bytes and listed free in our own free-space map, is live code on this lineage: PHA / LDA $A02E / CMP #$40 / BNE / PLA / RTI.
  • $A02E, 16 bytes, is read by that same hook — across banks, which is how filler in one bank becomes data in another.
  • $DC41, 15 bytes of $00, is indexed table data: LDA $DC41,Y.
  • $A049 survives at 9 bytes$A058 starts another indexed table. Two short.

The three-byte fix was free because it was the same length: a bound in place of a wrap test, nothing new to house. The propagation fix needs somewhere to live, and a 64KB cart with an AI coprocessor already bolted into it has nowhere left. Making room means rewriting stock game logic, which would cost exactly the property that makes the first fix worth trusting. designed, costed, not shipped

Is this documented anywhere?

Not that we can find. The publicly known Dr. Mario (NES) defects are different bugs:

  • The controller-polling / DPCM glitch — music-driven input corruption, exploited by TASes, documented in the speedrun.com NNE guide and on TCRF. Hardware DMA, not a software scan.
  • The virus-erasure loop crash on very large simultaneous clears — a score-multiplier loop, not the match scan.
  • The Game Boy level-28 infinite loop — a different game with a published write-up. Puzzle generation, not match checking.

The strongest evidence of novelty is the community disassembly itself: checkVerMatch is fully transcribed and labeled, and the fatal test carries the annotation "Check if reached bottom". The bug passed through expert hands unrecognized, because the line looks like a bounds check and, absent corruption, behaves like one.

Honest scope: "undocumented" means not found in web search, the community disassembly's annotations, TCRF's indexed bug lists, or the speedrunning guides, as of Aug 2026. Absence of hits is not proof of absence. If this is written up somewhere obscure, we'd like to read it.

How it was found

An FPGA soak rig — MiSTer, CPU-vs-CPU with an AI coprocessor — froze twice in one evening on different seeds. Every modern-code suspect was eliminated by enumerating what it could and couldn't write, until the captured RAM pointed at a stock routine re-scanning one column of one bottle: six bytes ticking in a dead machine. What wrote the first illegal byte is still open, under instrumentation. The leading suspect is a known NMI-timing corruption family in the same setup, itself being hardened out.