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 globalsLvlScrollX 0xA902/0xA903,sLevelCoins 0xA97A/0xA97B,sPaused 0xA908,sBossRoom 0xA995(src/memory.asm). Rejected anyway: that state lives in cartridge SRAM, and binjgb'sgb_read_ext_ramreturnsINVALID_READ_BYTEwheneverext_ram_enabledis 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 at0xD086is 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:
hGameState 0xFFB3in{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).hGamePaused 0xFFB2 == 0.0xFF9F == 0. bank0.asm:341 comments it "Equal to 28 in menu and during demo", andCall_2113(bank0.asm:5060-5066) overwriteshJoyHeldfrom0xC0DBonly when0xFF9Fis nonzero, so this is the attract-demo gate. Name UNVERIFIED (unnamed inhram.asm); confirm with a watchpoint before shipping. Without it the attract demo would farm rewards.wGameOverWindowEnabled 0xC0A5 == 0.- Consistency: high nibble of
hWorldAndLevelin 1..4, low nibble in 1..3,hLevelIndex < 12, andhLevelIndex == (world-1)*3 + (level-1). A mismatch means a mid-transition or wrong-revision read; reportTRANSITIONand pay nothing. Cheap, and it catches a wrong ROM immediately. hScreenIndexin[3, screens[level]],hColumnIndex < 20,wLives >= 1.- Three consecutive stable samples in the same level (mirrors
pokemon-red.ts:122-125'sstable >= 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 in0xC20Cup to 6 and only then sets0xC20E = 0x02; with no direction held, momentum decrements one per frame and0xC20Eclears (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.
downenters pipes (Jmp_1765testshJoyHeldbit 7, bank0.asm:3456-3458) and crouches as Super Mario (bank0.asm:4446-4456).upis needed in the two vehicle levels. Dropping either would make 2-3 and 4-3 unplayable. hysteresis1.15 -> 1.25. Reversals in SML trigger a reverse animation and reset momentum (bank0.asm:4560-4577). A challenger should need a 25% lead.fatigueGain0.08 -> 0.04,fatigueDecay0.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.ahold 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 frombaserom.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.bas 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 becausecooldownMs < 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/selectboot-only. The adapter passesboot = !playable(exactlypokemon-red.ts'sBOOTmode), 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 writesnextAllowedfor 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.
hGameStateobserved as0x39or0x3A, orwLives == 0withwGameOverWindowEnabledset. 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 areplatformer::RECOVERY_POLICY, and Pokemon keepsRecoveryPolicy::default(). Only the budgets and triggers are per-game; the ratchet's rank bound is the running adapter's ladder length, 16 here, passed toRatchet::import. - Restore trigger B, stall. No new
bandpayout 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-19semantics unchanged; brief airborne frames must not age into a reset, which is whyunsafeSinceuses 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), withscreens[]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.
projectFrameinpackages/brain/src/model/retina.tsis nearest-neighbour Rec. 709 luminance timesgain = 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:- 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. - Refractory compression. If rates instead hit the LIF ceiling, score differences compress and the tie-break order (see §4) effectively decides direction.
- 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
- 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 ofdecode()calls whose argmax margin is belowhysteresis; 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
- 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 bytes0xC207/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. hScreenIndexis the column loader, not Mario. It advances ashScrollXwraps 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.- Underground aliasing. Pipe sub-rooms reuse
hScreenIndex, so underground bands would alias onto the main level's bands. Safe v1: suppressbandpayouts and never archive while0xFFF9 != 0. Revisit once0xFFF4/0xFFF5are verified. - Jump physics not source-verified.
GameState_00is stillINCBIN-ed frombaserom.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 msholdMsis a starting estimate to be tuned against the random-walker baseline. - 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 OFFotherwise, and fold the hash plussml-progress-v1plus the preset name into the compatibility fingerprint so a checkpoint cannot be loaded under different semantics. - 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
bandis the primary reward rather thanlevel. - VRAM is closed off, and that is now a verified fact rather than a worry (see §1): binjgb's
public read path returns
0xFFfor VRAM during PPU mode 3. Any future temptation to read the HUD should be refused. - 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.
- Fallback trigger. If
0xFF9For 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 newplatformer.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