flybrain/docs/design/platformer.md
acamilo 660c3cf00d
Some checks failed
ci / node 22 (test + typecheck) (push) Has been cancelled
ci / rust stable (cargo test --workspace --release) (push) Has been cancelled
ci / infra/tests/lint.sh (push) Has been cancelled
ci / playwright apps/stage (allowed to fail) (push) Has been cancelled
flybrain v0.4.0: public tree (history retained privately)
2026-09-21 15:09:46 +00:00

33 KiB

Design document produced 2026-09-15 by a planning agent. Post-MVP; the Rust GameAdapter trait and the stage per-game config are shaped so this drops in.

Design: the platformer adapter (demo 2)

Status: design, 2026-09-15. Read-only research pass. Every RAM address below carries a source. Anything I could not verify from source is marked UNVERIFIED.

Companion docs: docs/stream-mvp-plan.md (decisions), docs/streaming-plan.md §6 (superseded in part by §1 below), docs/readout.md, docs/integration.md, and the Pokemon adapter at ~/fly-plays-pokemon/src/reward/{catalog.ts,pokemon-red.ts} and src/runtime/ratchet.ts.

1. Game choice: Super Mario Land, primary. Kirby's Dream Land, fallback.

streaming-plan.md §6 recommended SML "if the world/stage and scroll search stalls, fall back to Kirby", because Data Crystal only lists world/stage as VRAM HUD tile indices and lists no global scroll (SML RAM map). That search is already done. Kasper Meerts' disassembly names all of it in HRAM:

Symbol Address Source
hGameState 0xFFB3 hram.asm
hWorldAndLevel (BCD nibbles, 1-1 = 0x11) 0xFFB4 hram.asm; encoding at bank0.asm:711, 746
hLevelIndex (0..11) 0xFFE4 hram.asm; cp a, $0C ; 12 levels in total bank0.asm:700-702
hScreenIndex 0xFFE5 hram.asm; incremented every 20 columns, bank0.asm:5215-5223
hColumnIndex (0..19) 0xFFE6 hram.asm; cp a, $14 ; 20 columns per screen? bank0.asm:5217
hScrollX (written straight to rSCX) 0xFFA4 hram.asm; bank0.asm:2775-2776
hCoins (BCD, 0..99) 0xFFFA hram.asm
hSuperStatus 0xFF99 hram.asm; 0 small, 1 growing, 2 super, 3+ i-frames (bank0.asm:1116, 1380-1385, 1408 InjureMario)
hSuperballMario 0xFFB5 hram.asm
hGamePaused 0xFFB2 hram.asm; bank0.asm:1083-1090
hWinCount (game clears) 0xFF9A, mirror wWinCount 0xC0E1 hram.asm, wram.asm; incremented at bank0.asm:3141-3144 after "THE END"
wScore (3 bytes BCD) 0xC0A0 wram.asm
wLives 0xDA15 wram.asm
wLivesEarnedLost 0xC0A3 wram.asm; +1 on 1UP (bank0.asm:1399), 0xFF on death (bank0.asm:925)
wGameTimer (3 bytes) / wGameTimerExpiringFlag 0xDA00 / 0xDA1D wram.asm
wGameOverWindowEnabled / wGameOverTimerExpired 0xC0A5 / 0xC0AD wram.asm
wInvincibilityTimer 0xC0D3 wram.asm

hGameState is a full jump table, listed verbatim at bank0.asm:2A6: 00 normal gameplay, 01 dead, 02 reset to checkpoint, 03 pre-dying, 04 dying animation, 05 explosion / score countdown, 06 end of level, 07 end-of-level gate, 08 increment level and load tiles, 09-0C pipe transitions, 0D autoscrolling level, 0E init menu, 0F start menu, 11 level start, 12 bonus game. Beyond the printed table there are labelled handlers up to GameState_3C; 0x39 prepares the "game over" text and 0x3A is the game-over wait (bank0.asm:4291-4348, and cp a, $3A / jr nz, .out ; game not over at bank0.asm:2784-2786). Data Crystal's "0x39 = Game Over" is therefore roughly right and the disassembly explains why.

Level length is also known statically. levels/levels.asm lists one screen pointer per screen, terminated by db $ff: 1-1 = 18, 1-2 = 17, 1-3 = 18, 2-1 = 19, 2-2 = 17, 2-3 = 21, 3-1 = 26, 3-2 = 19, 3-3 = 18, 4-1 = 26, 4-2 = 23, 4-3 = 27 (levels.asm). Levels start at hScreenIndex = 3 (ld a, $03 / ldh [hScreenIndex], a ; do all levels start on screen 3, bank0.asm:2071). Death restores hScreenIndex to one of 03, 07, 0B, 0F, 13, 17 with matching 0xC0AB values 0C, 34, 5C, 84, AC, D4 (GameState_02, bank0.asm:952-978), so the checkpoint grid is every four screens and is exactly known.

So a monotone in-level progress signal exists with no VRAM read: column = hScreenIndex * 20 + hColumnIndex, percent = (column - 60) / ((screens[level] - 3) * 20). 0xC0AB is a single-byte alternative, commented "sort of progress in the level in columns / 2" (bank0.asm:3361), but it is unnamed; prefer the two named HRAM bytes.

Is reading VRAM acceptable? No, and we do not need to.

Now verified, not unverified. binjgb's emulator_read_mem calls read_u8_raw -> read_u8_pair(..., raw=TRUE), and the MEMORY_MAP_VRAM case still goes through read_vram, which returns INVALID_READ_BYTE when is_using_vram(e, FALSE) (PPU mode 3, plus the tick before it via is_almost_mode3). The raw flag only suppresses ROM hooks (src/emulator.c, read_vram ~L1674, read_u8_pair ~L1957, emulator_read_mem ~L5194). By contrast emulator_get_wram_ptr and emulator_get_hram_ptr hand back raw pointers with no PPU gating (~L5186-5192). Decision: read WRAM and HRAM only, ideally through the bulk pointers; treat any VRAM read as a bug.

The rejected candidates

  • Wario Land: Super Mario Land 3. The Kak2X/wl disassembly is the best-labelled of the five: sLevelId 0xA804, sTotalCoins 0xA805-0xA807, sHearts 0xA808, sLives 0xA809, sLevelsCleared 0xA80D, sGameMode 0xA8C3, a true 16-bit global sLvlScrollX 0xA902/0xA903, sLevelCoins 0xA97A/0xA97B, sPaused 0xA908, sBossRoom 0xA995 (src/memory.asm). Rejected anyway: that state lives in cartridge SRAM, and binjgb's gb_read_ext_ram returns INVALID_READ_BYTE whenever ext_ram_enabled is clear (emulator.c ~L1243), so every sample would need the MBC1 RAM gate open; and Wario Land gates level entry behind a walkable world map, which a mostly-random walker will not navigate. Keep it as the third option if SRAM turns out to be readable every frame.
  • Donkey Kong '94. No Data Crystal RAM map (only Donkey Kong Land / Land III exist) and no disassembly with named RAM found. It is also a 101-stage puzzle-platformer where progress needs a key carried to a door; a random walker earns nothing. Deprioritised, agreeing with §6.
  • Mega Man: Dr. Wily's Revenge / Castlevania: The Adventure (GB). No complete named-RAM disassembly found; both are among the hardest GB games, and Castlevania's whip plus its slow movement means a random walker dies in the first screen. Reject.
  • Kirby's Dream Land: the fallback. huderlem/kirbydreamland names, in WRAM: wCurStage 0xD03B, wCurStageScreen 0xD03E, wStageLengthInMetatiles 0xD042 ("prevents scrolling too far to the right"), wStageScrollTileX 0xD051, wStageScrollTileY 0xD052, wPlayerScreenXCoord 0xD05C, wPlayerScreenYCoord 0xD05D, wRemainingLives 0xD089, wMaximumLives 0xD08A, wScore 0xD08B (3 bytes, little-endian) (wram.asm). Health at 0xD086 is used as a bare literal (bank_000.asm:173) and is named only by Data Crystal: UNVERIFIED. Kirby also has no game-mode symbol in that disassembly, so the playable gate would need its own RAM search. Its real advantage is forgiveness: Kirby floats and has a health bar, so a random walker survives far longer.

Verdict

Super Mario Land wins on RAM-map completeness (now equal to Kirby, better on game state), recognisability (a dedicated Twitch category, the sibling-of-Pokemon framing lands instantly), and the fact that its game timer kills a standing-still fly automatically, which keeps the stream moving without any stall penalty. Kirby wins only on forgiveness. IP risk is identical: SML is Nintendo R&D1 / Nintendo, Kirby's Dream Land is HAL / Nintendo, Wario Land is Nintendo (Super Mario Land, Mario Wiki). Same posture as Pokemon Red: local ROM only, gitignored, SHA-256 pinned, never shown, never linked.

Adapter identity: sml-progress-v1. ROM pin: SHA-256 of the local copy of Super Mario Land (World) (Rev A); the disassembly builds SHA-1 418203621b887caa090215d97e3f509b79affd3e (rom.sha1). Other revisions are UNVERIFIED for layout; anything else shows SEMANTIC REWARDS OFF, exactly as pokemon-red.ts:43 does.

2. Reward catalog

Same shape as src/reward/catalog.ts. All values positive. No penalties.

Kind Label Trigger Value Budget / cap Stim ms
started Start First boot -> playable transition observed 1 once per lifetime 250
band Ground Each new 10-column band of the current level reached (column = hScreenIndex*20 + hColumnIndex) 0.05 keyed band:<level>:<n> in the lifetime ledger; at most 2*(screens-3) per level (30 for 1-1, 48 for 4-3) 80
coin Coin hCoins 0xFFFA BCD increases (mod-100 wrap counts as +1) 0.02 40 payouts per level, lifetime 60
score Points wScore 0xC0A0 3-byte BCD increases 0.05 * min(1, delta/400) / (1 + floor(n/4)) where n = prior payouts in this level 20 payouts per level, lifetime 80
powerup Power-up hSuperStatus 0xFF99 reaches 2, or hSuperballMario 0xFFB5 becomes nonzero 0.5 first of each per level, lifetime 200
life 1UP wLives 0xDA15 increases 1 uncapped (rare: 100 coins or a 1UP mushroom) 250
level Level hLevelIndex 0xFFE4 rises past its lifetime max (cross-checked against hWorldAndLevel) 3 once per level, lifetime 400
world World High nibble of hWorldAndLevel 0xFFB4 rises past its lifetime max 5 once per world, lifetime 500
clear Game clear hWinCount 0xFF9A rises 10 once per lifetime 800

score deliberately stands in for "enemy defeated". Stomps pay through score floaties (hStompChain 0xFF9D, chain capped at 3, hStompChainTimer 0xFF9C = 50 frames, bank0.asm:1285-1301), and breakable blocks call AddScore with de = 0x0050 (bank0.asm:3917). Decoding the 160-byte enemy object table at 0xD100 to prove a kill is strictly harder and buys nothing the score delta does not already give.

Playable gate

All of these, every sample, before any payout:

  1. hGameState 0xFFB3 in {0x00, 0x0D} (normal gameplay, autoscroll). Excludes dying (03/04), score countdown (05), end of level (06/07), level load (08), pipes (09-0C), menus (0E/0F), level start (11), bonus game (12) and game over (39/3A).
  2. hGamePaused 0xFFB2 == 0.
  3. 0xFF9F == 0. bank0.asm:341 comments it "Equal to 28 in menu and during demo", and Call_2113 (bank0.asm:5060-5066) overwrites hJoyHeld from 0xC0DB only when 0xFF9F is nonzero, so this is the attract-demo gate. Name UNVERIFIED (unnamed in hram.asm); confirm with a watchpoint before shipping. Without it the attract demo would farm rewards.
  4. wGameOverWindowEnabled 0xC0A5 == 0.
  5. Consistency: high nibble of hWorldAndLevel in 1..4, low nibble in 1..3, hLevelIndex < 12, and hLevelIndex == (world-1)*3 + (level-1). A mismatch means a mid-transition or wrong-revision read; report TRANSITION and pay nothing. Cheap, and it catches a wrong ROM immediately.
  6. hScreenIndex in [3, screens[level]], hColumnIndex < 20, wLives >= 1.
  7. Three consecutive stable samples in the same level (mirrors pokemon-red.ts:122-125's stable >= 3).

Baselining

On the first valid sample (initialized === false), record without paying: the current level, every band key up to the current column, hCoins, wScore, wLives, hSuperStatus, hSuperballMario, hLevelIndex, world, hWinCount. A restored checkpoint mid-level therefore replays nothing, exactly as pokemon-red.ts:85-92 does. started pays only if a boot state was observed first (sawBoot).

Novelty caps so oscillation cannot farm

Bands are keyed positions in a lifetime Set, not "beat the previous best by N pixels". Walking left and right across a band boundary pays once, ever. Dying and re-running the same ground pays nothing: progress inside the level resets, the ledger does not. Coin and score payouts are per-level lifetime counters, so a coin that respawns after a death can be re-collected but can only be paid 40 times in that level for the whole run. score additionally decays as 1/(1 + floor(n/4)), mirroring the wild-KO decay at pokemon-red.ts:116.

Death pays nothing and costs nothing. This is doctrine, and it is also the anti-farm mechanism: the only repeatable income is capped, and the only large income is ground never reached before.

Safe snapshot (for the ratchet)

Everything in the playable gate, plus: hGameState == 0x00 exactly (not 0x0D; restoring an autoscroll vehicle level mid-flight is fragile), 0xC20A == 1 (on the ground; bank0.asm:1258 ld hl, $C20A ; 1 if on ground), 0xC207 == 0 (jump status 00 on ground / 01 ascending / 02 descending, bank0.asm:4409), wInvincibilityTimer 0xC0D3 == 0, hSuperStatus in {0, 2} (not 1 growing, not 3+ i-frames), wGameTimer 0xDA00 BCD >= 100 units, wGameTimerExpiringFlag 0xDA1D == 0, 0xFFF9 == 0 (not underground; comment-only name, UNVERIFIED), and the three stable samples.

3. Milestone ladder (0..15, 16 ranks)

Keeps the existing state.best > 15 guard in ratchet.ts:33 valid unchanged.

Rank Label Condition
0 BOOTING no playable sample yet
1 GAME STARTED first playable sample
2 FIRST COIN any coin payout
3 HALFWAY THROUGH 1-1 level 0 and column >= 60 + (18-3)*20/2 = 210
4 1-1 CLEARED hLevelIndex reached 1
5 1-2 CLEARED reached 2
6 WORLD 1 CLEARED (King Totomesu) reached 3
7 2-1 CLEARED reached 4
8 2-2 CLEARED reached 5
9 WORLD 2 CLEARED (Dragonzamasu, Marine Pop) reached 6
10 3-1 CLEARED reached 7
11 3-2 CLEARED reached 8
12 WORLD 3 CLEARED (Hiyoihoi) reached 9
13 4-1 CLEARED reached 10
14 4-2 CLEARED reached 11
15 GAME CLEARED (Tatanga) hWinCount rose

Formula for ranks 4..15: 4 + highestClearedLevelIndex. World names and the two vehicle levels are from Mario Wiki and agree with the disassembly, which switches to the submarine at hLevelIndex == 0x05 and the airplane at 0x0B (bank0.asm:1043-1052).

"First enemy defeated" is deliberately an event-ticker item, not a rank: ranks must be monotone and must correspond to a state worth archiving, and a stomp is neither.

4. Decoder preset platformer

New file packages/brain/src/readout/presets/platformer.ts, reusing GAMEBOY_BUTTONS, GAMEBOY_BUTTON_BITS, toButtonMask. Only DecoderConfig values change; no decoder code changes.

exclusive: {
  channels: { right: 'command_3', left: 'command_2', down: 'command_1', up: 'command_0' },
  decisionMs: 250, holdMs: 250, hysteresis: 1.25, fatigueGain: 0.04, fatigueDecay: 0.85,
},
pulses: [
  { channel: 'a',      role: 'command_4', holdMs: 300, cooldownMs: 420, threshold: 1.10 },
  { channel: 'b',      role: 'command_5', holdMs: 600, cooldownMs: 200, threshold: 1.05 },
  { channel: 'start',  role: 'command_6', holdMs: 55, cooldownMs: 600000, threshold: 2.0,
                       boot: { cooldownMs: 2500, threshold: 1 }, throttleGroup: 'system' },
  { channel: 'select', role: 'command_7', holdMs: 55, cooldownMs: 600000, threshold: 2.0,
                       boot: { cooldownMs: 2500, threshold: 1 }, throttleGroup: 'system' },
],
clearLockoutMs: 300,

Rationale, per change against gameboy.ts:

  • holdMs === decisionMs (250/250, vs 400/400 with an implicit gap). SML builds momentum in 0xC20C up to 6 and only then sets 0xC20E = 0x02; with no direction held, momentum decrements one per frame and 0xC20E clears (bank0.asm:4418-4429). Gaps in the right-hold therefore erase running speed, and running speed is what clears gaps. 250 ms is about 15 frames, fast enough to react to a floor edge, slow enough to be a commitment.
  • Keep all four directions. down enters pipes (Jmp_1765 tests hJoyHeld bit 7, bank0.asm:3456-3458) and crouches as Super Mario (bank0.asm:4446-4456). up is needed in the two vehicle levels. Dropping either would make 2-3 and 4-3 unplayable.
  • hysteresis 1.15 -> 1.25. Reversals in SML trigger a reverse animation and reset momentum (bank0.asm:4560-4577). A challenger should need a 25% lead.
  • fatigueGain 0.08 -> 0.04, fatigueDecay 0.8 -> 0.85. Habituation exists to break a stuck corner in Pokemon. In SML the game timer (wGameTimer 0xDA00) breaks a stuck fly for us, so weaker habituation is correct: the network should be able to hold right for many seconds.
  • a hold 85 -> 300 ms, cooldown 480 -> 420 ms. Super Mario Land has variable jump height, so a 85 ms tap is a minimum-height hop and can never clear a two-block gap. The game's own manual is the citation ("Press A to jump. To jump higher, press and hold A", quoted on Mario Wiki: Jump); the per-frame gravity code lives inside the region the disassembly still fills from baserom.gb (GameState_00, INCBIN "baserom.gb", $0627, $06BC, bank0.asm:4299-4300), so the exact hold-to-height curve is UNVERIFIED from source and must be measured. 300 ms is about 18 frames; 420 ms leaves a ~120 ms grounded gap for the landing.
  • b as a 600 ms hold with a 200 ms cooldown. B is run-faster and Superball/vehicle fire (Mario Wiki), which needs a hold, not a pulse. The decoder has no hold primitive outside the exclusive group, but because cooldownMs < holdMs, a channel whose score stays above threshold refires the instant the hold expires, giving a gapless sustained hold that releases within 600 ms of the score falling. Zero decoder changes.
  • start/select boot-only. The adapter passes boot = !playable (exactly pokemon-red.ts's BOOT mode), so the permissive 2500 ms / threshold-1 variant applies on the title screen and after a game over, letting the fly start a run; during play a 10-minute cooldown at threshold 2.0 makes pausing effectively impossible. Pausing is dead air on a 24/7 stream.
  • Keeping both in throttleGroup: 'system' is a safety property, not just a throttle. SML soft resets when A, B, Select and Start are all held in one frame (and a, $0F / cp a, $0F / jp Init, bank0.asm:1075-1079). Because a throttle-group fire writes nextAllowed for every member (decoder.ts:208-209), Start and Select can never be held simultaneously, so the reset combination is structurally unreachable. Assert it in a test.

Doctrine check. These are readout parameters, fixed once, identical for every run, carrying no learning; plasticity remains KC->MBON only. One item needs the operator's explicit sign-off: I put right first in channels, and insertion order breaks argmax ties (decoder.ts:187, and readout.md "Ties keep the earlier channel"). That is a mild fixed prior toward rightward travel. It should either be accepted and disclosed on the honesty panel, or the Pokemon order (up, down, left, right) kept for strict neutrality. My recommendation: accept it and disclose it, since a tie is measure-zero in practice and the label is cheap.

5. Recovery and ratchet rules

The Pokemon ratchet (ratchet.ts) is reused unchanged in shape: rank-monotone archive, capture on the first safe sample at a new best rank, stall window, attempt and lifetime budgets.

  • What is archived. The emulator state plus its exact 160x144x4 framebuffer, at the first safe (§2) sample whose rank exceeds state.best. Nothing below the best rank is ever archived; rank regression is rejected server-side as today.
  • Within-level granularity. None beyond rank 3 (halfway 1-1). Archiving arbitrary mid-level positions would mean restoring a stale game timer and a stale enemy table, and would break the rank-monotone invariant the archive retention logic depends on.
  • Restore trigger A, game over. hGameState observed as 0x39 or 0x3A, or wLives == 0 with wGameOverWindowEnabled set. Restore immediately, no stall wait: a game over discards the entire run and returns to a static title screen. This needs its own, larger budget, because the Pokemon limits (3 attempts per rank, 12 lifetime) would be spent within the first hour of a platformer. Proposal: 3 per rank, 48 lifetime, 60 s cooldown, disclosed on screen as a counter. Implemented as proposed. Pokemon's lifetime budget has since become 36 rather than 12 (docs/design/ladder.md, the 38-rung ladder), so the gap is narrower than this paragraph assumed, but the argument and the numbers here stand: 48 and the 60 s game-over cooldown are platformer::RECOVERY_POLICY, and Pokemon keeps RecoveryPolicy::default(). Only the budgets and triggers are per-game; the ratchet's rank bound is the running adapter's ladder length, 16 here, passed to Ratchet::import.
  • Restore trigger B, stall. No new band payout in the current level for 300 brain seconds, and at least 180 s since the last recovery, and attempts for this rank < 3. Same shape as Pokemon's 120 s / 180 s.
  • What resets the stall clock. Any new band, any rank increase, and sustained unsafe state for

    = 1 brain second (ratchet.ts:16-19 semantics unchanged; brief airborne frames must not age into a reset, which is why unsafeSince uses a 1 s floor).

  • What a restore does. agent.resetTransients(frame): release every button, clear decoder holds and fatigue, clear plastic eligibility, clear the adapter's transient state (stable counter, per-life score/coin latches), and refresh visual drive from a copy of the archived framebuffer. Untouched: brain membranes, RNG, clock, learned KC->MBON gains, and the entire lifetime reward ledger. Because the ledger survives, the restored ground pays nothing on the way back.

6. Stream differences

  • Milestone panel. The 16-rung ladder, plus a 4x3 level grid (1-1 .. 4-3) with cleared levels filled and the current level ringed. Hero number: FURTHEST IN 1-2: 63%, from (hScreenIndex*20 + hColumnIndex - 60) / ((screens[level]-3)*20), with screens[] the static table from §1. A per-level bar with a ghost marker at the lifetime best for that level, and ticks at the verified checkpoint screens 3, 7, 11, 15, 19, 23.
  • Stuck-o-meter. Time since the last new band, on a 5-minute dial (Pokemon uses 2). Beside it: LIVES 3, DEATHS THIS LEVEL 14, and the game timer as a secondary tension meter. Deaths are the single most legible "something is happening" signal a platformer gives; show them, and show that they are free.
  • Event ticker. COIN, +200, SUPER MARIO, SUPERBALL, 1UP, NEW GROUND, CHECKPOINT, LEVEL CLEARED, WORLD CLEARED, RECOVERY, and DIED (no penalty, ground kept). The parenthetical matters: viewers will otherwise assume dying punishes the fly, and the doctrine is that it does not.
  • Honesty panel additions. "Deaths are not punished. The fly is only paid for ground it has never reached." And: "Progress is measured from the camera, not from Mario" (the loader front runs up to one screen ahead of the camera; see below).
  • Retina input. projectFrame in packages/brain/src/model/retina.ts is nearest-neighbour Rec. 709 luminance times gain = 0.20, one pixel per column, with no temporal filter, no motion detector and no contrast normalisation. A side-scroller changes almost every sampled pixel every frame, where Pokemon's maps are static between 16-pixel steps. Two concrete risks:
    1. Score saturation. SML's skies are the lightest DMG shade across most of the upper screen, so mean drive is higher and more uniform. Decoder scores are ratios against a single calibrate() taken at rest on a near-black boot screen (readout.md, "Score"). If every role's rate sits well above its baseline, threshold-1 pulses fire at their cooldown limit forever (A becomes a metronome) and the exclusive argmax is decided by noise-scale differences.
    2. Refractory compression. If rates instead hit the LIF ceiling, score differences compress and the tie-break order (see §4) effectively decides direction.
  • What to measure, over a 30-minute run, against a matched Pokemon run: distribution of per-column drive (mean, p50, p95, fraction at 0, fraction at max); mean and p95 per-role rate for command_0..7; the eight normalized scores (mean, s.d., fraction above 1.0 / 1.10 / 2.0); fraction of decode() calls whose argmax margin is below hysteresis; per-neuron spike-rate histogram versus the Pokemon baseline; total retina input current per brain ms.
  • Decision rule. If more than ~80% of A-channel samples exceed threshold, raise the A/B thresholds (a documented readout parameter) rather than touching retina.gain; changing the retina changes the model, changing a threshold changes the readout. Either way, record it in the compatibility fingerprint.

7. Verification plan

Synthetic traces (a fake MemoryReader over a byte map, one test per rule, mirroring what rewards-learning.md describes for Pokemon):

boot -> playable pays started once; band novelty; band oscillation immunity across a boundary; band cap at 2*(screens-3); death resets in-level position, pays nothing, and re-paying the same bands is impossible; coin BCD increase; coin BCD wrap 0x99 -> 0x00 counts as +1 and never as a decrease; score 3-byte BCD delta with decay and cap; powerup 0 -> 2 pays once per level, 2 -> 3 (injury) and 3 -> 2 pay nothing; superball 0 -> 1 pays once; 1UP via wLives rising; level clear on hLevelIndex 0 -> 1 with hWorldAndLevel 0x11 -> 0x12; world clear on 0x13 -> 0x21; game clear on hWinCount rising; attract-demo gate (0xFF9F != 0 pays nothing); pause gate; autoscroll level is playable but never safe; game-over states 0x39/0x3A; world/level/index inconsistency reports TRANSITION; mid-level import baselines and pays nothing; invalid checkpoint import throws; one-read-per-address caching.

Decoder tests: right-holds are gapless (holdMs === decisionMs); B is a sustained hold and releases within 600 ms of the score dropping; A fires at most once per 420 ms; Start cannot fire during play at threshold 2.0 but can in boot; A, B, Start and Select are never simultaneously active (guards the bank0.asm:1075 soft reset); clearHolds lockout is 300 ms.

Ratchet tests: rank ladder monotone and rank = 4 + highestClearedLevelIndex; capture only on safe samples (on ground, jump status 0, not i-frames, timer > 100, not underground, not autoscroll); game-over restore path with its own budget; stall restore path; budget exhaustion leaves the run unrecovered; rank regression rejected; restore preserves brain state, gains and the lifetime ledger.

ROM-gated integration test, skipped unless FLY_ROM_PLATFORMER is set (mirroring POKEMON_ROM): assert the ROM SHA-256 equals SUPPORTED_ROM; boot to playable within N frames with only neural input; at the first playable sample assert hWorldAndLevel == 0x11, hLevelIndex == 0, hScreenIndex == 3; adapter mode reads IN LEVEL 1-1; 60 brain seconds of play produce at least one reward event; a seeded-archive variant (a RATCHET_CHECKPOINT-equivalent env var) exercises the restore path without waiting out the real stall.

30-minute random-walker baseline. Replace the network's role rates with a seeded uniform random source, keep the real platformer decoder and the real adapter, run 30 brain minutes across 5 seeds headless. Report: levels reached, furthest band per level, deaths, coins, score, total reward, reward per brain minute, per-channel active-frame fractions. Calibration target: 0.5 to 2.0 total reward per brain minute, dominated by band and coin, and 1-1 cleared in at most 1 of 5 seeds. If random clears 1-1 in every seed, coarsen bands to a full screen and halve coin; if random earns under 0.1/min, halve the band width. This calibrates the reward scale only; it is not evidence about the fly, and the numbers must never be presented as such.

8. Open items and risks

  1. Unnamed addresses this design depends on. 0xFF9F (attract-demo gate, the most important one: without it the demo farms rewards), 0xFFF9 (underground), 0xFFF4/0xFFF5 (pipe exit), 0xC0AB (column progress), 0xC0D2 (end-of-level counter), and the Mario physics bytes 0xC207/0xC20A/0xC20C/0xC20E. All are comment-only in the disassembly, so all are UNVERIFIED. Confirm each with bgb or mGBA watchpoints in a one-day spike before the adapter ships.
  2. hScreenIndex is the column loader, not Mario. It advances as hScrollX wraps and runs up to one screen ahead of the camera (bank0.asm:5215-5223). It is monotone within a level and resets to a checkpoint on death, which is exactly the progress signal we want, but it is camera progress and must be labelled as such on screen.
  3. Underground aliasing. Pipe sub-rooms reuse hScreenIndex, so underground bands would alias onto the main level's bands. Safe v1: suppress band payouts and never archive while 0xFFF9 != 0. Revisit once 0xFFF4/0xFFF5 are verified.
  4. Jump physics not source-verified. GameState_00 is still INCBIN-ed from baserom.gb (bank0.asm:4299), and the README puts bank 0 coverage at three quarters and HRAM at 46 of 127 bytes identified. The A-hold-to-height curve therefore rests on the manual plus measurement, and the 300 ms holdMs is a starting estimate to be tuned against the random-walker baseline.
  5. ROM revision drift. The disassembly pins Super Mario Land (World) (Rev A). Layout on Rev 0 or the JP release is UNVERIFIED. Pin one SHA-256, fail visibly with SEMANTIC REWARDS OFF otherwise, and fold the hash plus sml-progress-v1 plus the preset name into the compatibility fingerprint so a checkpoint cannot be loaded under different semantics.
  6. Latency. At ~16.74 brain ms per frame with 250 ms direction commitments and 300 ms jump holds, frame-precise platforming is impossible. Expect the fly to live in world 1 and to clear 1-1 rarely. Say so in the stream copy; it is the honest framing, and it is the reason band is the primary reward rather than level.
  7. VRAM is closed off, and that is now a verified fact rather than a worry (see §1): binjgb's public read path returns 0xFF for VRAM during PPU mode 3. Any future temptation to read the HUD should be refused.
  8. IP. No candidate has a risk advantage: SML, Kirby's Dream Land and Wario Land are all Nintendo-published. Exposure is the ROM, not the game, identical to Pokemon Red. Category discovery favours Super Mario Land, which is the one real asymmetry and it favours the primary choice.
  9. Fallback trigger. If 0xFF9F or the underground aliasing cannot be resolved in two days, switch to Kirby's Dream Land on the symbols in §1, accepting that its health byte and its playable gate both need their own RAM search and that its 5-stage structure compresses the ladder to about 10 ranks.

Critical files for implementation

  • packages/brain/src/readout/presets/gameboy.ts (template for the new platformer.ts)
  • packages/brain/src/readout/decoder.ts (hold/pulse/throttle semantics the preset relies on)
  • ~/fly-plays-pokemon/src/reward/pokemon-red.ts (adapter shape: per-sample byte cache, gates, once() ledger, baselining, export/import)
  • ~/fly-plays-pokemon/src/reward/catalog.ts (catalog shape and stimulation-ms field)
  • ~/fly-plays-pokemon/src/runtime/ratchet.ts (rank-monotone archive, stall window, budgets; needs the game-over trigger and the larger budget)
  • packages/brain/src/model/retina.ts (the luminance projection whose saturation §6 asks you to measure)

Sources: kaspermeerts/supermarioland (hram.asm, wram.asm, bank0.asm, levels/levels.asm, rom.sha1) · huderlem/kirbydreamland wram.asm · Kak2X/wl src/memory.asm · binji/binjgb src/emulator.c · Data Crystal: Super Mario Land RAM map · Data Crystal: Kirby's Dream Land RAM map · Data Crystal: Donkey Kong (Game Boy) · Mario Wiki: Super Mario Land · Mario Wiki: Jump