diff --git a/GOLD-OPEN-ITEMS.md b/GOLD-OPEN-ITEMS.md deleted file mode 100644 index b0970466..00000000 --- a/GOLD-OPEN-ITEMS.md +++ /dev/null @@ -1,275 +0,0 @@ -# Gold port: what is still open after the 2026-08-09 asm parity runs - -Two multi-agent runs diffed the Gold port against the `pokegold` disassembly -(`../pokegold`) and fixed what diverged. Round 1 confirmed 51 defects across 14 -paired clusters; round 2 confirmed 41 more across 13, including one crash. A -third round (2026-08-10) closed the mod-API boundary: see "The 2026-08-10 -mod-API round" below for what that opened and closed. - -Every tier is green at the end of all three: `run_engine` 131/131, `run_tests` -ALL TESTS PASSED, `run_modkit` 6/6, `run_save_editor_tests` 650/0, -`run_link_tests` ALL PASSED. - -This file records only what is NOT done, so nobody re-derives it. It is a -working handoff doc in the shape of `KANTO-CONTINUE.md`, not user documentation. - -## Do this first or none of it is visible - -**Re-import the Gold cache.** Seven fixes live in the extractor and the manifest -tool, so they do nothing until the ROM is re-imported. This was verified still -outstanding on 2026-08-10: the default identity's -`gold/data/generated/menu_gfx.lua` has no `trainerPics` key at all, so a trainer -battle still opens on the mon rather than the trainer even though the Lua half -is fixed and correct. - -- trainer frontpics (`TrainerPicPointers` walk in `extractMenuGfx`), which is - the missing trainer intro image at the start of a trainer battle. The Lua - side is now right and re-import is the ONLY thing left: `Trainers.lookup` - returns a numeric `class` (36) beside a `classId` (`BUG_CATCHER`), and both - `menu_gfx.trainerPics` and `palettes.trainers` are keyed by the constant, so - the lookup had to move to `classId`. On a cache that carries the pics the - frontpic renders in the class's own palette. -- egg assets: `battle/front/egg.png` and `menu/egg_hatch.png`, feeding the - hatch cutscene and the summary screen's egg page. A fallback landed in the - meantime (`ICON_EGG` frame 0, which resolves to a real 16x16 frame), so the - egg page draws SOMETHING on a stale cache; `tools/rom_manifest_gold.json` - carries `EggPic` at `[20, 31363]` and that stream decompresses to exactly 400 - bytes (a 5x5-tile pic), so a fresh import takes the real path. -- the Pokegear phone icon. `_LoadFontsExtra` (`engine/gfx/load_font.asm:8-15`) - puts `FontsExtra_SolidBlackAndUpArrowGFX` at `$60`/`$61` and - `PokegearPhoneIconGFX` at `$62`, ON TOP of `FontExtra`'s BOLD_A/B/C. - `src/import/RomExtractorGen2.lua:342-356` reads `FontExtra` straight in and - then blits only `Frames` over `$79-$7E`, so `$62` is still a bold "C" -- which - is what the new caller box draws where the telephone glyph belongs. Fix is two - files: add the two GFX symbols to `tools/rom_manifest_gold.json`, then blit - them at `$60`/`$61`/`$62` in the loop that already handles `Frames`. -- `PREDEFPAL_UNOWN_PUZZLE = 76`, which is why the Ruins of Alph puzzle rendered - grayscale instead of brown -- the tilemap pad `0x7f -> 0x4f` in `readTilemapRLE` / `readFlatTilemap` -- held-item icons (`HeldItemIcons` in the manifest, `out.heldItem` in - `extractIcons`), which is the held-item marker in the party menu -- the NPC-trade rows' `item` field, which is what made the Violet City Onix's - Bitter Berry print as "83" - -## Open: real unknowns, each needs a cache and a driver run - -1. **Magnet train renders a blank field.** Pushing `World:magnetTrain` directly - shows nothing for 90+ frames (see shot `03-magnet-train.png`). The screen's - surround is correct now; what was never investigated is whether - `drawBackground` needs setup that the direct push skips. This cannot be - settled by reading source. - -2. **The NPC that introduces Unown never appears.** Round 1 could not produce a - port ref that diverges from the asm, so it is filed `cannot_locate`, not - fixed. The cart chain is: solve a chamber puzzle, then - `setmapscene RUINS_OF_ALPH_INNER_CHAMBER, SCENE_RUINSOFALPHINNERCHAMBER_STRANGE_PRESENCE` - from `maps/RuinsOfAlphKabutoChamber.asm`. Walk that chain live rather than - statically: the static read came back clean twice. - -3. **Two pic sites still resolve an Unown off the species.** - `src/ui/gen2/PhotoStudio.lua:102` and `src/ui/gen2/EvolutionAnim.lua:460` use - `def.spriteFront` without going through `Unown.formSprite`. For EvolutionAnim - this is almost certainly dead (Unown does not evolve). For PhotoStudio it is - genuinely unclear: the cart's `engine/events/print_photo.asm` does not call - `GetUnownLetter` at all, so the port may already match. Check before changing. - -4. **Unown dex registration on obtain paths.** `engine/pokemon/evolve.asm:310` - runs `GetUnownLetter` then `callfar UpdateUnownDex` after - `SetSeenAndCaughtMon`, guarded by `cp UNOWN`. That is dex bookkeeping, not - rendering, and it was never audited across every path that gives the player a - mon. `GetUnownLetter` has 20 call sites on the cart; the port covers the pic - ones. - -## The 2026-08-10 mod-API round - -Six lanes closed the Gen 1 / Gen 2 mod-API boundary and a batch of reported -gameplay bugs. What that round **closed**, so nobody re-opens it: - -- Fifteen seams that had a Gen 2 site but no entry in the parity gate are now - listed and asserted: the four `intro.oak_speech.*` events plus the - `intro.oak_speech.build` hook, `battle.overlay`, - `battle.low_health_alarm`, `battle.catch_exp`, `pokemon.sprite`, - `input.step`, `input.pointer`, `render.zones`, `render.compose`, - `render.letterbox`, `render.hud`. Five more (`intro.boot.*`) are listed as - Gen 2-only. `tests/engine/gate_gen2_mod_api.lua` is 893/893. -- Two registries un-gated on Gold after their consumer landed: - `battle_sprite_scales` (`src/ui/gen2/BattleState.lua:imageScale`) and - `render_pipelines` (`src/core/Game2.lua:load` installs - `src/render/Pipelines.lua` on the merged dataset after `mods:load`). Both - keep the SHARED Gen 1 target, so one mod record serves both games. 40 of the - 46 registries are now available on Gold. -- `hook:render.zones` came off the `gate_meta_coverage.lua` DEBT ledger, which - is now four entries, all M7/M12 link and give-mon seams. - -What that round **left open**, each verified on 2026-08-10: - -1. **`render_pipelines`' `drawWorld` half is inert on Gold.** Gold's overworld - draws straight to the window rather than into a canvas the way - `src/world/OverworldController.lua` hands one to `Pipelines.drawWorld`, so a - drawWorld-only pipeline renders nothing. It is not left switched on - pretending: `Game2:load` retires a restored level for one and re-applies - Tilt from the option the exclusion just cleared, leaving - `options.pipelines` untouched so the mode returns the day Gold grows a world - canvas. Related: `Pipelines.rows` is read only from - `src/ui/OptionsMenu.lua`, so a pipeline on Gold has a hotkey and no OPTION - row. - -2. **`transitions` stays gated and should.** `src/ui/gen2/BattleTransition.lua` - keys `STYLES` as a boolean SET of the four cart wipes (`spin`, `speckle`, - `zoom`, `sine`), not the `{ frames, draw, sound, flash }` record the - registry carries, and there is no styleDef lookup a mod id could reach -- - a registered style would fail the membership test and fall back to vanilla. - Un-gating it before that changes would be the silent no-op the routing table - exists to prevent. Same for `rulesets`, `field`, `text_pointers`, - `link_fields` and `map_scripts`; `docs/mod-api-gen2-compat.md` carries the - per-registry reason. - -3. **`src/core/Game2.lua:joystickremoved` is half of Gen 1's.** - `src/core/Game.lua:867` does `self:recoverInput("joystickremoved", joystick)` - AND `TouchControls:joystickremoved()`; Game2 does only the pad half, so a - controller unplugged mid-hold leaves Gold's Input state stranded and a held - direction walks forever. It was a total noop before, so this is not a - regression -- it is a seam finished halfway. One line. - -4. **`Game2` noops five joystick callbacks.** `joystickpressed`, - `joystickreleased`, `joystickaxis`, `joystickhat` and `joystickadded` are - assigned `noop`, so a stick with no SDL game-controller-database entry - reaches neither Input nor `GamepadMap.RAW_BUTTON_BINDINGS` on Gold. Gen 1 - handles all four (`src/core/Game.lua:764-797`). A DualSense is - SDL-recognized and takes the gamepad path, so this is not the reported - SELECT bug -- it is the raw-stick fallback. - -5. **`Game2:gamepadpressed` has no SELECT-held guard.** Gen 1 - (`src/core/Game.lua:686-694`) suppresses the shoulder GAME SPEED cycle while - SELECT is held, because Select+L is a display chord on NX. Harmless today - (Gold has no display chord) but the two paths have diverged. - -6. **`hideCallerBox`'s fallback path skips the screen seams.** - `src/script/gen2/CallAsm.lua:217-231` pops through `stack:pop` when the - caller box is top (which it always is on a normal call, since `PhoneRing` - runs `closetext` before `InitCallReceiveDelay`) but falls back to a bare - `table.remove(states, index)` otherwise, which raises neither `exit` nor - `screen.popped`. Only a mod screen pushed over the box can reach it, and - then a listener that saw `screen.pushed` never sees the pop. - -7. **`src/core/gen2/Roamers.lua:338` says the wrong thing.** Its comment says - "the shared `encounter.species` hook still runs downstream and is where a - mod changes what appears". It does not: `World:tryWildEncounter` calls - `startBattle` on a roamer hit and RETURNS before it reaches - `World:rollEncounter`. The doc's partial-coverage list is right and the - comment is wrong; correct the comment, not the doc. - -8. **`src/world/gen2/World.lua:4318` still calls the pack's SEL row something - "this port has not built".** The pack submenu now offers SEL. Stale comment. - -Three partial coverages that are documented and still true, repeated here -because "the hook exists" is not "the hook sees everything": -`encounter.roll` / `encounter.species` are not wired into `World:tryHeadbutt`, -`World:rockMonEncounter` or the roamer path; `src/ui/gen2/BattleState.lua` -builds a flat `opts` for `Catching.attempt` with no `data` in it, so a -mod-registered ball is readable through `Catching.recordFor` but is not -resolved at the real throw site; and no Gen 2 UI file reads -`Battle.statusRecordFor(data, status).hudLabel`, so a mod status shows no label -in the battle HUD, the party menu or the summary page. - -## Open: cleanups declined on purpose - -These were skipped with reasons during the wiring pass. They are listed so the -reasons survive, not because they are pending work. - -1. **`Vm:showRaw(body, stay)`** was proposed and declined. `showRawHeld` in - `Specials.lua` already answers the text lookahead correctly, so this trades - one working shape for another. - -2. **`tests/drivers/gold_egg_hatch.lua` summary push** was declined as - redundant: `gold_egg_hatch_shots.lua` already photographs both the cutscene - and the summary page. - -3. **Two `Battle.lua` divergences** were filed as notes by the file's own owner - rather than fixed, because no reported symptom drives either: text printed - for an already-statused target, and Future Sight taking STAB, type - effectiveness and weather. Both are real divergences from the cart. Fix them - when something actually depends on them. - -## Reported, investigated, and NOT defects: do not re-chase these - -Each of these came in as a bug report and came back disproved against the asm. -Re-opening one costs another full investigation, so the reasoning is kept here. - -- **SFX pointer table misalignment.** The table is aligned end to end. 188 `dba` - rows with no skips or padding, 188 constants, verified through the manifest - scraper, the extractor's `*3` stride, and 8 ids spot-checked against the live - cache. Two things that look like misalignment are how the cart is built: - `SFX_GET_EGG_UNUSED` and `SFX_GET_EGG` both point at `Sfx_GetEgg`, and - `Sfx_ReadText` / `Sfx_ReadText2` share one header address. **Do not repoint, - re-stride, or add an offset to the SFX, music, or cry tables.** - -- **Bind letting you pick other moves.** This is Gen 1 behavior and does not - hold for Gen 2. `BattleCommand_TrapTarget` (`effect_commands.asm:5568-5605`) - writes only the TARGET's wrap count and trapping move. It touches nothing on - the user, so the user is free to switch moves. The port is correct. The real - Gen 2 move locks (Rollout, Thrash, Petal Dance) were genuinely missing and - have been added. - -- **The Slowpoke Tail salesman not blocking progression.** He is Route 32's - `FISHER4` at (7,70), not an Azalea Town object, and the cart's refusal arm - only prints. `_OfferToSellSlowpokeTail` runs `setscene SCENE_ROUTE32_NOOP` - first, so the coord event never fires again. There is no pushback to port. - -- **The rival being named BLUE.** Not reproducible. The whole chain is intact: - special 36 resolves to `H.NameRival`, `NamingScreen:accept` hands the typed - string through, and BLUE is the Gen 1 default that Gold never reaches. A - separate real defect on this seam (the rival being pre-named SILVER before the - officer asks, and the blank-entry fallback) was found and fixed. - -- **Not Very Effective dealing 0 damage**, **status not shown in the battle - UI**, and **the Pokedex missing from the Start Menu** all came back - `already_fixed`: the damage floor is `MIN_DAMAGE` added after the cap and - before the type multiply, the HUD prints the status tag where the level goes, - and the menu row is gated on `save.engineFlags[11]` correctly. - -## Standing risks, not breakage - -- **Four Gold test files are wired into no runner**, so a regression in them - will not turn `run_tests.lua` red: `gold_flag_names_test`, - `gold_route_validate_test`, `gen2_pokegear_unlock_test`, - `gen2_save_export_test`. All four pass standalone. The first two are the - pre-run route validators documented in `KANTO-CONTINUE.md`; the last two need - a `GOLD_CACHE` and are excluded by an in-file comment. This is deliberate, but - it is a coverage hole and worth knowing. - -- **`mods/` must stay clean.** The 2026-08-10 round left temporary probe mods - behind mid-run (`mods/tmp_menus`, `mods/zz_verify_seams`); both are gone now - and `mods/` holds only `example_mew_starter`, `example_silly_oak`, `examples` - and `nuzlocke`. A stray `mods/tmp_*` loads on every Gold boot and changes what - a driver measures. `mods/example_silly_oak` carries only a `.modkitignore` and - warns "manifest.json does not exist" on every boot; that is pre-existing - (27 Jul) and unrelated. - -- **`tests/drivers/gold_opaque_surround.lua` is stale.** It reads - `game.stack._items`; `src/core/StateStack.lua` has only `.states`. That branch - is covered instead by `gold_center_pc` and the letterbox-per-frame count in - `gold_frame_seams`. - -- **`luac` on this machine is Lua 5.5**, so `luac -p` is a weak proxy for the - LuaJIT/5.1 semantics this engine targets. Use `luajit -b /dev/null` for - a real syntax gate. - -- **`docs/rfcs/0001-surfing-pikachu-sprite.md` and `0002-screen-render-visible.md` - are deleted** in the working tree, and both are still referenced by name from - live modkit case files that pass. Those deletions predate both parity runs. - Worth resolving before a commit. - -## The failure mode that actually bit, worth remembering - -Round 1 reported the Yes/No dialogue fix as landed. It was not. Two of its three -lanes wrote their half, and the third left the hook closure at -`World.lua:828` declared as `function(body, onDone)`, so the third argument -`Vm:resume` passes was silently discarded and the entire `stay` implementation -in `World:showText` and `World:askYesNo` was dead code. Lua drops extra -arguments without complaint, so nothing failed and every suite stayed green. - -Round 2 caught it only because an investigator probed the boundary empirically -instead of reading both sides and assuming they met. When work is split across -agents by file, **the seams between the files are where the bugs live**, and a -green suite does not prove a seam is connected. diff --git a/docs/behavior-porting-notes.md b/docs/behavior-porting-notes.md deleted file mode 100644 index d26affce..00000000 --- a/docs/behavior-porting-notes.md +++ /dev/null @@ -1,519 +0,0 @@ -# Behavior porting notes - -What was ported from pokered's engine code and where it came from. - -## Overworld - -- **Collision rule** (`home/overworld.asm` tile-in-front checks): a 16x16 - cell is passable when its bottom-left 8x8 tile is in the tileset's - `coll_tiles` list. Verified against Pallet Town's fences/houses/water - and Oak's Lab furniture. -- **Warp activation** (`home/overworld.asm` CheckWarpsNoCollision / - ExtraWarpCheck): a warp fires when arriving on a warp whose standing - tile is in the tileset's door or warp tile list, or when standing on a - warp and walking off the map edge (interior exit mats). Both paths are - data-driven from `door_tile_ids.asm` / `warp_tile_ids.asm`. -- **LAST_MAP warps** return to the remembered outdoor map/position, like - `wLastMap`. -- **Connections** (`map_header` connection directives): crossing an edge - places the player at `destCoord = curCoord - offset*2` cells on the - destination's opposite edge. -- **Movement**: tile-by-tile, 1 px/frame at 60 fps (16 frames per step), - tap-to-turn without stepping, hold-to-walk, input locked mid-step. -- **Wild encounters** (`engine/battle/wild_encounters.asm`): per grass - step, encounter iff `rand(0..255) < rate`; slot picked via the - cumulative buckets 51/102/141/166/191/216/229/242/253/256. -- **Initial object visibility** from `toggleable_objects.asm` (e.g. Oak - hidden in his lab), with `show_object`/`hide_object` script commands - persisting to the save like the missable-object bits. - -## Pokémon math (`engine/pokemon/calc_stats.asm`, `experience.asm`) - -- `stat = floor(((base + DV)*2 + floor(sqrt(statExp)/4)) * L / 100) + 5` - (HP: `+ L + 10`); HP DV from the low bits of the other four DVs. -- Growth curves use the exact cubic coefficients (MEDIUM_SLOW = - 1.2n^3 - 15n^2 + 100n - 140, etc). -- Exp gain = `floor(baseExp * level / 7)` (x1.5 for trainer battles); - defeated species' base stats accumulate as stat experience. - -## Battle core (`engine/battle/core.asm`) - -- Damage: `floor(floor(2L(x2 crit)/5 + 2) * power * atk / def / 50)` - capped at 997, `+2`, STAB x1.5, per-matchup type multipliers applied - sequentially (x10 fixed point), then `rand(217..255)/255` when - damage > 1. -- Critical hits: `rand(0..255) < baseSpeed/2` (x4 for Karate Chop, Razor - Leaf, Crabhammer, Slash, capped 255); crits double level and ignore - stat stages (gen1_faithful ruleset). -- Accuracy: `rand(0..255) < floor(acc*255/100)` after accuracy/evasion - stages, including the 1/256 miss at 100% accuracy (toggleable via the - `modern_clean` ruleset). -- Stat stages use the 25/28/33/40/50/66/100/150/.../400 multiplier table - (`data/battle/stat_modifiers.asm`). -- Physical/special split by type (special = Water/Grass/Fire/Ice/ - Electric/Psychic/Dragon). -- Status: paralysis speed/4 and 25% full para, burn halves physical - attack, poison/burn residual = maxHP/16, sleep 1-7 turns waking on the - lost turn, freeze permanent (as in Gen 1). -- Turn order: effective speed, coin-flip ties; Quick Attack first, - Counter last (Gen 1's only priorities). -- Run formula (`TryRunningFromBattle`): always escape if faster, - otherwise `floor(pSpd*32 / (eSpd/4)) + 30*attempts` vs `rand(0..255)`. -- Catching (`ItemUseBall`): ball-specific rand ranges (255/200/150), - status bonus 25/12, second roll `floor(maxHP*255/ballFactor) / - floor(HP/4)` capped 255. -- Prize money: class base money x last defeated mon's level - (`pic_pointers_money.asm`). - -## Battle move effects (engine/battle/core.asm, move_effects/*) - -- Mimic via Metronome (effects.asm:1203-1273): MimicEffect's - .letPlayerChooseMove branch snapshots wCurrentMenuItem before the - copy-picker menu opens and restores it afterward as the write index - into wBattleMonMoves. Since SelectMenuItem always writes - wCurrentMenuItem/wPlayerMoveListIndex together at the FIGHT-menu - confirm and nothing (including MetronomePickMove) touches either - variable during mid-move resolution, the reused value is always the - calling move's own slot, BattleState.lua's applyMimic fallback uses - self.moveIndex, frozen the same way, so a called Mimic (e.g. from - METRONOME in slot 3) overwrites the calling move's own slot, keeping - its PP, matching the Gen 1 quirk exactly. -- Multi-hit distribution 2/2/2/3/3/3/4/5 over rand(0..7); all hits reuse - the first damage roll (faithful). -- Recoil = damage/4 (Struggle /2); drain/Dream Eater heal = damage/2; - Dream Eater requires sleep. -- Fixed damage: SonicBoom 20, Dragon Rage 40, Seismic Toss/Night Shade = - level, Psywave rand(1 .. 1.5xlevel-1). -- OHKO deals 65535, fails against faster targets; Swift skips accuracy; - Jump Kick crash = 1 damage on miss; Explosion halves defense and - faints the user even on a miss; Hyper Beam skips recharge if it KOs. -- Charge moves (incl. Fly's invulnerable turn), trapping moves locking - the victim out of its turns, Thrash's 3-4 turn lock ending in - confusion, Bide's 2-3 turn store-and-double, Rage's permanent lock - with attack-up on being hit, Counter/Quick Attack priority. -- Side-effect chances: 26/256 (10%), 77/256 (30%), stat-down side - effects 85/256; Twineedle 20% poison. -- Substitute costs 1/4 max HP, absorbs damage, blocks status/stat/side - effects; screens double effective defense (bypassed by crits); Focus - Energy keeps the Gen 1 quarter-rate bug under gen1_faithful. -- Status: sleep 1-7 turns (wake turn is lost), freeze permanent, burn - halves physical attack, paralysis speed/4 + 25% full para, Toxic's - rising counter, Leech Seed transfer, confusion 2-5 turns with 50% - 40-power typeless self-hit. -- Trainer Pokémon use fixed DVs 9/8/8/8 (TrainerAI.asm convention). - -## Items (engine/items/item_effects.asm) - -- Potion family 20/50/200/full; drinks 50/60/80; status heals per item; - Revive half HP; Rare Candy = exact next-level exp with HP delta kept; - evolution stones use the extracted evos data; TMs single-use / HMs - reusable, gated by the species' real tmhm list; Repel 100/200/250 - steps blocking wilds below the lead's level; Escape Rope returns to - the last heal point. -- Snorlax (Route 12/16) only wakes via `ItemUsePokeFlute` (item-use - menu, adjacent to it, not yet beaten), talking to it with the POKé - FLUTE merely in the bag has no effect (`engine/items/item_effects.asm`, - `scripts/Route12.asm`/`Route16.asm`). -- Mart inventories come from the script_mart lists per clerk; selling - pays half price; TM prices from tm_prices.asm. - -## Overworld field systems - -- Ledges from ledge_tiles.asm (facing + standing tile + ledge tile + - input direction -> two-cell hop). -- Counter talk-through uses the tileset's counter tiles - (tileset_headers.asm), which is how mart clerks and nurses work. -- Trainer sight (`home/trainers.asm` CheckFightingMapTrainers + - `engine/overworld/trainer_sight.asm`): extracted per-trainer range, - inclusive tiles along the facing line; detection runs only on - tile-aligned frames, before input handling, so on detection the d-pad - is dead (wJoyIgnore) and the player freezes on the spotted tile; the - "!" holds 60 frames (EmotionBubble), then the trainer walks - distance−1 steps to the adjacent tile (none if already adjacent) and - uses the real battle/won/after dialogue from the trainer headers. - Sight is a pure screen-coordinate comparison with no line-of-sight - obstruction check (TrainerEngage / CheckSpriteCanSeePlayer): an - aligned in-range trainer engages through interposed NPCs and - unwalkable tiles, and the walk-up (TrainerWalkUpToPlayer, a fixed - distance−1 MoveSprite_ script) has no collision either, so the - trainer simply walks/overlaps through anything on the line, as OAM - sprites overlap on hardware. -- Elevator rides (`engine/overworld/elevator.asm` ShakeElevator → - `src/world/ElevatorShake.lua`): choosing a floor stops the music, - bounces the BG scroll ±1 px around rest for 100 two-frame cycles with - SFX_COLLISION retriggered every cycle, restores the scroll, plays - SFX_SAFARI_ZONE_PA to completion, and restarts the map theme before - the floor warp. Lead-in delays kept per script: 9 frames of Delay3s - inside ShakeElevator (Celadon farjps in), 12 with the Silph/Rocket - scripts' extra Delay3. The offset applies to the BG layer only, - sprites are OAM and stay put. After the ride the port no longer - jump-cuts: choosing a floor rewrites the car's own exit-warp entries - to that floor (`engine/events/elevator.asm` DisplayElevatorFloorMenu - .UpdateWarp, per scripts/SilphCoElevator.asm / - CeladonMartElevator.asm / RocketHideoutElevator.asm), then the player - is walked out through the doorway onto that warp (ow:scriptMove → - ow:takeWarp), like the original. -- Field-move gates (engine/overworld/field_move_messages.asm + - start_sub_menus.asm): IsSurfingAllowed ported exactly, SURF refuses - with _CyclingIsFunText while the Cycling Road's BIT_ALWAYS_ON_BIKE is - armed (save.forcedBike: set on the Route 16/18 forced-bike tiles, - cleared by the gates, Fly, dungeon/blackout warps; the forced mount - itself is silent, as in CheckForceBikeOrSurf) and with - _CurrentTooFastText on Seafoam B4F's stairs square (7,11) until both - EVENT_SEAFOAM4 boulders are down. Re-selecting SURF while surfing is - ItemUseSurfboard's dismount attempt: steps ashore silently if the - facing tile is land-passable and unoccupied, else "There's no place - to get off!", and the menu closes either way (wActionResult stays 1). - STRENGTH's first page auto-advances after the cry + Delay3 (no - prompt); "can move boulders." prompts. The GBPalWhiteOutWithDelay3 - white blink plays on every .goBackToMap closer: Strength, surf - mount/dismount/no-place, Flash (after its text), and Dig/Teleport - (Cut closes without a blink, per the asm). -- Wild slot table + rate per map; water encounter tables used while - surfing. -- Cut-tree block swaps from cut_tree_blocks.asm; surfable tilesets from - water_tilesets.asm (water tile $14, plus $32 on SHIP_PORT). - -## Story events (data/scripts/story.lua and friends) - -- Every hand-ported script cites its scripts/*.asm source and reuses the - real extracted text and event-flag names. -- Custom flag names (audited equivalent): three port-internal flag - families have no pokered EVENT constant but mirror the original's - state exactly. EVENT_TRADED_* are per-trade names for - wCompletedInGameTradeFlags bits (engine/events/in_game_trades.asm: - FLAG_TEST before the offer → after-trade text, FLAG_SET on completion; - dialogset text families, party-menu pick, the received mon joins the - end of the party, ConnectCable→anim→TradedFor→Thanks all ported). - EVENT_GOT_EEVEE is bookkeeping alongside the real guard, the hidden - ball object (scripts/CeladonMansionRoofHouse.asm HideObject, ≡ - save.objectToggles), and self-heals older saves; a full party+box - keeps the ball claimable (_BoxIsFullText). EVENT_BEAT_SS_ANNE_RIVAL - stands in for scripts/SSAnne2F.asm's saved wSSAnne2FCurScript NOOP - progression, including the lose-and-retrigger path (flag only set on - victory). Names are kept for save compatibility. Coverage: - tests/parity_trade_gift.lua. -- The Pallet Town intro follows pokered exactly: the trigger is - PalletTownDefaultScript's wYCoord==1 check, Oak appears at (8,5) and - takes FindPathToPlayer's zigzag to one tile below the player, and the - escort is RLEList_ProfOakWalkToLab against the reverse-order playback - of RLEList_PlayerWalkToLab (the 17th simulated press is eaten by the - door-warp frame), followed by the OaksLab walk-in and choose-mon - exchange with map music deferred like BIT_NO_MAP_MUSIC. Oak's speech - ends with the real shrink: RedPicFront collapses through the extracted - ShrinkPic1/ShrinkPic2 into the overworld walking sprite on - OakSpeech.asm's frame timings (SFX_SHRINK, 4/4/20/50-frame beats, fade - to white), with the closing text box held on screen. The escort's - scripted steps run 16 frames/tile (chained single-tile scriptMoves - start back-to-back, no idle frame); Oak marches in place on the door - mat for RLEList_ProfOakWalkToLab's trailing NPC_CHANGE_FACING beat - (movement.asm ChangeFacingDirection → zero-delta TryWalking); the "!" - EmotionBubble overlaps the still-shown "Hey! Wait!" box - (PalletTownOakText prints without a button wait, then DelayFrames 10 → - EmotionBubble before the box clears); and the shrink beat ramps the - music to silence over ~70 frames (wAudioFadeOutControl = 10; - home/fade_audio.asm FadeOutAudio steps rAUDVOL 7→0) rather than - hard-stopping. -- The 12 disguised static wild battles (Power Plant Voltorb/Electrode + - Zapdos, Articuno, Moltres, Mewtwo) follow TalkToTrainer/ - EndTrainerBattle exactly: cry + battle text, after-battle text without - a rematch once EVENT_BEAT_* is set, and the flag/HideObject on any - non-blackout result (fleeing loses the legendary, as in Gen 1). - Snorlax hides before its battle and only shows the calmed-down/ - returned line when not caught. Zapdos/Articuno/Moltres/Mewtwo's - battle text is a text_far string ending in a bare "...@" terminator - (no /) followed by text_asm PlayCry + WaitForSoundToFinish: - the box types with no ▼ prompt and auto-closes only once the cry - finishes, never on a button press, ported via `Commands.play_cry` - stashing the pending cry for the following `Commands.show_text` to - consume as the TextBox's auto-close sound. Voltorb/Electrode's battle - text has no PlayCry call in the ROM at all and keeps the ordinary - button-wait close. -- Gym leader repeat dialogue (data/scripts/gyms.lua): each leader's - text_asm branches on EVENT_BEAT_, pre-badge talk prints the - pre-battle text and engages the leader battle (badge/TM via - data/scripts/victories.lua); post-badge talk prints the leader's - post-battle advice text (Misty's is her TM11 explanation). The - originals' middle branch (beaten but TM not handed over, - CheckEventReuseA EVENT_GOT_TM*) is ported too: the victory's GiveItem - goes through the bag's capacity check, a full bag shows the leader's - "make room" text instead of the received lines and leaves - EVENT_GOT_TM* unset, and talking to the leader re-runs the ReceiveTM - script until the TM goes in (#797). Giovanni's - farewell (`ViridianGymGiovanniText` .afterBeat) hides him inside a - fade-to-black/fade-in Transition matching ViridianGym.asm's - GBFadeOutToBlack → HideObject → GBFadeInFromBlack, persisted - permanently via TOGGLE_VIRIDIAN_GYM_GIOVANNI in save.objectToggles. -- Cable Club receptionists (TX_SCRIPT_CABLE_CLUB_RECEPTIONIST → - CableClubNPC, all 12 Pokémon Centers): welcome, pre-Pokédex "making - preparations" brush-off, and the apply/save YES-NO are ported; - accepting saves the game and opens the link menu, declining prints - "Please come again!". -- Cinnabar fossil deposit follows GiveFossilToCinnabarLab: a menu of - carried fossils (FossilsList order), SeesFossilText with a Yes/No - confirm, ComeAgainText on either cancel. -- Hall of Fame induction: each party mon's front sprite scrolls in from - the left at 4px/frame, matching HoFShowMonOrPlayer's .ScrollPic - front-pic phase (engine/movie/hall_of_fame.asm); the back-pic's - enlarged/blurred pre-wipe is a VRAM-scroll-register trick not - replicated in this sprite-based renderer. The finale - (HoFDisplayPlayerStats) shows trainer name, play time, money, POKéDEX - seen/owned, and Prof. Oak's rating text (engine/events/ - pokedex_rating.asm DexRatingsTable) from real save data. -- End credits + post-game reset (engine/movie/credits.asm, - scripts/HallOfFame.asm): screen-by-screen CreditsOrder pages (hlcoord - 9,6 + signed columns), FadeInCredits' 4x5-frame ramp, 90/110/120/140- - frame holds, DisplayCreditsMon's 27-frame 8px/frame silhouette wipe, - LoadCopyrightTiles' three-row block, THE END at (4,8). While THE END - is up the HoF script autosaves (wLastBlackoutMap := PALLET_TOWN; the - player is saved in the HALL_OF_FAME room), waits 600 frames, then A/B - triggers `jp Init`, the boot sequence replays into the title screen. -- Victory Road's boulder switches replicate the original's - ReplaceTileBlock data: 1F boulder at (17,13) -> block $1D at (4,6); - 2F boulders at (1,16)/(9,16) -> $15 at (3,4) and $1D at (11,7); 3F - boulder at (3,5) -> $1D at (3,5), and the (23,15) hole drops the - boulder to 2F (hide/show toggle). Barriers are re-applied from flags - on map entry, exactly like the originals' map-load scripts. -- Item balls, static legendary encounters and trainer rewards - (badges + gym TMs, the Silph Giovanni flag) are generic systems driven - by the extracted object args and a hand-ported reward table - (data/scripts/victories.lua). -- In-game trades use the real data/events/trades.asm table (species in, - species out, original nickname). - -## Safari game (engine/events/hidden_events/safari_game.asm + engine/battle) - -- ¥500 buys 30 SAFARI BALLs and 502 steps (scripts/SafariZoneGate.asm - sets `wSafariSteps = 502`); steps count down on the four outdoor zone - maps and hitting 0 (or throwing the last ball) ends the game at the - gate. -- Safari battles offer BALL / BAIT / ROCK / RUN; no player Pokémon - acts. The working catch rate starts at the species rate; BAIT halves - it and adds 1-5 to the bait factor (zeroing the escape factor); ROCK - doubles it (cap 255) and adds 1-5 to the escape factor (zeroing bait) - -- ItemUseBait/ItemUseRock in engine/items/item_effects.asm. -- Each turn one factor decays ("is eating!" / "is angry!"); when the - escape factor decays to 0 the catch rate resets to the species rate - (PrintSafariZoneBattleText, engine/battle/safari_zone.asm). -- Flee check (engine/battle/core.asm): `b = 2 * (speed % 256)`; the mon - always flees when speed > 127; while eating `b /= 4`, while angry - `b = min(255, 2b)`; it flees when `rand(0,255) < b`. -- The SAFARI BALL rolls the ULTRA_BALL rand range (0-150) in the Gen 1 - catch formula, against the BAIT/ROCK-modified rate. - -## Slot machines (engine/slots/slot_machine.asm) - -- The three reels are the extracted 18-symbol wheel sequences - (data/events/slot_machine_wheels.asm); bet 1 plays the middle row, - bet 2 adds top+bottom, bet 3 adds both diagonals. -- Payouts: 7-7-7 = 300, BAR = 100, CHERRY = 8, MOUSE/FISH/BIRD = 15 - (SlotRewardPointers). -- Per-wheel stop/slip rules ported exactly: wheel 1 spends up to 4 slip - charges, slipping past a centred CHERRY (in seven-and-bar mode it - always slips all 4 via pokered's `cp HIGH(SLOTS7)` bug); wheel 2 stops - as soon as wheels 1+2 line up any potential match (pairs checked b/b, - b/m, m/m, t/m, t/t) or, in seven-and-bar mode, on 7/BAR; wheel 3 rolls - past forbidden matches free and burns wSlotMachineRerollCounter - charges on winnable no-match spins, animated tile-by-tile. Luck flags - (SetFlags): seven-and-bar mode is sticky across spins; r==0 arms 60 - allow-matches charges; a BAR win clears flags; a 300 win zeroes the - counter and clears flags with probability 128/256; 8/15 wins burn one - charge. Lines are checked in asm order with the first match taken; - A-presses are ignored while a prior wheel's slip counter is nonzero. - Machine and COIN CASE texts are byte-identical - (_GameCorner*Text; AbleToPlaySlotsCheck's no-coins gate included). -- Flow brackets: PromptUserToPlaySlots "A slot machine! Want to play?" - (YesNoChoice) and MainSlotMachineLoop's "One more go?" (TwoOptionMenu); - the x3/x2/x1 coin menu (CoinMultiplierSlotMachineText) defaults its - cursor to x3, bet = 3 - menu item. Static frame: the real - SlotMachineMap (gfx/slots/slots.tilemap, 20x12 tile ids < $25) blitted - from red_slots_1.png, extracted as field.slotSymbols.tilemap - (tools/extract/gfx.py extract_slots). Win flash: - SlotMachine_CheckForMatches.flashScreenLoop flips rBGP (shade 3->2) b - times at 5 frames each, b = 20/8/4/2 for the 300/100/15/8 rewards - (SlotReward{300,100,8,15}Func). Payout drip: - SlotMachine_PayCoinsToPlayer credits one coin every 8 frames (4 for a - 7/BAR), SFX_SLOTS_REWARD per coin, rOBP0 symbol flicker every 5 coins. - -## Spinner arrow tiles (scripts/*.asm arrow movement tables) - -- Viridian Gym and Rocket Hideout B2F/B3F keep per-coordinate RLE - movement lists (map_coord_movement); each list executes backwards - from its terminator (DecodeArrowMovementRLE), sliding the player and - chaining onto further arrows. - -## Cries (data/pokemon/cries.asm, audio/engine_1.asm) - -- Each species = a base cry (one of 38 SFX_CryXX streams) + a frequency - modifier added to every note's frequency register - (Audio1_ApplyFrequencyModifier) + a tempo modifier - (`sfx tempo = $80 + length`, Audio1_SetSfxTempo). All 151 cries are - rendered offline with those modifiers applied and play on battle - entry and Pokédex pages. - -## Hidden events & facility puzzles - -- Card key doors (engine/events/card_key.asm): door tiles $18/$24 - (SILPH_CO_11F: $5e) replaced with block $0e ($03 on 11F). -- Vermilion trash cans - (engine/events/hidden_events/vermilion_gym_trash.asm): the first-lock - can re-rolls on every Vermilion City map load (VermilionCity_Script's - Random & $e, even cans) and after every failed second-can guess; the - second lock uses the GymTrashCans table verbatim, including the - underflow bug that can place it in can 0 regardless of adjacency; a - wrong pick resets EVENT_1ST_LOCK_OPENED and re-rolls immediately; only - SuccessText3 prints on completion; the gym door block at (2,2) is - $24 closed / $5 open (scripts/VermilionGym.asm). SuccessText1/ - SuccessText3/FailText play SFX_SWITCH/GO_INSIDE/DENIED from each - text's text_asm tail after the text prints (DisplayTextID's - WaitForTextScrollButtonPress then holds the box), so the port fires - them from an onDone on the TextBox, landing the beep as the box - closes rather than as it opens. -- Menu close-keys follow pokered's per-menu wMenuWatchedKeys mask, not - a single global rule: the shared Menu base (src/ui/Menu.lua) closes - on B only, and START-close is opt-in via opts.startCloses. Only the - start menu sets it, matching engine/menus/draw_start_menu.asm's - PAD_DOWN|PAD_UP|PAD_START|PAD_B|PAD_A; OptionsMenu also closes on - START via its own loop, matching engine/menus/main_menu.asm - DisplayOptionMenu's explicit B_PAD_B/B_PAD_START checks. Every other - menu (bag/PC item lists PAD_A|PAD_B|PAD_SELECT, party menu / - BUY-SELL-QUIT / USE-TOSS submenu / PC menus / Pokedex side menu - PAD_A|PAD_B) leaves PAD_START unwatched, so START does not close - them. START never replays SFX_PRESS_AB (HandleMenuInput_ beeps only - for the PAD_A|PAD_B branch). -- Old man tutorial hollow cursor: the item list is itself scripted in - pokered (DisplayListMenuID's old-man branch, home/list_menu.asm:65-91) - , no input is read; the filled '▶' hovers POKé BALL for 80 frames, - auto-presses A, then PlaceUnfilledArrowMenuCursor leaves the hollow - '▷' on that row until ItemUseBall tears the list down for the throw. - Ported via ListMenu's opts.script hook (src/ui/ListMenu.lua) and - BattleState:openOldManBag driving the same beats. The MissingNo./ - wGrassRate side effects of the OLD MAN name swap are not modeled, - see docs/gameboy-hardware-limitations.md. -- Gym statues (gym_statues.asm): plaque with the city/leader from each - gym's script; the player joins WINNING TRAINERS with the badge. -- Route 22 gate / Route 23 guards: real trigger rows, badge order - (EARTH down to CASCADE) and EVENT_PASSED_*_CHECK skip flags. -- Game Corner poster (scripts/GameCorner.asm): block (8,2) $2a -> $43 - on EVENT_FOUND_ROCKET_HIDEOUT. -- Seafoam Islands (scripts/SeafoamIslandsB3F/B4F.asm): reversed-RLE - current paths, Seafoam4HolesCoords boulder holes setting the - EVENT_SEAFOAM*_BOULDER*_DOWN_HOLE pairs, the forced pool exit rows. -- Rock Tunnel darkness: wMapPalOffset = 6 on entry, cleared by Flash - (BOULDERBADGE) or leaving (home/overworld.asm). - -## Battle extras - -- GROWL/ROAR (GetMoveSound/IsCryMove, engine/battle/animations.asm - ~2196): the move's own MoveSoundTable tempo byte (Growl $c0, Roar - $40, both pitch $00) layers onto the cry via `Sound.playMoveCry`'s - `Source:setPitch(256/(128+tempoMod))`. Transform (engine/gfx/ - palettes.asm DeterminePaletteID, bit TRANSFORMED): the swapped-in pic - is tinted PAL_GRAYMON via `PaletteFX.monPal(data, species, - transformed)`, not the copied species' own palette, in - `BattleState:speciesSprite`. Growl (DoGrowlSpecialEffects, - animations.asm ~928): AnimPlayer's GROWL frame-block branch keeps a - `growlNoteTrail` snapshot so each block's emitted sprites include the - previous block's note copy alongside the current one (GROWL skips - AnimationCleanOAM between blocks per the `cp GROWL` check ~line 145); - ROAR is unaffected since the asm never applies this quirk to it. -- Master/Ultra ball tosses flicker the OBJ palette: DoBallTossSpecial - Effects (engine/battle/animations.asm:685) XORs rOBP0 with %00111100 - after every frame block while wCurItem <= ULTRA_BALL, so the 11 toss - blocks alternate the $F0/$CC shade maps starting normal; PlayAnimation - pushes/pops rOBP0 around each subanimation row, so the ambient - palette returns when the toss ends. GREAT/POKE/SAFARI balls never - flicker, and the toss arc always follows wCurItem via - TossBallAnimation, including the ghost-dodge throw. -- Anim-layer OBJ colorization is per 8x8 attribute cell: the SGB's - ATTR_BLK regions color the composited DMG picture per cell, not per - OAM entry, so an anim sprite overlapping a zone boundary takes each - cell's palette on the pixels inside it, AnimPlayer samples the zone - under every cell an 8x8 tile touches and repaints differing cells - through a cell-clipped scissor (aligned tiles stay one draw). -- Ball wobbles (ItemUseBall): Z = X*Y/255 + status2 with - Y = rate*100/ballFactor2; <10/<30/<70 -> 0/1/2 shakes, else 3, with - the matching ItemUseBallText01-04 lines. -- Trainer class AI (data/trainers/ai_pointers.asm + - engine/battle/trainer_ai.asm): per-class item/switch routines with - wAICount uses per Pokémon, ported to data/scripts/ai_classes.lua. -- Exp (engine/battle/experience.asm): baseExp*level/7 divided by the - participant count, x1.5 for trainers, x1.5 for traded mons; stat exp - in full to each participant. -- Move sounds: data/moves/sfx.asm (sound + pitch/tempo per move). The - pitch/tempo modifiers are applied at synthesis time - (Audio2_ApplyFrequencyModifier adds pitch to every frequency write; - Audio2_SetSfxTempo scales tone-channel note lengths, noise skips it), - 128 variant WAVs keyed "@" that Sound.playMove - selects, exact rather than a playback-rate approximation. Per-row - sounds fire as PlayAnimation does; GROWL/ROAR (IsCryMove) play the - attacker's cry. Hit sounds by effectiveness (Damage/Super/NotVery). -- Screen-effect animations (engine/battle/animations.asm + - engine/gfx/screen_effects.asm): every SE_* is implemented per-routine, - FlashScreen/FlashScreenLong (the FlashScreenLongSGB 12-entry table), - Dark/Light/DarkenMon/Reset palette ops (shade-map permutations of the - SGB zone palettes), all SlideMon variants, ShakeBackAndForth, - BoundUpAndDown, SquishMonPic, Minimize (real MinimizedMonSprite), - spiral/shoot-balls/water-droplets/leaves emitters compiled from the - asm trajectories, per-animation-id frame-block flashes (Explosion, - Rock Slide's rumbles, Blizzard's cadence...), AnimationWavyScreen with - true per-scanline offsets, PredefShakeScreenHorizontally/Vertically - and ShakeEnemyHUD. SE rows carry the faithful blocking durations. -- SGB battle colorization (SetPal_Battle, BlkPacket_Battle, - SetAnimationPalette): the battle screen is colorized by zone, player - HUD, enemy HUD, player mon + message box, enemy mon; trainer front - pics and the player/old-man back pics take PAL_MEWMON (both species - IDs are zero at the intro, so MonsterPalettes[0]); the ghost keeps the - disguised species' palette; attack animation sprites and thrown balls - are colored through the OBJ palettes (wAnimPalette $F0 on SGB, ambient - $E4, OBP1 $6C). Headless/no-shader environments fall back to the flat - pipeline. -- Mimic resolves mid-move (MimicEffect): accuracy first, then the - player's copy menu (enemy/link copy a random slot); the copy - overwrites only the slot's move ID, PP is shared with Mimic's slot, - and reverts on switch/battle end. -- Old man tutorial (DisplayBattleMenu's BATTLE_TYPE_OLD_MAN branch): the - real scripted cursor, ▶ beside FIGHT for 80 frames, beside ITEM for - 50, ITEM force-selected into the POKé BALL x50 list; the throw always - catches at full HP (item_effects.asm jumps straight to .captured, 3 - shakes, no party/dex add, no ball consumed); backing out of the bag - replays the script. The old man never attacks, the original tutorial - is menu navigation + a guaranteed catch, nothing more. - -## Link battles (lockstep) - -- Both sides simulate with a shared Park-Miller RNG stream (host deals - the seed), identical pack/unpack-clamped party copies, no badge - boosts, and a mirrored speed-tie roll (the guest inverts it); a - canonical host-side-first state hash is exchanged per turn and any - mismatch ends the match as a draw. - -## Music (audio/engine_1.asm) - -- Note duration: `frames = length * speed * tempo / 0x100` with - fractional carry, at 60 fps (Audio1_note_length / CalculateDelay). -- Frequency: `reg = pitches[note] asr (octave - 1)` (CalculateFrequency; - the octave byte stores `8 - octave`), `f = 131072/(2048 - reg)` for - squares, halved for channel 3. -- note_type volume/fade renders as an NRx2-style envelope (step every - `fade/64` s); duty_cycle maps to 12.5/25/50/75% pulse widths; - sound_call/sound_loop honor the engine's one-level call stack and - loop counters. - -## Text & font - -- The Pokédex height row uses the real ′/″ tiles: gfx/pokedex/pokedex.png - tiles 0/1 are patched over font-extra slots $60/$61 exactly as - engine/gfx/load_pokedex_tiles.asm loads them over vChars2 (they replace - glyphs charmap.asm marks unused); ASCII `"` aliases to the closing- - quote glyph $73 so stray hand-written quotes render. - -## Validation against the original - -- `tests/run_tests.lua` pins hand-checked values: L5 Bulbasaur 19 HP / - 9 Atk at 0 DVs, L100 Mewtwo 415 HP / 406 Spc at max DVs+statExp, - MEDIUM_SLOW(5) = 135, type chart spot checks, deterministic damage - rolls, Route 1 slot 1 = L3 Pidgey. -- The autopilot run reproduces the original's early flow on real map - data: Pallet sign text, lab door warp target (5,11), Oak's Lab exit by - walking off the mat, connection into Route 1 at matching x. diff --git a/docs/differences-between-gold-silver-and-crystal.md b/docs/differences-between-gold-silver-and-crystal.md deleted file mode 100644 index 6ff4a0bf..00000000 --- a/docs/differences-between-gold-silver-and-crystal.md +++ /dev/null @@ -1,615 +0,0 @@ -# Pokémon Gold, Silver, and Crystal: A Systematic Engine, ASM, and Behavioral Comparison - -## Executive summary - -Pokémon Gold and Silver are best understood as **two data-configured builds of one engine**, whereas Pokémon Crystal is an **expanded, reorganized, Color-only derivative of that engine**. The `pret/pokegold` repository reproducibly builds the international Gold and Silver ROMs byte-for-byte, while `pret/pokecrystal` separately builds multiple international Crystal revisions and debug artifacts. This makes the two repositories unusually strong primary evidence: their output hashes correspond to known retail binaries rather than merely reimplementing observed behavior. - -The largest technical conclusion is that Crystal is **not a ground-up engine rewrite**. Core subsystems (the frame-driven sound interpreter, hardware-derived RNG, synchronized link-battle PRNG, wild-encounter probability machinery, party-mon record size, and much of the RTC pipeline) remain recognizably inherited from Gold/Silver. Crystal instead adds new ROM banks, reorganizes routines, introduces Color-specific rendering paths, fills formerly reserved fields in Pokémon records, redesigns SRAM placement, and layers Battle Tower, Mobile System GB, animated sprites, the female protagonist, Buena, Move Tutor, Suicune story logic, and Virtual Console hooks around the inherited core. - -The most important behavioral differences are therefore caused less by wholesale algorithm replacement than by **changed control flow and data**: - -| Area | Gold and Silver | Crystal | Technical consequence | -|---|---|---|---| -| Edition structure | One shared engine with edition-selected encounter, sprite, title, and other data | One enhanced-version build with regional/revision conditionals | Gold-to-Silver ports are mostly data substitutions; Gold/Silver-to-Crystal ports are architectural merges | -| Display target | Runtime distinguishes DMG/SGB/CGB modes | Explicit Color-only path | Crystal code can assume CGB palettes, VRAM banking, and related presentation behavior more aggressively | -| RNG core | Divider-register accumulator plus synchronized link PRNG | Same algorithms | Different outcomes normally arise from different call timing or paths, not a different generator | -| Party records | Two bytes after Pokérus are reserved | Those same bytes become caught-time/gender/level/location metadata | Pokémon records remain structurally compatible despite Crystal gaining metadata | -| Save layout | Main and backup data are fragmented among SRAM sections | Main and backup records become more contiguous; Crystal/Battle Tower/Mobile blocks are added | Whole `.sav` files are not drop-in interchangeable | -| Battles | Standard Gen II battle engine | Inherited engine plus Battle Tower branches and selective bug fixes | Link-compatible behavior is intentionally retained in some otherwise-fixed cases | -| Roaming Pokémon | Raikou, Entei, and Suicune use the roaming system | Only Raikou and Entei roam; Suicune becomes a scripted encounter | Encounter-state conversion requires special handling | -| Graphics | Static Pokémon fronts and DMG/SGB-compatible layouts | Animated fronts, revised sprites, richer Color layouts | Animation introduces new graphics data, decompression, frame, and timing requirements | -| Audio | Shared four-channel Game Boy music interpreter | Same interpreter plus new songs and call sites | Music ports are mostly data/pointer work rather than synthesizer rewrites | -| Major fixed defects | Coin Case arbitrary code execution, first-save Hall of Fame corruption, Lucky Number box limit, and several map/text defects | Corrected | Crystal is safer, but not "bug-fixed Gen II" in the broad sense | -| Persistent defects | Many Gen II battle, capture, item, and badge defects | Many remain | Mechanical compatibility often outweighed cleanup | - -Gold and Silver themselves have almost no meaningful engine-level divergence. Their scientifically interesting comparison is principally **which tables and assets are selected at assembly time**. Crystal is the meaningful engine comparison. - -The report assumes a technically literate audience familiar with low-level programming but not necessarily Game Boy internals. Unless explicitly stated, "Crystal" means the international English code represented by `pret/pokecrystal`; Japanese Mobile System GB and Australian/revision-specific behavior are treated as regional variants. "Scientific" here means reproducible binary-derived comparison, explicit source hierarchy, and separation of direct evidence from inference, not laboratory experimentation involving original development source code, which has not been publicly released. - -## Scope, evidence, and reproducibility - -### Source hierarchy - -The strongest sources are the two pret disassemblies: - -- [`pret/pokegold`](https://github.com/pret/pokegold), which builds exact Gold and Silver international ROM images. -- [`pret/pokecrystal`](https://github.com/pret/pokecrystal), which builds Crystal international v1.0, v1.1, Australian, and debug images. - -The repositories identify exact SHA-1 outputs. Gold builds to `d8b8a3600a465308c9953dfa04f0081c05bdcb94`, Silver to `49b163f7e57702bc939d642a18f591de55d92dae`, Crystal international v1.0 to `f4cd194bdee0d04ca4eac29e09b8e4e9d818c133`, and Crystal international v1.1 to `f2f52230b536214ef7c9924f483392993e226cfb`. Those reproducible identities are critical: a label such as `BadgeStatBoosts` is a reverse-engineered name, but the emitted bytes and resulting behavior are those of the retail ROM. - -Secondary sources such as Bulbapedia and Serebii are useful for player-visible cross-checking, release differences, encounter availability, and historical context. The pret bug documents are stronger than ordinary wiki summaries for defects because they identify the responsible instructions and provide corrective diffs. Official Nintendo and Pokémon sources are most valuable for release/Virtual Console behavior and supported transfer paths, but they do not publish the games' assembly architecture. - -### Reproducible comparison method - -A rigorous comparison should operate at four levels: - -| Level | Method | What it establishes | Main limitation | -|---|---|---|---| -| Binary identity | Build each repository with its pinned RGBDS-compatible toolchain and verify SHA-1 | The source corresponds to the target retail ROM | Does not explain why bytes differ | -| Source topology | Compare `main.asm`, `home.asm`, bank sections, include graphs, WRAM/SRAM declarations | Architectural additions, removals, and relocation | File organization can change without behavioral change | -| Routine semantics | Compare labels, branches, register use, calls, data offsets, and side effects | Control-flow and algorithmic differences | Labels and comments are community-derived | -| Behavioral validation | Run controlled emulator tests with trace logging and fixed input timing | Observable consequences and RNG/cycle sensitivity | Emulator accuracy and RTC state must be controlled | - -For a formal experiment, the recommended independent variables are edition, ROM revision, initial SRAM, RTC register state, boot hardware mode, input sequence, and link mode. Dependent variables include RNG bytes, encounter choice, battle result, SRAM writes, audio commands, VRAM/OAM writes, and cycle/frame number. Because the ordinary RNG reads the divider register, deterministic comparisons must start from a controlled boot state and reproduce input timing at frame or cycle precision. - -### Gold versus Silver as a build-configuration comparison - -The fact that `pokegold` emits both Gold and Silver from one repository is itself strong evidence that the two versions share the same broad code architecture. Edition differences are implemented through conditional assembly and alternate data or assets rather than through separate engines. The obvious behavioral results are version-specific wild Pokémon, title graphics, mascot-facing presentation, and selected NPC or table data; battle arithmetic, saves, RTC processing, audio interpretation, and link handling come from the same shared source. - -This distinction matters for analysis. Treating Gold, Silver, and Crystal as three equally separate engines exaggerates Gold/Silver differences and understates Crystal's structural changes. A better model is: - -```mermaid -flowchart TD - GSC[Generation II design and data model] - GS[Gold/Silver shared engine] - GD[Gold-selected data and assets] - SD[Silver-selected data and assets] - C[Crystal derivative engine] - CF[Crystal-only feature modules] - CR[Crystal revisions and regional flags] - - GSC --> GS - GS --> GD - GS --> SD - GS --> C - C --> CF - C --> CR -``` - -## ROM and engine architecture - -### Banked execution model - -The games execute on the Game Boy's LR35902-class CPU and are organized around a fixed home bank plus switchable ROM banks. Calls across banks generally pass through wrappers that save the current bank, select the target bank, call the routine, and restore the original bank. The `BattleRandom` wrapper is a concise example: `_BattleRandom` resides outside the home bank, so the wrapper saves `hROMBank`, uses the bank-switch restart vector, calls the routine, preserves its return value, and restores the previous bank. - -```asm -BattleRandom:: - ldh a, [hROMBank] - push af - ld a, BANK(_BattleRandom) - rst Bankswitch - call _BattleRandom - ... - pop af - rst Bankswitch - ret -``` - -Relevant sources: [Gold/Silver `home/random.asm`](https://github.com/pret/pokegold/blob/a0dad0957ac8a9ffa67e950ee3ab6715a212ded5/home/random.asm#L29-L46) and [Crystal `home/random.asm`](https://github.com/pret/pokecrystal/blob/8e8f7e20052a596371a77022f0392c285e51bbf1/home/random.asm#L29-L46). - -The repositories' top-level `main.asm` files are effectively link manifests for this architecture. Gold/Silver's file groups the battle core, encounters, party menu, RTC, phone, Pokégear, sprite animation, graphics loading, and other systems into named ROM sections. Crystal retains those categories but adds explicitly named feature sections and many additional modules. - -### Crystal as an extension and bank-layout rewrite - -Crystal's `main.asm` introduces a conspicuous `Crystal Features 1` section containing gender initialization, Kris-specific bag handling, Move Tutor, Crystal layouts, Celebi logic, the redesigned main menu, Mobile menu code, owned-Pokémon search, and Buena's menu. Nearby banks add Battle Tower trainer logic, caught-data handling, a revised stats screen, sliding battle intros, battle-scene checks, and the Color-only startup screen. - -That include topology supports three conclusions. - -First, Crystal preserves **vertical subsystem continuity**. The battle engine is still `engine/battle/core.asm`; encounters remain `engine/overworld/wildmons.asm`; Pokémon records still derive from the same party/box macros; and audio is still driven by the same interpreter. - -Second, Crystal adds **horizontal feature coupling**. Battle Tower touches battle setup, stat boosts, experience rules, saving, menus, SRAM, trainers, music, and link-like constraints. The female protagonist touches initialization, sprites, bag graphics, caught metadata, and menu presentation. Mobile support touches interrupts, SRAM, menus, communications, rankings, and audio. - -Third, code and data relocation is substantial enough that raw ROM addresses from Gold/Silver cannot generally be transplanted into Crystal. A symbolic port based on labels and structures is practical; a patch based on absolute offsets is brittle. - -### File and routine comparison - -| Subsystem | Gold/Silver source | Crystal source | ASM-level difference | Porting significance | -|---|---|---|---|---| -| Top-level bank graph | [`main.asm`](https://github.com/pret/pokegold/blob/a0dad0957ac8a9ffa67e950ee3ab6715a212ded5/main.asm) | [`main.asm`](https://github.com/pret/pokecrystal/blob/8e8f7e20052a596371a77022f0392c285e51bbf1/main.asm) | Crystal adds feature, Battle Tower, Mobile, gender, animation, and caught-data modules and relocates shared includes | Port by symbol and feature dependency, not ROM offset | -| Ordinary RNG | [`home/random.asm`](https://github.com/pret/pokegold/blob/a0dad0957ac8a9ffa67e950ee3ab6715a212ded5/home/random.asm) | [`home/random.asm`](https://github.com/pret/pokecrystal/blob/8e8f7e20052a596371a77022f0392c285e51bbf1/home/random.asm) | Essentially identical routine and rejection-sampling helper | RNG-sensitive ports must preserve call timing | -| Battle core | [`engine/battle/core.asm`](https://github.com/pret/pokegold/blob/a0dad0957ac8a9ffa67e950ee3ab6715a212ded5/engine/battle/core.asm) | [`engine/battle/core.asm`](https://github.com/pret/pokecrystal/blob/8e8f7e20052a596371a77022f0392c285e51bbf1/engine/battle/core.asm) | Crystal adds Battle Tower guards and conditional fixes while retaining link behavior | Mechanical changes can desynchronize unmodified peers | -| Encounters | [`engine/overworld/wildmons.asm`](https://github.com/pret/pokegold/blob/a0dad0957ac8a9ffa67e950ee3ab6715a212ded5/engine/overworld/wildmons.asm) | [`engine/overworld/wildmons.asm`](https://github.com/pret/pokecrystal/blob/8e8f7e20052a596371a77022f0392c285e51bbf1/engine/overworld/wildmons.asm) | Same rate/slot pipeline; different swarm state and two rather than three roamers | World-state conversion needs special Suicune handling | -| SRAM declaration | [`ram/sram.asm`](https://github.com/pret/pokegold/blob/a0dad0957ac8a9ffa67e950ee3ab6715a212ded5/ram/sram.asm) | [`ram/sram.asm`](https://github.com/pret/pokecrystal/blob/8e8f7e20052a596371a77022f0392c285e51bbf1/ram/sram.asm) | Crystal consolidates save/backup regions and adds GS Ball, Battle Tower, rankings, and Mobile blocks | Whole-save binary compatibility is lost | -| Pokémon structures | [`pokemon_data_constants.asm`](https://github.com/pret/pokegold/blob/a0dad0957ac8a9ffa67e950ee3ab6715a212ded5/constants/pokemon_data_constants.asm#L69-L103) | [`pokemon_data_constants.asm`](https://github.com/pret/pokecrystal/blob/8e8f7e20052a596371a77022f0392c285e51bbf1/constants/pokemon_data_constants.asm#L69-L108) | Crystal assigns caught metadata to two bytes reserved in Gold/Silver | Record length remains compatible | -| RTC/home time | [`home/time.asm`](https://github.com/pret/pokegold/blob/a0dad0957ac8a9ffa67e950ee3ab6715a212ded5/home/time.asm) | [`home/time.asm`](https://github.com/pret/pokecrystal/blob/8e8f7e20052a596371a77022f0392c285e51bbf1/home/time.asm) | Same RTC model; Crystal timer interrupt can dispatch Mobile timing | Emulator ports must emulate RTC and, for Japanese features, mobile timing assumptions | -| Picture loading | [`engine/gfx/load_pics.asm`](https://github.com/pret/pokegold/blob/a0dad0957ac8a9ffa67e950ee3ab6715a212ded5/engine/gfx/load_pics.asm) | [`engine/gfx/load_pics.asm`](https://github.com/pret/pokecrystal/blob/8e8f7e20052a596371a77022f0392c285e51bbf1/engine/gfx/load_pics.asm) | Crystal has additional front-picture and animation-oriented handling | Static sprite replacement is insufficient | -| Sound interpreter | [`audio/engine.asm`](https://github.com/pret/pokegold/blob/a0dad0957ac8a9ffa67e950ee3ab6715a212ded5/audio/engine.asm) | [`audio/engine.asm`](https://github.com/pret/pokecrystal/blob/8e8f7e20052a596371a77022f0392c285e51bbf1/audio/engine.asm) | Core interpreter is structurally the same | New Crystal music is primarily sequenced data and pointers | -| Song table | [`audio/music_pointers.asm`](https://github.com/pret/pokegold/blob/a0dad0957ac8a9ffa67e950ee3ab6715a212ded5/audio/music_pointers.asm) | [`audio/music_pointers.asm`](https://github.com/pret/pokecrystal/blob/8e8f7e20052a596371a77022f0392c285e51bbf1/audio/music_pointers.asm#L97-L108) | Crystal appends ten named songs | Existing song IDs remain stable through the Gold/Silver range | - -The line counts of several Crystal files are larger, but line count alone is not a behavioral metric; comments, label quality, and source refactoring affect it. The stronger evidence is the presence of new branches, fields, tables, and externally observable side effects. - -### Hardware-mode divergence - -Gold/Silver initialization tests whether the console is a Game Boy Color and maintains non-CGB paths, including Super Game Boy initialization. Crystal includes an explicit `gbc_only.asm` module that displays the incompatibility message on non-CGB hardware. That is not merely marketing metadata: it changes what assumptions later rendering code may safely make about palettes, VRAM banking, and Color hardware. - -For a reverse port of Crystal features into Gold/Silver, every CGB-only feature falls into one of three categories: provide a monochrome/SGB fallback, disable it outside CGB mode, or intentionally convert the resulting ROM into a Color-only build. Skipping that decision produces subtle failures rather than one clean compile error: palette attributes, second-bank VRAM data, and tile-upload timing can all be implicated. - -## Save, RNG, clock, and entity state - -### Save architecture - -Gold/Silver divide their main game state into options, three player-data fragments, current-map data, Pokémon data, and a checksum. Backup pieces are distributed among several separately placed SRAM sections. The current PC box is separate, the fourteen inactive boxes occupy exactly two SRAM banks, and mail and Mystery Gift have dedicated regions. - -Crystal instead defines a contiguous `sGameData` consisting of player, map, and Pokémon data, pads the region, and stores a checksum. Its backup is similarly grouped. Crystal then adds a GS Ball flag, `sCrystalData`, Battle Tower progress and recent-trainer state, ranking records, Mobile communication data, offers, credentials, and Japanese Mobile-specific buffers. The source explicitly notes that the international `sCrystalData` location differs from Japanese Crystal. - -| Save component | Gold/Silver | Crystal | -|---|---|---| -| Options and corruption sentinels | Present | Present | -| Main player/map/Pokémon data | Split into labeled player fragments inside the save region | Grouped into one contiguous main-game block | -| Main checksum | 16-bit checksum after game data | 16-bit checksum after padded game-data region | -| Backup | Fragmented among multiple SRAM sections | Consolidated backup game-data block | -| Current box | Separate `curbox` structure | Separate full `box` structure plus padding | -| Inactive boxes | Fourteen boxes over two SRAM banks | Same fourteen-box capacity over two banks | -| Party and mailbox mail | Dedicated primary and backup regions | Same broad model | -| GS Ball state | Absent | Primary and backup flags | -| Battle Tower | Absent | Challenge state, streak, previous teams, reward | -| Mobile/rankings | Absent | Several additional SRAM sections | -| Full-file compatibility | Gold and Silver are closely related but still edition-specific | Not layout-compatible with Gold/Silver | - -Crystal's save routine makes the transactional sequence visible: - -```asm -_SaveGameData: - farcall StageRTCTimeForSave - farcall BackupMysteryGift - call ValidateSave - call SaveOptions - call SavePlayerData - call SavePokemonData - call SaveBox - call SaveChecksum - call ValidateBackupSave - ... - farcall BackupGSBallFlag - farcall SaveRTC -``` - -Source: [Crystal `engine/menus/save.asm`](https://github.com/pret/pokecrystal/blob/8e8f7e20052a596371a77022f0392c285e51bbf1/engine/menus/save.asm#L255-L284). The routine stages RTC state, writes the primary data and box, computes the primary checksum, writes backup data and checksum, backs up party mail and GS Ball state, persists RTC state, and normalizes a completed Battle Tower reward state. - -The practical conclusion is precise: **do not migrate a Gold/Silver `.sav` to Crystal by copying the file or the main save block**. A converter should parse symbolic fields, validate checksums and sentinels, copy player/map/Pokémon/box/mail data field by field, initialize Crystal-only state, and regenerate Crystal checksums. Gold-to-Silver conversion is less structurally disruptive, but version-dependent event and encounter state should still be treated deliberately. - -### Party and boxed-Pokémon records - -One of Crystal's most elegant compatibility decisions is visible at the structure-definition level. In Gold/Silver, the two bytes after `MON_POKERUS` are simply reserved: - -```asm -DEF MON_HAPPINESS rb -DEF MON_POKERUS rb - rb_skip 2 -DEF MON_LEVEL rb -``` - -In Crystal, the same positions are assigned to a two-byte caught-data field: - -```asm -DEF MON_HAPPINESS rb -DEF MON_POKERUS rb -DEF MON_CAUGHTDATA rw -DEF MON_LEVEL rb -``` - -Sources: [Gold/Silver structure](https://github.com/pret/pokegold/blob/a0dad0957ac8a9ffa67e950ee3ab6715a212ded5/constants/pokemon_data_constants.asm#L83-L101) and [Crystal structure](https://github.com/pret/pokecrystal/blob/8e8f7e20052a596371a77022f0392c285e51bbf1/constants/pokemon_data_constants.asm#L83-L107). - -Crystal packs caught time and trainer gender into one byte and caught level and location into another. Because Game Freak repurposed reserved bytes instead of extending the record, `BOXMON_STRUCT_LENGTH` and `PARTYMON_STRUCT_LENGTH` remain compatible. Gold/Silver can carry those bytes without understanding them; Crystal can interpret them when present. This is a major reason Gen II Pokémon can move among Gold, Silver, and Crystal without a record-size translation layer. - -A second structure change appears in the base-species TM/HM compatibility bitset. Gold/Silver size it for `NUM_TM_HM`; Crystal sizes it for `NUM_TM_HM_TUTOR`, accommodating Move Tutor compatibility in the same species-data model. This means a straight copy of Gold/Silver base-stat records into Crystal must account for Crystal's expanded learnability domain even if the visible species stats are unchanged. - -### Item and party handling - -The basic inventory model (separate item pockets, held-item byte in each Pokémon record, party length of six, PC boxes, mail records, and item-dispatch routines) is inherited. Crystal's changes are concentrated in new consumers and UI behavior: Move Tutor eligibility, gender-specific player presentation, caught metadata, Battle Tower restrictions, and additional event items such as GS Ball state. - -Crystal did **not** comprehensively repair the item mechanics. Its documented defects still include Moon Ball not applying its intended multiplier, Love Ball checking the wrong gender relationship, Fast Ball applying to only a few species, three incorrect Heavy Ball weight cases, and status conditions failing to affect capture rate as intended. Those defects live in shared mechanical paths and are important when evaluating a "faithful" engine port: correcting them changes gameplay and may alter deterministic test vectors. - -### Ordinary RNG - -Gold/Silver and Crystal use the same ordinary RNG routine. It samples the hardware divider register and updates two one-byte accumulators, one by addition-with-carry and one by subtraction-with-carry: - -```asm -ldh a, [rDIV] -ld b, a -ldh a, [hRandomAdd] -adc b -ldh [hRandomAdd], a - -ldh a, [rDIV] -ld b, a -ldh a, [hRandomSub] -sbc b -ldh [hRandomSub], a -``` - -Sources: [Gold/Silver](https://github.com/pret/pokegold/blob/a0dad0957ac8a9ffa67e950ee3ab6715a212ded5/home/random.asm#L13-L27) and [Crystal](https://github.com/pret/pokecrystal/blob/8e8f7e20052a596371a77022f0392c285e51bbf1/home/random.asm#L13-L27). The files are effectively identical, including the `RandomRange` rejection-sampling routine used to avoid simple modulo bias. - -This generator is not a self-contained seeded PRNG in the modern sense. Its output depends on divider phase, previous accumulator state, carry state, VBlank updates, and when the routine is reached. Consequently, adding an animation, menu delay, conditional call, or extra random-consuming feature can alter later outcomes even while the RNG instructions remain unchanged. - -### Link-battle PRNG - -Battles route randomness through `BattleRandom`. In non-link play, `_BattleRandom` ultimately uses the ordinary RNG. In linked battles, both systems consume a shared ten-byte random sequence and advance bytes with the recurrence: - -``` -x[n+1] = (5 * x[n] + 1) mod 256 -``` - -The Gold/Silver and Crystal battle cores contain the same broad synchronization mechanism. - -This has a major compatibility consequence: a battle patch can be logically correct in isolation yet break link play if it causes one participant to make a different number or order of RNG calls. That is why Crystal sometimes preserves Gold/Silver behavior specifically in link battles. Synchronization depends on matching control flow, not merely using the same random-number formula. - -### RTC and time-of-day behavior - -Both codebases latch the MBC3-style real-time clock, read seconds, minutes, hours, and the low/high day registers, normalize time, add the player's selected starting offset, and derive the current time-of-day state. Crystal's `UpdateTime` remains a short chain of `GetClock`, `FixDays`, `FixTime`, and `GetTimeOfDay`. - -Crystal's source shows the day counter reduced modulo 140 for the game's weekly/event model, with status flags distinguishing an RTC count beyond 139 days and a hardware day-high overflow beyond 255 days. The displayed game time is formed by adding the new-game start offsets to RTC values with carry through seconds, minutes, hours, and day. - -The notable architectural addition is the Crystal timer interrupt's Mobile dispatch: - -```asm -Timer:: - push af - ldh a, [hMobile] - and a - jr z, .not_mobile - call MobileTimer -.not_mobile - pop af - reti -``` - -Source: [Crystal `home/time.asm`](https://github.com/pret/pokecrystal/blob/8e8f7e20052a596371a77022f0392c285e51bbf1/home/time.asm#L2-L11). The ordinary RTC logic remains inherited; Japanese/mobile Crystal adds another timing consumer. - -For emulators, flash cartridges, and ports, SRAM alone is insufficient. RTC registers, halt/carry state, elapsed host time, and game offsets must be preserved consistently. A save imported without corresponding RTC state may be structurally valid yet produce incorrect daily events, phone behavior, berries, swarms, day-of-week encounters, or time-dependent evolutions. - -## Battles and encounters - -### Battle-engine inheritance - -The core Gen II battle model is shared: turn selection, speed ordering, accuracy, damage, stat stages, held items, status, volatile effects, experience, capture, AI, and link synchronization follow the same broad engine. Crystal's battle core is larger principally because it adds Battle Tower integration, additional presentation paths, and selective corrections. - -Crystal is therefore mechanically closer to a patched and extended Gold/Silver than to a later-generation ruleset. There are no abilities, natures, modern physical/special move split, or rewritten damage model. Its new content operates within Gen II's existing move and Pokémon structures. - -### Control-flow difference: Battle Tower exclusions - -Gold/Silver's `BadgeStatBoosts` exits for link battles and otherwise applies badge boosts. Crystal adds a second early return when `wInBattleTowerBattle` is nonzero: - -```asm -ld a, [wLinkMode] -and a -ret nz - -ld a, [wInBattleTowerBattle] -and a -ret nz -``` - -Source: [Crystal `BadgeStatBoosts`](https://github.com/pret/pokecrystal/blob/8e8f7e20052a596371a77022f0392c285e51bbf1/engine/battle/core.asm#L6526-L6532). Gold/Silver have only the link-mode guard at the corresponding point. - -This tiny six-instruction addition is representative of Crystal's design. The stat algorithm was not replaced; Crystal introduced a new battle context and inserted a guard so adventure-only badge advantages do not leak into the standardized Battle Tower. Similar context checks appear around experience and other post-battle handling. - -### Selective fixes constrained by link compatibility - -The pret Crystal bug documentation explicitly states that Crystal fixed Gold/Silver's Reflect/Light Screen overflow and Present damage behavior only where doing so would not break ordinary cross-version link battles. Link-mode behavior retains the compatible path. - -That is an important historical engineering tradeoff. A fully corrected Crystal battle engine would disagree with Gold/Silver on intermediate values and potentially on RNG consumption, damage, fainting, or message sequence. Compatibility with the installed base was treated as a protocol requirement. Consequently, "Crystal fixed the bug" can mean **fixed in single-player but deliberately retained in linked simulation**. - -### Shared and persistent battle defects - -Crystal retains a substantial Gen II defect surface. Documented examples include: - -| Defect | Mechanical effect | Crystal status | -|---|---|---| -| "100%" secondary effects | Fail in 1/256 qualifying cases | Retained | -| Belly Drum | May maximize Attack even when the HP requirement is not properly met | Retained | -| Berserk Gene | Confusion duration can become 256 turns or inherit stale state | Retained | -| Confusion damage | Can receive type-item and Explosion/Self-Destruct modifiers | Retained | -| Beat Up | Can behave incorrectly and can desynchronize link battles | Retained | -| Return/Frustration edge | Can produce zero damage at extreme happiness values | Retained | -| Dragon boosting item | Dragon Scale is checked instead of Dragon Fang | Retained | -| Glacier Badge | Special Defense boost depends incorrectly on overwritten accumulator state | Retained | -| Capture status bonus | Burn, poison, and paralysis do not contribute as intended | Retained | -| Specialty Balls | Multiple Apricorn-ball formulas target the wrong condition or table | Retained | - -These are documented against original Crystal code, not inferred from modern competitive summaries. - -The Glacier Badge defect is especially instructive at ASM level. The routine shifts a badge bitfield through register `b`, calls `BoostStat`, and later reuses register `a` as if it still contained the expected badge value. `BoostStat` can overwrite `a`, so whether Special Defense receives its boost depends on the Special Attack calculation's resulting register state. The bug survived Crystal even though the source gained an explicit comment identifying it. - -### Gold/Silver defects corrected in Crystal - -The `pokegold` bug document separates defects fixed in Crystal from defects still shared with it. It identifies seven clear Gold/Silver corrections: Coin Case arbitrary code execution, Hall of Fame corruption when no prior save exists, Lucky Number failure to inspect boxes 10-14, Present text overflow, surfing onto NPCs, fishing inside Cerulean Gym, and Route 15 capitalization. - -The Coin Case correction is one bytecode-level terminator change: - -```diff - text "Coins:" - line "@" - text_decimal wCoins, 2, 4 --done -+text_end -``` - -Source: [`pokegold/docs/bugs_and_glitches.md`](https://github.com/pret/pokegold/blob/a0dad0957ac8a9ffa67e950ee3ab6715a212ded5/docs/bugs_and_glitches.md#L20-L34). The Gold/Silver terminator permits text-command execution to continue into unintended memory under exploitable conditions; Crystal ends the text stream correctly. - -The Hall of Fame correction adds a saved-at-least-once check and erases/initializes previous-save structures before attempting the Hall of Fame write. The Lucky Number correction changes the loop bound from the Japanese box count constant to the international fourteen-box count. Both are good examples of Crystal correcting localization-sensitive state assumptions rather than changing game design. - -### Encounter-rate algorithm - -Gold/Silver and Crystal use essentially the same high-level encounter pipeline: - -```mermaid -flowchart TD - STEP[Eligible movement step] - RATE[Read morning/day/night or water rate] - MOD1[Apply radio modifier] - MOD2[Apply Cleanse Tag modifier] - ROLL[Call Random and compare with rate] - SLOT[Choose encounter slot] - ROAM[Check roaming Pokémon] - REPEL[Apply Repel level check] - BATTLE[Stage wild battle] - - STEP --> RATE --> MOD1 --> MOD2 --> ROLL - ROLL -->|pass| SLOT - SLOT --> ROAM - ROAM --> REPEL - REPEL --> BATTLE -``` - -In both engines, `TryWildEncounter` gets the map rate, applies Pokémon March/Ruins of Alph doubling or Pokémon Lullaby halving, applies Cleanse Tag halving, draws an RNG byte, chooses a slot from grass or water probability tables, and checks Repel. - -Grass records contain separate morning, day, and night slot blocks; the active block is selected through `wTimeOfDay`. Water uses a separate rate and three-slot table. The structure constants remain seven grass slots and three water slots in both codebases. - -### Encounter-table differences - -Gold and Silver primarily diverge through edition-conditioned tables. Crystal has its own consolidated tables and changes both availability and placement. It makes several former Gold/Silver exclusives obtainable in one edition, while removing some species available in both base versions; Mareep's evolutionary family is a prominent Crystal omission. Crystal also moves species such as Sneasel to different locations. - -The engine/table distinction is important. An encounter may differ because: - -1. the map's encounter-rate byte changed; -2. a morning/day/night slot changed; -3. the slot's level changed; -4. a swarm override changed; -5. a roaming Pokémon was removed from the roaming subsystem; -6. the map itself gained or lost encounter-enabled tiles. - -A robust diff should therefore compare map headers, wild tables, swarm flags, and roaming initialization, not merely produce a Pokédex availability list. - -### Swarms and roaming state - -Gold/Silver's swarm lookup is comparatively generic: it compares the current map against `wSwarmMapGroup` and `wSwarmMapNumber`. Crystal's code has explicit Dunsparce and Yanma swarm flags and corresponding map state. That is a data-model specialization, not just a changed encounter table. - -The roaming difference is even clearer. Gold/Silver initialize three records: - -```asm -ld a, RAIKOU -ld [wRoamMon1Species], a -ld a, ENTEI -ld [wRoamMon2Species], a -ld a, SUICUNE -ld [wRoamMon3Species], a -``` - -Crystal initializes only Raikou and Entei: - -```asm -ld a, RAIKOU -ld [wRoamMon1Species], a -ld a, ENTEI -ld [wRoamMon2Species], a -``` - -Sources: [Gold/Silver `InitRoamMons`](https://github.com/pret/pokegold/blob/a0dad0957ac8a9ffa67e950ee3ab6715a212ded5/engine/overworld/wildmons.asm#L471-L511) and [Crystal `InitRoamMons`](https://github.com/pret/pokecrystal/blob/8e8f7e20052a596371a77022f0392c285e51bbf1/engine/overworld/wildmons.asm#L476-L506). - -Gold/Silver's subsequent selection logic describes an equal choice among three beasts after the roaming check succeeds; Crystal changes the corresponding comment and index range to two. Suicune's removal is tied to Crystal's expanded Eusine/Suicune plot and scripted Tin Tower encounter. - -For save conversion, a Gold/Silver Suicune roaming record cannot simply remain active in Crystal. The converter must map capture/defeat/event state into Crystal's scripted Suicune flags or deliberately define a hybrid behavior. - -## Graphics and audio - -### Tile, palette, and sprite model - -All three games retain the Game Boy's tile-oriented rendering model: graphics are stored as compact tile data, decompressed or copied into VRAM, arranged through background/window tilemaps, and supplemented by hardware sprites through shadow OAM. Pokémon and many UI images use four-color source palettes, with separate palette data determining their actual colors. The pokecrystal FAQ explicitly notes the four-color paletted-PNG convention and the distinction between image data and palette data. - -Gold/Silver must support original Game Boy-style output and Super Game Boy behavior as well as Game Boy Color enhancements. Crystal's explicit Color-only startup path lets it make stronger use of CGB layout and palette machinery. Its top-level architecture adds `crystal_layouts.asm`, dedicated player graphics, revised map and battle presentation, and other CGB-focused modules. - -### Animated Pokémon sprites - -Crystal's headline renderer change is animated front sprites. Every Pokémon receives an entrance animation, and the status/profile viewer can play a longer animation; several designs, palettes, and back sprites were also revised. Crystal additionally gives the legendary beasts distinct overworld sprites and introduces richer trade-screen presentation. - -At the engine level, this requires more than storing extra frames. A full animation path needs: - -- a base front picture and additional frame or bitmask data; -- frame sequencing and duration state; -- tile-buffer reconstruction or differential tile updates; -- synchronization with battle intro control flow; -- palette and VRAM updates during safe LCD periods; -- fallback behavior for static contexts such as link displays or icons. - -Crystal's `load_pics.asm` is correspondingly larger and is integrated with dedicated animation and battle-intro modules, while Gold/Silver's loader primarily services static front/back pictures. The exact file-size difference should not be treated as a performance measurement, but the added code paths confirm a broader graphics pipeline. - -Animated fronts can also alter RNG-observable timing indirectly. The ordinary RNG is updated from divider timing and VBlank activity; therefore, any test that compares post-animation random outcomes must control whether animations are enabled and how many frames elapsed. This is an inference from the animation and RNG architectures, not evidence that every animation directly invokes `Random`. - -### Color and protagonist handling - -Crystal adds player-gender initialization and Kris-specific bag/player graphics. The Pokémon record's caught-data bits can record whether the catcher was the boy or girl protagonist. This is an example of a feature spanning UI, overworld sprites, menus, Pokémon metadata, and save state rather than existing in one isolated "female player" switch. - -A Crystal-to-Gold/Silver backport must decide how to handle caught-by-girl metadata when Gold/Silver have no female protagonist UI. Structurally the bytes can survive because they were reserved, but Gold/Silver will not natively display or generate the metadata. - -### Sound-engine architecture - -The most striking audio result is how little the interpreter changed. Both repositories' `audio/engine.asm` identify themselves as the entire sound engine, update once per frame, parse music commands, maintain eight software channel structures (four music and four sound-effect channels), and ultimately drive the four Game Boy audio channels. Both implementations handle duty, envelope, frequency, vibrato, noise sampling, channel muting, low-health sound, and fades through the same broad code. - -```mermaid -flowchart LR - SONG[Song bytecode] - PARSE[ParseMusic] - STATE[Per-channel WRAM state] - FX[Vibrato / pitch / envelope / noise] - MIX[Music-SFX priority and routing] - APU[Game Boy audio registers] - - SONG --> PARSE --> STATE --> FX --> MIX --> APU -``` - -Crystal therefore does not introduce a new synthesizer. It extends the music corpus and invokes new songs in new contexts. - -### Crystal's additional music - -Gold/Silver's song pointer table ends at `Music_PostCredits`. Crystal retains that ordering and appends ten entries: - -| Crystal-only pointer entry | Use | -|---|---| -| `Music_Clair` | Clair-related scene | -| `Music_MobileAdapterMenu` | Japanese Mobile menu | -| `Music_MobileAdapter` | Mobile connectivity | -| `Music_BuenasPassword` | Buena's Password | -| `Music_LookMysticalMan` | Eusine encounter | -| `Music_CrystalOpening` | Revised opening | -| `Music_BattleTowerTheme` | Battle Tower battle/context | -| `Music_SuicuneBattle` | Legendary-beast/Suicune battle theme | -| `Music_BattleTowerLobby` | Battle Tower lobby | -| `Music_MobileCenter` | Japanese Mobile Center | - -The pointer table labels these explicitly as "new to Crystal." - -Bulbapedia independently notes that Crystal gives the legendary beasts a unique battle theme and presents it as the first core-series special legendary battle music. - -From a porting perspective, bringing Crystal music into Gold/Silver primarily requires assigning ROM space, importing sequence data, adding pointer/constants entries, and adding selection call sites. Replacing the audio engine is generally unnecessary. However, preserving existing numeric song IDs is wise because map headers, scripts, battle setup, radio state, and fades refer to those constants. - -## Bugs, compatibility, porting, and reverse-engineering timeline - -### Bug and quirk matrix - -| Category | Gold/Silver | Crystal | Engineering interpretation | -|---|---|---|---| -| Coin Case text terminator | Exploitable continuation can permit arbitrary code execution | Correct terminator | Parser/data correction | -| First-save Hall of Fame | Can corrupt PC boxes | Guard and initialization added | Save-state precondition correction | -| Lucky Number boxes | International boxes 10-14 omitted | Uses full box count | Localization constant corrected | -| Surf onto NPC | Possible | Facing-object check added | Collision precondition corrected | -| Cerulean Gym fishing | Enabled by map fish group | Disabled | Map-header data correction | -| Reflect/Light Screen overflow | Incorrect | Fixed outside compatible link path | Context-dependent battle fix | -| Present damage | Incorrect | Fixed outside compatible link path | Protocol-preserving fix | -| Glacier Badge Special Defense | Bugged | Still bugged | Shared inherited defect | -| Apricorn specialty balls | Multiple formula defects | Still defective | Shared inherited mechanics | -| Beat Up synchronization | Vulnerable | Still vulnerable | Shared link-engine defect | -| Secondary-effect 1/256 failure | Present | Present | Shared probability comparison defect | -| Animated-sprite quirks | Not applicable | New animation-specific defects possible | Feature expansion creates new failure surface | - -The Gold/Silver and Crystal bug documents should be read together. The Gold/Silver document intentionally lists only bugs that Crystal fixed; any shared defects are documented in the Crystal repository instead. - -Crystal is thus more polished but not mechanically "corrected" in a comprehensive sense. A modern source port must choose a compatibility target: - -- **Retail-faithful:** preserve all version-specific bugs and timing. -- **Crystal-faithful:** preserve Crystal's selective fixes and its link-mode exceptions. -- **Corrected Gen II:** repair documented defects, accepting that link compatibility and historical RNG traces may change. -- **Hybrid:** gate corrections behind flags or negotiate them between identical modified link peers. - -### ROM revisions and regional code - -The Crystal repository builds international v1.0, v1.1, and Australian releases. Its FAQ states that v1.1 corrected some issues in the initial international release, while the Australian build is based on v1.1 and censors gambling references. Thus "Crystal behavior" is not completely singular even within English-language retail ROMs. - -A serious test report should always state the target hash rather than merely "Pokémon Crystal." Otherwise, an observed difference may be edition-level, v1.0/v1.1-level, Australian localization, Japanese Mobile code, Virtual Console patching, emulator behavior, or an altered ROM. - -### Link and trade compatibility - -Gold, Silver, and Crystal share the Gen II Pokémon record length and battle/link architecture, allowing ordinary same-generation trading and battling. The retained two-byte record size, shared `REDMON_STRUCT_LENGTH` conversion constant for Time Capsule interaction, and synchronized battle RNG are all source-level evidence of compatibility-oriented design. - -However, three distinct notions of compatibility must not be conflated: - -| Compatibility type | Status | -|---|---| -| Pokémon record compatibility | Strong: same record length; Crystal fills reserved bytes | -| Link protocol/battle simulation compatibility | Strong for retail games, partly because Crystal preserves old link behavior | -| Whole-save-file compatibility | Weak: Crystal's SRAM organization and added state differ substantially | -| ROM patch-address compatibility | Weak: bank layout and code placement differ | -| Feature-source portability | Moderate: shared architecture helps, but dependencies are broad | -| Gen I Time Capsule data compatibility | Supported through dedicated conversion structures and restrictions | -| Modern transfer compatibility | Supported from 3DS VC releases through Poké Transporter and Pokémon Bank | - -Nintendo's support documentation lists the Virtual Console releases of Gold, Silver, and Crystal as compatible with Poké Transporter. Transfers proceed into Pokémon Bank and can then move onward to Pokémon HOME, but that modern path is not a raw Gen II save conversion and is effectively one-way at later stages. - -### Virtual Console modifications - -Crystal's save code contains an explicit `vc_hook` after Hall of Fame insertion. The hook sets the primary and backup GS Ball flags to make the GS Ball quest and Celebi encounter available, with compile-time assertions pinning the expected SRAM addresses and flag value. - -```asm -vc_hook Enable_GS_Ball_mobile_event -vc_assert BANK(sGSBallFlag) == $1 -vc_assert BANK(sGSBallFlagBackup) == $1 -``` - -Source: [Crystal `engine/menus/save.asm`](https://github.com/pret/pokecrystal/blob/8e8f7e20052a596371a77022f0392c285e51bbf1/engine/menus/save.asm#L157-L167). - -This is a fascinating compatibility shim: the original international cartridge retained GS Ball-related structures but lacked the original Japanese distribution path, so the Virtual Console wrapper activates the event through a targeted runtime hook. The Pokémon Company confirms that the Virtual Console version permits the Celebi encounter at Ilex Forest's shrine. - -The pret repositories also contain `vc` build material, and community GitHub discussion has addressed preserving or generating Virtual Console patches. A port targeting 3DS VC behavior should therefore compare not only the retail ROM but the external patch/hook layer. - -### Japanese Crystal and mapper/SRAM concerns - -Crystal's SRAM source reserves several Mobile sections and explicitly comments that a Mobile Easy Chat initialization routine uses an "MBC30 bank" available to Japanese Crystal but inaccessible with ordinary MBC3 behavior. This is direct evidence that regional hardware assumptions matter when emulating or reproducing Japanese Mobile features. - -An implementation that supports only the common international MBC3-style SRAM/RTC configuration may run the international game correctly yet fail Japanese Crystal's extended Mobile storage accesses. Conversely, allocating the larger memory blindly does not implement Mobile Adapter protocols, timer behavior, ranking checksums, or server-era workflows. - -### Porting guidance - -The safest migration strategy between the two disassemblies is subsystem-oriented: - -| Port direction | Recommended approach | Main hazard | -|---|---|---| -| Gold <-> Silver data | Preserve shared engine; switch edition tables/assets | Conditional data references and event assumptions | -| Crystal feature -> Gold/Silver | Import feature plus all WRAM/SRAM, graphics, script, audio, and menu dependencies | DMG/SGB fallback and bank-space pressure | -| Gold/Silver behavior -> Crystal | Replace data or selectively restore old branch | Crystal story/event state may expect new semantics | -| Gold/Silver save -> Crystal | Parse and rebuild symbolic fields | Relocated blocks and Crystal-only state | -| Crystal save -> Gold/Silver | Strip Crystal state and preserve common Pokémon/player data | Caught metadata becomes opaque; Suicune/Battle Tower state has no target | -| Retail battle fix | Gate by non-link mode or require identical modified peers | RNG/control-flow desynchronization | -| Crystal graphics -> DMG-compatible engine | Create monochrome layouts or make build CGB-only | Palette attributes and animation timing | -| Japanese Mobile feature port | Emulate mapper, extended SRAM, timer, and communication assumptions | Hardware and defunct-service dependencies | - -Bank space is a concrete constraint. The pokecrystal FAQ describes the international ROM as 2 MiB divided across banks and notes that adding features can overflow fixed bank placement, requiring sections to be moved through the linker script. Crystal's extra features already consume carefully arranged banks, so a port that compiles at the object level can still fail at link time. - -The deepest practical rule is: **port invariants before routines**. Before copying code, define the target's expected record lengths, bank-switch convention, WRAM variables, SRAM addresses, script command set, palette mode, RNG call contract, and link-mode semantics. Once those invariants match, most inherited Gen II routines are straightforward. Without them, a perfectly copied routine can read the wrong field, switch to the wrong bank, write outside a save block, or desynchronize a link battle. - -### Release and reverse-engineering evidence timeline - -```mermaid -timeline - title Gold, Silver, and Crystal technical lineage - 1999-11-21 : Gold and Silver released in Japan - 2000-10-15 : Gold and Silver released in North America - 2000-12-14 : Crystal released in Japan - 2001 : International Crystal releases - : Crystal adds Color-only presentation, animations, Battle Tower, and expanded Suicune story - 2017-09-22 : Gold and Silver released for Nintendo 3DS Virtual Console - 2018-01-26 : Crystal released for Nintendo 3DS Virtual Console - : VC hook enables the GS Ball and Celebi event - 2026 : pret repositories reproducibly build exact retail revisions - : ASM-level bug catalogs and symbolic layouts support controlled comparison -``` - -Gold and Silver launched in Japan on November 21, 1999 and in North America on October 15, 2000; Crystal followed in Japan on December 14, 2000 and internationally in 2001. Gold/Silver reached 3DS Virtual Console in September 2017 and Crystal followed in January 2018. - -The current reverse-engineering record is better described as an evolving evidence base than as a single "discovery date." pret's labels, comments, and bug documentation have accumulated over thousands of repository commits, while the exact-build hashes provide a stable anchor to the retail binaries. Crystal's repository currently has substantially more history and feature documentation than `pokegold`, reflecting its role as the community's principal Gen II hacking platform rather than evidence that Crystal's retail source was inherently better documented. - -### Final technical assessment - -Gold and Silver are functionally sibling configurations of one engine. Their meaningful differences are overwhelmingly table-, asset-, and version-flag-driven. Crystal preserves that engine's defining architecture but turns it into a more specialized platform: Color-only graphics, animated Pokémon, richer event scripting, expanded persistent state, Mobile-era infrastructure, Battle Tower contexts, caught metadata, additional music, and selective defect correction. - -At ASM level, the most consequential changes are not usually exotic algorithms. They are small branches with large semantic reach: - -- a Battle Tower early return suppresses badge boosts; -- two reserved Pokémon bytes acquire caught metadata without changing record size; -- Suicune disappears from `InitRoamMons`; -- save blocks become reorganized and gain new persistent domains; -- a text terminator closes the Coin Case execution path; -- link-mode checks preserve old arithmetic to avoid desynchronization; -- a Virtual Console hook activates an otherwise inaccessible event; -- a Mobile timer branch attaches a new subsystem to the interrupt path. - -That pattern explains both Crystal's compatibility and its porting difficulty. It remains recognizably the Gold/Silver engine, but its new features are woven through banks, state structures, control-flow conditions, graphics timing, SRAM, scripts, and presentation. The code is cousin-shaped, not copy-paste-shaped: the classic reverse-engineering booby trap wearing a tiny Suicune hat. \ No newline at end of file diff --git a/docs/extraction-notes.md b/docs/extraction-notes.md deleted file mode 100644 index 635a00a3..00000000 --- a/docs/extraction-notes.md +++ /dev/null @@ -1,50 +0,0 @@ -# ROM Extraction Notes - -There are two ROM-only extraction paths: - -- The packaged app uses `src/import/RomImporter.lua` and - `src/import/RomExtractor.lua` on first boot. -- Developers can run `tools/build_data.py --rom [--clean]` to generate - data in the source tree for audit and parity work. - -Both paths read only the supplied ROM and the checked-in -`tools/rom_manifest.json`. Neither invokes RGBDS, Git, or a disassembly. - -## Validation - -Only the canonical US Pokemon Red ROM is supported. SHA-1 is checked before -any cached output is removed or written. - -## Decoded Data - -| Area | ROM data | -| --- | --- | -| world | map headers, block maps, connections, warps, signs, objects | -| tiles | tileset graphics, blocksets, collision, door and warp tile lists | -| text | 2,584 text command streams and RAM/number substitutions | -| Pokemon | names, stats, evolutions, learnsets, Dex data, compressed pictures | -| battle | moves, detailed animations, OAM frames/tiles, effects, type chart, palettes, trainer parties/AI/pictures | -| inventory | item names, prices, key-item flags, TM/HM data | -| encounters | grass and water wild tables | -| UI | fonts, icons, title/intro, trainer card, town map, slots, field effects | -| audio | music, SFX and cry headers, channel programs, wave instruments | - -The Python and Lua picture decompressors implement the Gen 1 `pic` format. -Graphics are converted to RGBA PNGs. OAM artwork uses transparent color 0; -battle pictures use edge-connected white matting so white interior details -remain visible. - -The in-app importer stores three audio ROM banks as a 48 KiB -`programs.bin`. `src/core/ChipAudio.lua` interprets the channel bytecode and -synthesizes music as a queueable stream; SFX and cries are synthesized on -demand. This avoids shipping or generating a large WAV/OGG tree. - -## Metadata Boundary - -Names, dimensions, enum ordering, Lua script hooks, and hand-ported field -behavior do not survive compilation in a form the Lua runtime can infer. -Those relationships are bundled in `rom_manifest.json`. The manifest stores -no dialogue strings, images, audio samples, or ROM bytes. - -`tools/make_rom_manifest.py` and `tools/verify_rom_data.py` are developer audit -tools. They are not used by the packaged game. diff --git a/docs/gen2-link-design.md b/docs/gen2-link-design.md deleted file mode 100644 index b1b94584..00000000 --- a/docs/gen2-link-design.md +++ /dev/null @@ -1,316 +0,0 @@ -# Gen 2 link play: what it takes - -This is a design document, not a feature announcement. Gold cannot link today -and nothing in this document changes that on its own. What it does is state -honestly what Gen 2 link play requires, where the Gen 1 protocol in `src/link/` -stops working on a Gen 2 record, what the cart itself did, and which pieces of -the work are self-contained enough to have been built already. - -The short version: the transport, the handshake and the fingerprint are -generation-agnostic or nearly so, and those are done. The party wire format is -half done (a codec exists, nothing sends it). The trade session, the trade UI -and the lockstep battle are not started, and each is a real piece of work. - -Read `docs/mod-api-gen2-compat.md` beside this: it is the reference for what a -Gold mod may touch, and the link fingerprint's whole job is to hash exactly that -surface. - -## 1. What Gen 1 link play is - -`src/link/` is nine files and about four thousand lines: - -| File | Job | -| --- | --- | -| `Net.lua` | lua-enet transport, LAN host/join plus a relay for online play. JSON messages, no game types. | -| `CodeEntry.lua` | the online join code widget. | -| `Handshake.lua` | the `hello` both peers exchange and the compatibility verdict drawn from the two of them. | -| `Fingerprint.lua` | a deterministic digest of the link surface: the slice of merged Data whose value decides whether two lockstep simulations stay identical. | -| `Protocol.lua` | mon serialization, the record-subset negotiation, and the trade session state machine. | -| `LinkState.lua` | the LINK menu, pairing, and the trade/battle hand-off. | -| `LinkBattle.lua` | lockstep battle: both machines run `src/battle/BattleState.lua` from mirrored perspectives on a shared seed, exchanging one action per turn and a per-turn state hash. | -| `Tournament.lua` | bracketed online play over the relay. | -| `Json.lua` | the encoder the wire and several unrelated callers share. | - -Only three of those nine are shaped around Gen 1 game content: `Fingerprint`, -`Protocol` and `LinkBattle`. `Net`, `Json`, `CodeEntry` and most of `Handshake` -never look at a Pokemon. - -## 2. What the cart did - -pokegold's link code is `engine/link/link.asm` (2508 lines), and it is worth -being precise about, because several of the port's design questions have a cart -answer. - -**Three rooms, one wire.** `wLinkMode` is `LINK_TIMECAPSULE`, -`LINK_TRADECENTER` or `LINK_COLOSSEUM` (`constants/serial_constants.asm`). -`LinkCommunications` branches once, at the top, on whether the mode is the Time -Capsule: `Gen2ToGen1LinkComms` for the Time Capsule and `Gen2ToGen2LinkComms` -for everything else (`engine/link/link.asm:33`). Everything after that branch is -shared -- the same byte exchange serves trading and battling, and the mode only -decides what is in the buffer and what the game does afterwards. - -**What crosses the wire, in order** (`Gen2ToGen2LinkComms`, -`engine/link/link.asm:202`): - -1. `SERIAL_RN_PREAMBLE_LENGTH + SERIAL_RNS_LENGTH` bytes: the shared battle - RNG state. Seven preamble bytes and ten seeds. -2. `SERIAL_PREAMBLE_LENGTH + NAME_LENGTH + (1 + PARTY_LENGTH + 1) + 2 + - (PARTYMON_STRUCT_LENGTH + NAME_LENGTH * 2) * PARTY_LENGTH + 3` bytes: the - player name, the party count and species list, the **trainer ID**, six party - structs, six OT names and six nicknames. -3. `SERIAL_PATCH_LIST_LENGTH` bytes: the patch list (see below). -4. In the Trade Center only, the mail block: six mail messages then six mail - metadata structs (`Link_PrepPartyData_Gen2`, `engine/link/link.asm:810`). - -**The shared PRNG is the whole trick behind lockstep.** `_BattleRandom` -(`engine/battle/core.asm:6650`) refuses the normal RNG whenever `wLinkMode` is -non-zero and pulls from `wLinkBattleRNs` instead: ten seeds, each advanced by -`a[n+1] = (a[n] * 5 + 1) % 256` when the stream runs out, with the count in -`wLinkBattleRNCount`. Both machines exchange those ten bytes once, before the -battle, and never again -- the external-clock side adopts the internal-clock -side's numbers (`Link_CopyRandomNumbers`, `engine/link/link.asm:1115`). The port -does the same thing with a Park-Miller stream seeded by the host -(`LinkBattle.makeRng`), which is the same idea in a different arithmetic. - -**The patch list exists because the wire has reserved bytes.** -`SERIAL_NO_DATA_BYTE` (`$fe`) may not appear in the payload, so -`FixDataForLinkTransfer` (`engine/link/link.asm:556`) walks the party block, -replaces every `$fe` with `$ff`, and records the offsets it touched in a -200-byte list that ships alongside. Mail gets its own smaller version of this in -`Link_PrepPartyData_Gen2`. The port's wire is JSON over enet and has no reserved -bytes, so this whole mechanism has no analogue and needs none. It is worth -knowing about only so nobody reimplements it by accident. - -**The Time Capsule is the cart's own answer to a cross-generation link.** -`CheckTimeCapsuleCompatibility` (`engine/link/link.asm:1970`) refuses the party -outright for exactly three reasons, and returns which one in `wScriptVar`: - -1. a species at or above `JOHTO_POKEMON` (`constants/pokemon_constants.asm:168`, - the 152 boundary), -2. a move above `STRUGGLE`, i.e. any move Gen 1 does not have, -3. any party member holding mail. - -If the party passes, `Link_PrepPartyData_Gen1` (`engine/link/link.asm:640`) -rewrites every mon into the 44-byte `REDMON_STRUCT_LENGTH` layout: -`ConvertMon_2to1` remaps the species index through `Pokered_MonIndices` -(`engine/link/time_capsule_2.asm:1`), the Special stat is recomputed from -`KantoMonSpecials` because Gen 1 has one Special and Gen 2 has two, and -Magnemite and Magneton are shipped as pure Electric because their typing changed -(`engine/link/link.asm:731`). Coming the other way, `Link_ConvertPartyStruct1to2` -(`engine/link/link.asm:930`) reads the Gen 1 catch rate byte as the held item -slot -- garbage, which is why `TimeCapsule_ReplaceTeruSama` -(`engine/link/link.asm:1078`) maps the handful of catch rates that collide with -real items through `data/items/catch_rate_items.asm` and turns everything else -into a Berry. `ValidateOTTrademon` (`engine/link/time_capsule.asm:1`) then -re-checks the incoming mon's types against the local base data and refuses -anything that does not agree, with Magnemite and Magneton carved out again. - -The lesson the port should take from all of that: a cross-generation link is not -a compatibility mode, it is a lossy conversion with a validator on each end, and -the cart wrote roughly 400 lines for it. Refusing the pairing is the honest -default until somebody wants to write those 400 lines. - -## 3. Where the Gen 1 protocol breaks on a Gen 2 record - -`Protocol.packMon` / `Protocol.unpackMon` are shaped around -`src/pokemon/Pokemon.lua`. Against `src/battle/gen2/Mon.lua`, every one of these -is wrong: - -| Gen 1 assumption | Gen 2 reality | Where | -| --- | --- | --- | -| `mon.exp` | `mon.experience` | `Mon.new`, `Mon.gainExperience` | -| five DVs including `special` | four rolled DVs (`attack`/`defense`/`speed`/`special`) with `hp` **derived** from their low bits | `Mon.hpDV` | -| `special` is one stat | `specialAttack` and `specialDefense` are two stats off one DV | `Mon.stats` | -| `statExp` has five words | five words still, but the fifth feeds both special stats | `Mon.stats` | -| no held item | `mon.item` decides damage, healing and flinching | `src/battle/gen2/Battle.lua` `heldEffect` | -| stats recomputed with `src/pokemon/Stats.lua` | must go through `Mon.stats`, which is a different formula shape | `Mon.stats` | -| exp curve via `src/pokemon/Growth.lua` (code) | curve coefficients are **data**, at `data.pokemon.growthRates` | `Mon.experienceForLevel` | -| `mon.status` is `"SLP"` / `"PSN"` / `"BRN"` / `"PAR"` / `"FRZ"` | `"sleep"` / `"poison"` / `"burn"` / `"paralyze"` / `"freeze"` / `"toxic"` | `src/battle/Status.lua:62` vs `src/battle/gen2/Battle.lua:65` | -| `mv.ppUps` | Gen 2 has no PP Ups modelled yet; moves carry `id`, `pp`, `maxPp` | `Mon.movesAtLevel` | -| nothing else on the mon | `happiness`, `pokerus`, `caughtLevel`, `isEgg`/`eggSteps`, and the derived `shiny` / `gender` / `unownLetter` | `Mon.new` | -| mail does not exist | mail is a **separate** block keyed by party slot, not a mon field | `src/core/gen2/Mail.lua:84`, and the cart agrees (`Link_PrepPartyData_Gen2`) | - -The status spelling is the sharpest of these. `packMon` puts `mon.status` on the -wire verbatim and `unpackMon` keeps it verbatim, so a shared codec would move -`"PSN"` onto a Gold party where nothing recognises it -- a mon that arrives -"poisoned" and never takes poison damage. Any Gen 2 codec has to be a separate -function with the generation baked into it, not a Gen 1 function with extra -optional keys. - -Two things are *not* a problem, and it is worth saying so: - -- **Species and move ids are shared name spaces.** Both generations key - `data.pokemon` and `data.moves` by the same `TOTODILE` / `TACKLE` strings, so - the wire never needs the cart's index remap (`ConvertMon_2to1`). Ids, not - bytes, is what makes the port's protocol simpler than the cable's. -- **The derived fields do not travel.** `shiny`, `gender` and `unownLetter` are - all functions of the DVs (`Mon.isShiny`, `Mon.gender`, `Unown.letterFromDVs`), - so the receiver recomputes them and a tampered packet cannot claim a shiny it - did not roll. Same reasoning as `unpackMon` recomputing stats. - -`LinkBattle.lua` breaks in a larger way. It calls -`BattleState.makeBattler`, `src/battle/TurnOrder.lua` and Gen 1's damage -pipeline directly. Gold's battle is `src/battle/gen2/Battle.lua`, a different -object with a different event shape. The one piece of good news is that -`Battle.new` already takes `opts.random` (`src/battle/gen2/Battle.lua:220`) -- -the injection seam a lockstep battle needs is there and does not have to be cut. - -## 4. The wire format for Gen 2 - -Message types stay the ones `Protocol.lua` already documents (`hello`, -`records`, `party`, `pick`, `confirm`, `action`, `event`, `bye`). Gen 2 adds -fields, never renames -- the same rule the mod seams follow. - -**`hello`** gains one field, `generation` (1 or 2). A peer that omits it is -generation 1 by construction: no released build has ever had Gen 2 link, so -absent means Red/Blue/Yellow. This is implemented. - -**`party`** carries a list of packed mons whose shape is the Gen 2 one: - -``` -{ species, level, experience, hp, status, nickname, - dvs = { attack, defense, speed, special }, -- hp derived - statExp = { hp, attack, defense, speed, special }, - moves = { { id, pp } ... }, - item, happiness, pokerus, caughtLevel, - ot, otId, isEgg, eggSteps, extra } -``` - -and, in a trade, a parallel `mail` array indexed by the same wire position -- -separate exactly as `Link_PrepPartyData_Gen2` keeps it separate, because mail is -SRAM the cart copies out of `sPartyMail`, not part of the party struct. The -codec for the mon half is implemented; the mail half is not. - -**`records`** gains a third map, `heldItems`, beside `pokemon` and `moves`, so a -subset trade can refuse a mon whose held item the other game would rebuild -differently. Implemented. - -**`action` / `event`** for a lockstep Gen 2 battle are not designed here, because -the design is downstream of a decision nobody has made yet: whether -`src/battle/gen2/Battle.lua` grows a "replay these two actions" entry point or -whether the link battle drives it through the same UI path a local battle uses. -Guessing at a message shape before that is exactly the half-built protocol this -document is meant to avoid. - -## 5. The fingerprint on Gold - -The fingerprint is the one part of the design that is fully answerable today, -because it depends only on what a Gold mod can reach, and `src/mods/Schemas.lua` -answers that exhaustively. - -The Gen 1 surface is species, moves, the type chart, statuses, move effects, -constants and mod-declared link fields. The Gen 2 surface is the same idea over -different tables, plus one registry Gen 1 does not have: - -| What | Gen 1 | Gen 2 | Why it is surface | -| --- | --- | --- | --- | -| species | `data.pokemon` | `data.pokemon` (Gen 2 shape) | base stats decide every damage roll; `evolutions` decides what a traded mon becomes | -| exp curves | code (`src/pokemon/Growth.lua`) | `data.pokemon.growthRates` | the level a traded mon's experience buys | -| moves | `data.moves` | `data.moves`, **plus `effectChance`** | Gen 2 stores the secondary-effect odds per move rather than per effect | -| type chart | `data.type_chart` | same, **plus `foresightMatchups`** | Foresight rewrites Ghost's immunities mid-battle | -| statuses | `data.statuses` | `data.gen2Statuses` | `statPenalty`, `cureOnSwitch`, `beforeMovePriority` | -| move effects | `data.move_effects` | `data.gen2MoveEffects` | which effect is primary, secondary, or accuracy-checked | -| held items | -- | `data.gen2HeldItems` | Leftovers, King's Rock, the type boosters: pure battle math, and the item travels with a traded mon | -| link fields | `data.link_fields` | none (gated) | a mod-declared extra mon field; see below | - -Deliberately **not** hashed on either generation, and the reasoning is the same -in both: - -- Names, dex entries, learnsets, TM/HM lists, sprite paths and `source`. None of - them changes a battle turn or a trade rebuild, and sprite paths differ between - two identical installs. -- `catchRate` (#511). A ball thrown in a link battle is refused; hashing it split - Red/Blue from Yellow over two bytes. -- Balls and item effects. Link battles allow no bag items on either cart. -- `data.gen2Constants`. It is the ROM's ordered name lists -- `mapOrder`, - `spriteOrder`, `speciesOrder`, `heldEffectOrder` -- and it is an *index* space. - Every dispatch in the Gen 2 simulation goes by name (`Battle.heldEffect` - compares `record.heldEffect` strings, `moveEffectRecordFor` keys by - `EFFECT_*`), so reordering a constants list moves no battle math. Hashing it - would split two peers over a list neither of them dispatches on. -- Breeding data (`eggGroups`, `eggMoves`, `eggSteps`). The Day-Care is local. - There is no link breeding and an egg's contents are decided before it is - traded. -- Everything in the Gen 2-only registries that is not `held_items`: - `phone_contacts`, `decorations`, `apricorns`, `landmarks`, `radio_channels`. - None of them can be observed from inside a link session. - -`genderRatio` **is** hashed, unlike anything in the breeding block, because Gen 2 -has Attract and a gender disagreement is a battle-math disagreement. - -`link_fields` stays gated on Gen 2 (`Schemas.GEN2`). It is a mod's declaration -that an extra mon field must survive the wire, and it can only be un-gated once -there is a Gen 2 mon wire format with a session behind it. A registry that hashes -into the fingerprint but that no packer reads would be a lie the fingerprint -tells. - -The hook keeps its Gen 1 name and its Gen 1 arity. `link.fingerprint` is called -as `(data, mods)` on both generations, and the generation is captured in the -vanilla closure rather than passed as a third argument -- a Gen 1 mod that wraps -the hook and forwards `nxt(data, mods)` therefore works verbatim on Gold, which -a third argument would have quietly broken. - -## 6. Cross-generation pairing - -`Handshake.checkCompat` refuses a pairing whose two `generation` fields differ, -with reason `generation_mismatch`, before any other check. This is deliberately -a refusal and not a Time Capsule: section 2 costs roughly 400 lines of lossy -conversion plus two validators, and shipping a menu that pairs a Gold game with a -Red one and then rebuilds a Cyndaquil as whatever species index 155 happens to -be in Red would be worse than refusing. - -If somebody does want the Time Capsule later, the cart's own rules are the spec, -and the port has an advantage the cart did not: ids rather than indices, so -`ConvertMon_2to1`'s remap table is unnecessary. What remains is real work: -refuse species above the Kanto 151, refuse moves Gen 1 lacks, refuse held items -and mail, fold `specialAttack`/`specialDefense` back into one Special -(`KantoMonSpecials`, `data/pokemon/gen1_base_special.asm`), and re-validate types -on arrival with Magnemite and Magneton carved out. - -## 7. What is built, and what is left - -**Built (this change).** - -- `Fingerprint.generationOf(data)`: reads `data.type_chart.generation`, then the - presence of the Gen 2-only Data namespaces, so the digest never has to be told - which game it is running in. -- A Gen 2 link surface in `Fingerprint.lua`, tagged `[gen2]` so a Gen 2 digest - can never collide with a Gen 1 one, covering the table in section 5. -- `Fingerprint.records(data, kind, generation)` for `pokemon`, `moves` and - `held_items` on Gold, which is what a subset trade negotiates on. -- `Handshake.hello` carries `generation`; `checkCompat` refuses a cross-generation - pairing; `describe` explains it in the screen's own voice. -- `held_items` counts as link surface for `Handshake.linkModified`, so a Gold mod - that gives Leftovers a different heal is correctly locked out of online play. - `growth_rates` counts too, and for a reason worth stating: a curve is an - `expForLevel` function, so the fingerprint serializes it as `?` and cannot - hash it at all. `Mon.growthFor` prefers the merged registry over the - extractor's coefficient rows, so a mod declaring `affects_link = false` could - otherwise rewrite every curve -- changing what level a traded mon's experience - buys -- and be caught by neither the digest nor the online gate. -- `Protocol.packMon2` / `unpackMon2`: the Gen 2 party-struct codec, with the - receiver recomputing stats, experience, shininess and gender from real species - data the way `unpackMon` does. Nothing sends it yet. -- `Protocol.recordsMessage` and `eligibleParty` understand held items, and - `TradeSession:_negotiate` builds our own side of the comparison by calling - `recordsMessage` rather than open-coding a subset of it. That is not a - refactor: the open-coded version left `heldItems` off our side only, and both - held-item arms of `eligibleParty` are guarded on our own map, so the whole - check was unreachable from the only caller that matters. - -**Not built.** Sized as honestly as I can: - -| Piece | Size | Why it is not here | -| --- | --- | --- | -| Mail on the wire | small (1-2 days) | needs `Mail.state(save).party` packed as a parallel array and the receiver writing it into its own slot; no consumer until the trade session exists | -| Gen 2 trade session | medium (about a week) | `TradeSession` is generic in shape but `apply` calls `src/world/PikachuFollower`, Gen 1 pokedex fields and Gen 1 trade evolutions; Gold needs `src/core/gen2/TradeAnim.lua`, `src/core/gen2/Evolution.lua` (`EVOLVE_TRADE`) and the Gen 2 pokedex | -| Gold LINK menu and trade UI | medium (about a week) | Gold's menus are `src/ui/gen2/`, its text box is Gen 2 chrome, and the Cable Club script needs the receptionist path; `LinkState.lua` draws with Gen 1 `TextBox` throughout | -| Gen 2 lockstep battle | large (several weeks) | needs a deterministic-replay entry point into `src/battle/gen2/Battle.lua`, a Gen 2 action/event encoding, and a state hash over a much larger volatile-status set than Gen 1's; the `opts.random` seam exists, nothing else does | -| Time Capsule | large | section 6 | -| `link_fields` on Gen 2 | small, but blocked | needs the trade session first | - -The order matters: mail, session and UI are the trade half and can ship without -the battle half, exactly as the cart's Trade Center and Colosseum are separate -rooms. A Gold LINK menu that offers only TRADE is a complete feature. A Gold -LINK menu that offers BATTLE before the lockstep battle exists is the failure -this document was written to prevent. diff --git a/docs/gold-phase1.md b/docs/gold-phase1.md deleted file mode 100644 index dfd86910..00000000 --- a/docs/gold-phase1.md +++ /dev/null @@ -1,188 +0,0 @@ -# Pokemon Gold: Gen 2 import, colour, menus, saving and battles - -Support for a canonical **Pokemon Gold** ROM -(SHA-1 `d8b8a3600a465308c9953dfa04f0081c05bdcb94`, 2 MiB). Gen 2 extraction -and overworld are separate from Red/Blue/Yellow -- never branched into -`RomExtractor.lua` / Gen 1 `Map.lua`. - -## Pipeline - -| Piece | Role | -| --- | --- | -| `src/import/Rom.lua` (`decompressLz3`) | Gen 2 graphics compression (`home/decompress.asm`) | -| `tools/make_gold_manifest.py` → `tools/rom_manifest_gold.json` | pret/pokegold constants + `pokegold.sym` | -| `src/import/RomExtractorGen2.lua` | Gen 2 extract (dispatched from `RomImporter` when `version == "gold"`) | -| `src/core/GameVersion.lua` | `gold` entry (`gold/` cache, `_gold` saves) | -| `src/world/gen2/` | COLL_* world, NPCs, events, roofs | -| `src/script/gen2/` | Opcodes + talk-oriented VM over extracted cmds | -| `src/core/Game2.lua` | Gold's service owner (the Gen 2 peer of `src/core/Game.lua`): boot cinema, intro menu, world, START menu | -| `src/core/gen2/Save.lua` | `save_gold.lua`, beside the Gen 1 saves | -| `src/ui/gen2/` | Intro menu, naming keyboard, START menu + every submenu, battle screen | -| `src/battle/gen2/` | Damage, stats/experience, HP bar, turn engine, catching, encounters | -| `src/render/GbcPalette.lua` | One shader: a 4-shade sheet through a GBC palette | - -## What extracts - -| Area | Status | -| --- | --- | -| Constants | species/map/tileset/move/type/sprite/environment/palette/fish orders | -| Font | `Font` (1bpp ink-on-transparent), `FontExtra` + `Frames` borders at $79–$7E, `FontBattleExtra` | -| Tilesets | lz3 GFX → PNG; raw Meta (128×16) + Coll (128×4 COLL_* quads); Anim/PalMap pointers | -| Roofs | 5 roof sheets + `MapGroupRoofs` (runtime: outdoor tilesets only) | -| Maps | all 368: blocks, connections, warps, coord/bg/object events (+ `scriptKey`) | -| Pokemon | `BaseData` (32B), names, front/back pics, plus Unown's 26 letter forms from `UnownPicPointers` | -| Items | Names, prices, pockets, held effects, field/battle use, descriptions, TM/HM numbers and the move each teaches | -| Marts | `Marts`' 34 shelves in MART_* order plus `BargainShopData`'s own item/price rows (`marts.lua`) | -| Overworld sprites | `OverworldSprites` → PNG sheets + `sprites.lua` | -| Scripts / text | disassembled cmds + decoded strings (`scripts.lua` / `text.lua`) | -| Initial events | `InitializeEventsScript` → `initial_events.lua` | -| Title | Tilemap + GBC tint; cloud band; Ho-Oh frames 1–5; trail; copyright splash | -| Oak speech | `_OakText1–7`, `PokemonProfPic` / `CalPic`, Marill front; `oak_speech.lua` | -| Pokemon / trainer pics | lz3 + column-major → row (`ImageWriter.columnsToRows`) | -| Audio | banks dumped to `programs.bin`; Gen 2 ChipSynth driver (`runtime = true`) | -| Palettes | `TilesetBGPalette`, `EnvironmentColorsPointers`, `MapObjectPals`, `RoofPals`, mon/trainer/HP-bar/exp-bar palettes, per-tileset `PalMap` | -| Moves | `Moves` + names + descriptions, effect names, real percentages | -| Type chart | `TypeMatchups` (plus the Foresight rows) and `TypeNames` with physical/special | -| Pokemon | Base stats, evolutions and level-up moves (`EvosAttacks`), TM/HM lists, growth rates, egg groups, gender ratio | -| Encounters | Grass (three time-of-day slot lists), water, fishing groups, headbutt tree sets | -| Trainers | Every class's parties with moves/items, class names, attributes, `TrainerEncounterMusic` | -| Pokedex | Entries (kind, height, weight, text) + the New and A-Z orderings | -| Landmarks | Town-map positions and names, plus `SpawnPoints` | -| Icons | Party-menu mon icons and the species → icon map | -| Menu / HUD gfx | Naming-screen chrome, battle HUD border and bar tiles, exp bar | -| Intro movie | The three acts' composed backgrounds, sprite sheets and fire frames | -| Battle anims | all 428 scripts, 188 objects, 185 framesets, 216 OAM sets, 40 object sheets, the six OBJ palettes | -| Field anims | stubs | - -Verified against a real Gold ROM: New Bark Town warps/objects/connections -match `pokegold/maps/NewBarkTown.asm`. Retail EVENT_* numeric ids differ -from pret’s current `const_def` order -- always take flags from the cart. - -## Play - -Launcher → Gold tab → Play opens `Game2` (`src/core/Game2.lua`): - -- Copyright → GameFreak Presents → GS intro stub → title (colored tilemap, - scrolling clouds, Ho-Oh flap, trails) → Oak speech (Marill cry + shrink) - → name pick → `src/world/gen2/World` -- Drivers (`POKEPORT_DRIVER`) skip cinema straight to the map -- New Bark Town with Johto tileset + roof overlay (outdoors only) -- Walk on `COLL_*` permissions; doors / stairs / carpets -- Elm's lab: scene walk-in, starter balls (`givepoke` + cutscene moves), - Elm phone number, aide Potion on exit (`verbosegiveitem`) -- Connected neighbor strips + seamless edge crossings (Route 29 / 27) -- Survey zoom (`-` / `=` / wheel / `4`) -- Chris + map/neighbor NPCs; `SPRITEMOVEDATA_*` walk / spin / stand -- New-game flags hide story NPCs (e.g. lab cop) -- A: talk to facing NPC or read `BGEVENT_READ` signs via Gen 2 VM + TextBox - -Escape is START, not quit: quitting is the START menu's QUIT row and the -intro menu's EXIT GAME. Title / map music and script SFX/cries use the Gen 2 -channel driver. - -## Colour - -Gen 2 is CGB-native, so colour is part of a tile's identity rather than a tint -over a 4-shade image. `engine/gfx/color.asm` LoadMapPals is ported whole in -`src/world/gen2/Palettes.lua`: - -1. the clock hour picks a daytime (`engine/rtc/rtc.asm` TimesOfDay), which a - map's own `PALETTE_*` can override (`ReplaceTimeOfDayPals`) -2. `EnvironmentColorsPointers[environment][daytime]` names eight entries in the - shared `TilesetBGPalette` pool -3. outdoors only, `RoofPals[mapGroup]` overwrites `PAL_BG_ROOF` colours 1-2 -- - one roof tile sheet, a different colour per town -4. each tile's slot comes from its tileset's `PalMap`, and each OW sprite's OBJ - palette from `MapObjectPals[daytime]` plus its own `PAL_OW_*` - -The map bake runs one shader pass per BG slot (eight per map) rather than one -per tile, and re-bakes when the clock rolls into a new daytime. -`POKEPORT_GOLD_HOUR=21` pins the hour for screenshots. - -## Still to do - -About fifty of AI_Smart's seventy per-effect handlers are unwritten, fishing -and headbutt have engine support but no input path, and Mart, the summary -screen, `.sav` interchange, Silver and mod-replaceable Gen 2 screens are all -still open. - -## Verify - -```sh -python3 tools/make_gold_manifest.py -for t in rom_lz3 gen2_world gen2_audio gen2_oak_speech gen2_vm \ - gen2_palettes gen2_battle gen2_menus gen2_save; do - luajit tests/${t}_test.lua -done -POKEPORT_IMPORT_TRACE=1 POKEPORT_IMPORT_ROM=/path/to/pokegold.gbc \ - POKEPORT_IMPORT_ONLY=1 POKEPORT_FORCE_IMPORT=1 POKEPORT_GAME=gold love . -POKEPORT_GAME=gold love . -# copyright → GF presents → GS intro → title → CONTINUE/NEW GAME/OPTION -# → Oak → name → the bedroom. START opens the menu; grass starts a battle. - -# Screenshot drivers (need a display): -POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_menu_shots.lua love . -POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_palette_shots.lua love . -POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_battle_smoke.lua love . -POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_transition_shots.lua love . -POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_teacher_scene.lua love . -POKEPORT_GAME=gold POKEPORT_BOOT_CINEMA=1 \ - POKEPORT_DRIVER=tests/drivers/gold_boot_smoke.lua love . -``` - -`POKEPORT_IMPORT_TRACE=1` prints each of the 26 stages as it starts, and a -headless import that fails now says so and exits non-zero rather than sitting -in an error state that looks exactly like a hang. - -A Gold cache without `palettes.lua`, `moves.lua`, `encounters.lua`, -`trainers.lua`, `pokedex.lua`, `landmarks.lua`, `icons.lua`, `menu_gfx.lua`, -`intro.lua`, `std_scripts.lua` or `battle/hud/*.png` needs a re-import -- as -does one whose back pics are still the front pic's size rather than 48x48 (see -below), and one whose trainer classes have no `encounterMusic` (the sixth -pass added `TrainerEncounterMusic` to the manifest, so the manifest has to be -regenerated as well). So does one whose `text.lua` has no `labels` table: that -is the by-name seed for the text no script pointer reaches (the Day-Care and -breeding block, the mart conversation, the Hall of Fame headers), and it needs -a regenerated manifest too, because the seed reads those symbols by name. - -## Traps this port has already fallen into - -Worth knowing before touching the extractor: - -- **Back pics are always 6x6 (48x48).** `BASE_PIC_SIZE`'s low nibble describes - the *front* pic only, so decoding a back at it reads the wrong tile count in - the wrong number of columns and produces garbage. -- **`#`, `` and `` have no font tile.** They are compression bytes - ($54/$24/$4a) that PlaceString expands; the manifest charmap has to expand - them or "#DEX" renders as "DEX". -- **FishGroups has no row for FISHGROUP_NONE**, so a group's row is its id minus - one. Walking the constant list from the top reads every row shifted. -- **Trainer groups have no end marker** -- the next group's label follows - immediately -- so a party scan needs the class's own member list to bound it. -- **Pokedex entries are spread over four banks** and the cart derives the bank - arithmetically; take each species' own symbol instead. -- **`EvosAttacksPointers` is `dw`, not `dba`**: the blobs share the table's bank. -- Item ids past NUM_ITEMS (the TMs and HMs) have no ItemNames row; their name is - their TM number. -- **`TrainerClassAttributes` rows are SEVEN bytes**, not eight: - `NUM_TRAINER_ATTRIBUTES` is `_RS` after three `rb` and two `rw`. An - eight-byte stride walks one byte further off with every class, so the AI - flags come out as noise for everything past the first trainer -- and it - produces plausible values rather than an error, which is how it survived two - passes. The row is {item1, item2, baseMoney, aiLo, aiHi, switchLo, - switchHi}: the base money is byte THREE. -- **`dba_pic` does not store the real bank.** For the three "Pics" sections - that sit above the 8-bit-friendly range it writes `$13`, `$14` or `$1f`, and - `FixPicBank` (engine/gfx/load_pics.asm) maps those back to `$1f`, `$20` and - `$2e`. Any table of pic pointers -- `PokemonPicPointers`, - `UnownPicPointers`, `TrainerPicPointers` -- needs that mapping, or the read - lands in a completely different bank. -- **A pic can run over the top of its bank.** `GetLZByte` bumps the bank and - drops back to `$4000` when the read pointer passes `$8000`, so a compressed - pic near the top of a bank keeps going into the next one; slicing only to - `$8000` stops short of nine of the Unown letters. -- **`BattleAnimObjects` rows are SIX bytes**, because `BATTLEANIMOBJ_LENGTH` is - `_RS - 1` -- the struct's runtime INDEX byte is not in the table. -- **Unown has no `PokemonPicPointers` row of its own**; its twenty-six forms - come out of `UnownPicPointers` instead, and letter A stands in for the - species. diff --git a/docs/ios-sideload.md b/docs/ios-sideload.md deleted file mode 100644 index 8c19cf01..00000000 --- a/docs/ios-sideload.md +++ /dev/null @@ -1,48 +0,0 @@ -# Sideload the iOS build with AltStore - -Every GitHub Release ships an IPA (`gen1recomp++-*-ios.ipa`). Install it on -your iPhone or iPad with [AltStore Classic](https://altstore.io/) — AltStore -re-signs the app with **your** free Apple ID so you do not need a Mac or -Xcode. - -## 1. Install AltStore - -Follow the official guide for your computer: - -- [How to Install (Windows)](https://faq.altstore.io/altstore-classic/how-to-install-altstore-windows) -- [How to Install (macOS)](https://faq.altstore.io/altstore-classic/how-to-install-altstore-macos) - -You will install **AltServer** on the computer, then use it to put AltStore -on the phone. What AltServer is and why it needs to stay running: - -- [AltServer](https://faq.altstore.io/altstore-classic/altserver) - -Stuck? Start here: - -- [Troubleshooting Guide](https://faq.altstore.io/altstore-classic/troubleshooting-guide) - -## 2. Install the game - -1. Download `gen1recomp++-*-ios.ipa` from - [Releases](https://github.com/bryanthaboi/gen1recomp/releases). -2. Open **AltStore** on the phone (AltServer must be running on the same - Wi‑Fi, or keep the phone plugged into the computer). -3. Tap **My Apps → +** (or share the IPA into AltStore) and pick the file. -4. Sign in with your Apple ID when prompted. Wait for the install to finish. -5. On first launch: Settings → **Privacy & Security → Developer Mode** (iOS - 16+), and Settings → **General → VPN & Device Management** → Trust your - Apple ID if asked. - -Then open the app, import your own legal `.gb` ROM on the Red/Blue tab, and -play. - -## Refresh / 7-day limit - -With a free Apple ID, sideloaded apps stop launching after **7 days**. Keep -AltServer running so AltStore can refresh them, or open AltStore and refresh -manually before they expire. Saves on the phone are kept across refreshes. - -## Prefer building it yourself? - -Building from source on a Mac (no AltStore) is covered in -[ios-install.md](ios-install.md). diff --git a/docs/known-differences.md b/docs/known-differences.md deleted file mode 100644 index f0f1b78e..00000000 --- a/docs/known-differences.md +++ /dev/null @@ -1,42 +0,0 @@ -# Known differences from the original game - -Only genuine remaining divergences live here: behavior that is still -**missing, wrong, or approximated for convenience** and would need more -work for true parity. Faithfully-ported behavior is documented in -docs/behavior-porting-notes.md; deliberate additions beyond the original -are in docs/new-features.md. - -## Reimplemented unused Prof. Oak and Rocket Chief battles - -The original ROM defines trainer data for `PROF_OAK` and `CHIEF` -(`data/trainers/parties.asm`) but never attaches either to an NPC, so -both battles are unreachable in the real game. This project makes them -fightable after the Hall of Fame: - -- Prof. Oak battles you in Pallet Town once `EVENT_BEAT_CHAMPION_RIVAL` - is set, using `ProfOakData`'s three starter-matched teams (the team is - picked by the type that counters your starter, mirroring the rival). -- The Celadon Game Corner Chief battles you in his house post-game. - `ChiefData` is empty in the ROM, so `OPP_CHIEF` is given a - reconstructed party. - -This is an intentional divergence: neither battle can be triggered in the -original game. - -## Reimplemented unused Silph Co. card-key doors - -`engine/events/card_key.asm` and the unused `CardKeyTable1/2/3` coordinate -lists (`data/events/card_key_coords.asm`) describe locked doors for Silph -Co. floors 2F-11F, but no retail `.blk` map layout ever places the closed -door block at those coordinates, so the card key check is dead code in -the original game. This project stamps the closed door block (`$54`/`$5f` -on floors 2F-10F, `$20` on 11F) over each of the 20 door coordinates on -map load, and swaps it for the open block once that door's -`EVENT_SILPH_CO_n_UNLOCKED_DOORn` flag is set (using the key from a Team -Rocket grunt, as in the original's unused design). - -This is an intentional divergence: the doors are not visible or -functional in the original game. The door layout lives in -`tools/rom_manifest.json` (`field.cardKeyDoors.closedDoors`), hand-ported -since no retail ROM data encodes it; `src/import/RomExtractor.lua` copies -it straight through on ROM import. diff --git a/docs/launcher.md b/docs/launcher.md deleted file mode 100644 index 24f79d0d..00000000 --- a/docs/launcher.md +++ /dev/null @@ -1,300 +0,0 @@ -# Launcher - -The launcher is `src/import/RomImporter.lua`, the first-run / title screen -that runs before `Game:load`. Besides ROM import (see the file's own header) -it hosts a tabbed shell covering per-game save slots and a mod manager. This -file documents the runtime model; the visual spec lives separately. - -## Android multi-ROM / mod / save import - -On Android, `love.system.pickFile([kind])` opens the Storage Access Framework -picker (`GameActivity.showFilePicker`); the chosen file is copied into the app -save directory as: - -| `kind` | Destination | -| --- | --- | -| nil / `"rom"` | `picked_rom.gb` (open) | -| `"mod"` | `picked_mod.zip` (open) | -| `"sav"` / `"save"` | `picked_save.sav` (open) | -| `"required_import"` | `picked_required_import.bin` (open) | - -Export uses a separate API: `love.system.createFile(suggestedName)` → -`GameActivity.showCreateDocument` (`ACTION_CREATE_DOCUMENT`), which copies -staged `pending_export.sav` to the user-chosen URI and writes `export_done.flag` -for the launcher to acknowledge on refocus. - -`RomImporter` then imports on refocus / Choose: - -- **ROMs** via `findPendingRom`: only a 1 MiB (Red/Blue/Yellow) or 2 MiB - (Gold) `.gb`/`.gbc` whose SHA-1 maps to a version that is **not** yet ready - counts as pending. A leftover `picked_rom.gb` from Red therefore cannot - block Blue's Choose (issue #167). -- **Mods** via `findPendingMod`: Prefer `picked_mod.zip`, or (on Choose) any - other `.zip` at the save-dir root (USB copy). -- **Saves** via `findPendingSav`: Prefer `picked_save.sav`, or (on Choose) any - other `.sav` at the save-dir root. - -After a successful import the consumed save-dir file is removed. - -**Manual check (device/emulator):** import Red → switch to Blue → Choose → -system file picker must appear (not a silent Red re-extract) → pick Blue → -Blue becomes ready beside Red. On the MODS tab, Import mod .zip must open the -same system picker and install the chosen archive on return. - -## Tab structure - -`self.tab` is one of `"red"`, `"blue"`, `"yellow"`, `"gold"`, `"mods"`, -`"find"`. The tab bar draws one chip per game plus MODS / FIND chips and -rebuilds `self.tabRects` every frame so `mousepressed` can dispatch clicks; -switching tabs mid-import is allowed (a dropped ROM still routes by SHA-1 -regardless of which tab shows). Yellow uses the bright gold rail colour; -Gold (Gen 2) uses a deeper amber so the two stay distinct. On **NX**, -**Scan again** is stricter: it only starts an import whose SHA-1 matches the -open game tab, so a shared `imports/` folder with Red+Yellow cannot jump -Yellow → Red. - -- A game tab (`_drawGamePanel`) shows the ROM card, the SAVE FILES card, the - Play button, and the SAVE SLOT card in a responsive two-column grid (see - Responsiveness). The MODS tab (`_drawModsPanel`) shows the mod list instead. -- The self-updater banner (`self.Check`, see `docs/updater.md`) draws as a - centered pill in a reserved band just above the footer, on every tab. That - position is unchanged by this redesign, so `docs/updater.md` needed no edits. - -## Save slot model - -All slot I/O lives in `src/core/SaveData.lua` and goes through the same fs -abstraction (`persistFs`) every other save/options call uses, so portable -mode (an `io.*` filesystem used when `portable.txt` marks the install) -keeps working unchanged. - -- **Files.** A version's playthroughs live under `saves//`, one file - per slot: `saves//slot1.lua` plus a rolling `.bak` and staged - `.tmp` witness (`slotNames`), mirroring the write/recovery discipline - `SaveData.save`/`load` already use for the flat legacy file. Slot ids match - `slot%d+`; `createSlot` allocates one past the highest existing number so a - reused id can never collide with a lingering file. -- **Registry.** The ordered slot list and which one is active persist in - `options.lua` (via the existing `SaveData.loadOptions`/`saveOptions`): - `options.saveSlots = { [version] = { list = {"slot1", ...}, active = "slot1" } }`. - Custom slot labels (#205) live alongside them in the same registry: - `options.saveSlots[version].names = { slot1 = "Nuzlocke" }`, written by - `SaveData.renameSlot` (trimmed; an empty label clears it) and surfaced on - each `listSlots` row as `label` (the launcher row shows `label`, falling - back to the player name). `deleteSlot` drops the label with the slot. - Renaming never touches the save file, so an empty slot can be labeled. - On desktop, right-clicking a slot row opens the inline rename modal - (Enter commits, Esc cancels); touch has no secondary button, so the - affordance is desktop-only. -- **Active slot resolution.** `saveNames(version)`, the function every - existing caller (`TitleState` hasSave/load/save, recovery order) already - goes through, now resolves the *active* slot instead of a fixed flat name. - Resolved once per version per process (`ensureVersionSlots`, cached in - `activeSlotCache`/`slotsChecked`): a registry entry wins; otherwise a lazy - legacy migration may create one; otherwise the flat legacy path is used - (`save.lua` / `save_blue.lua`), so a pre-slots install keeps working as before. -- **Legacy migration.** One-time per version, lazy on first - `listSlots`/`load`/`saveNames` call (`tryMigrateLegacy`): if a flat legacy - file exists and no `saves//` registry does, its main + `.bak` are - copied into `saves//slot1.lua(.bak)`, verified readable - (`decodeSlot`: main, then `.tmp`, then `.bak`), and only then are the - originals removed and `slot1` registered as active. A copy that fails to - verify leaves the originals in place; migration never loses data. - -The launcher-facing API: -- `SaveData.listSlots(version)` -> array of `{id, exists, name, meta}` for - every registered slot. `name` is the save's player name, or `nil` for an - empty slot; `meta` is `{badges, timeText, dexCount}` (the same fields the - title screen's `ContinueInfo` shows) or `nil`. The pure part, - `SaveData.slotSummary(save)`, is unit-testable with no filesystem. -- `SaveData.setActiveSlot(version, slotId)` registers the id if new, persists - it as active, and updates the process cache so the very next save/load - lands there. The launcher calls this the moment a slot row is clicked - (`RomImporter:_selectSlot`); pressing Play needs no signature change, since - `Game.lua`/`main.lua` still just call `SaveData.load()`/`save()`. -- `SaveData.createSlot(version)` -> new slot id, registered but with **no - save file written**. An empty slot means the title screen offers NEW GAME - only, which needs no further changes. -- `SaveData.deleteSlot(version, slotId)` removes the slot's - main/`.bak`/`.tmp` files, drops it from the registry, and if it was active - points active at another remaining slot (or clears active when the list is - empty). The launcher's SAVE SLOT panel Delete control calls this. - -## Launcher mod manager - -`src/mods/LauncherMods.lua` is a launcher-only read of the mod set. It runs -before `Game:load`, so **it never loads a mod's entry chunk**; only -`manifest.json` is read and validated (`src/mods/Manifest.validate`), the way -`Loader:_discover` finds mods without running them. The real loader -(`src/mods/Loader.lua`) still owns the actual load at boot. - -- `LauncherMods.list()` scans `mods/` one level deep (first id wins on a - duplicate) and returns one row per mod: - `{id, name, version, badge, description, enabled, status, statusDetail}`. - `badge` is the manifest's `category`, falling back to `profile`, then - `"MOD"`, uppercased. `enabledByVersion` contains an answer for each game; - missing entries default to enabled (except experimental mods), matching the - loader. On the first run with per-game controls, legacy shared choices are - copied to every installed game's answer. -- `status` is `"ok"`, `"warn"`, or `"conflict"`, computed by the pure - `LauncherMods.deriveList`/`statusFor` against `ManagerState.resolveToggle` - and the validated manifests: `conflict` when enabling this mod collides - with another enabled one; `warn` for an out-of-range `game_version` or an - absent/disabled/wrong-version hard dependency; `ok` otherwise. Having no - `love.*` calls, this half is table-driven by the test suite on its own. -- `LauncherMods.setEnabled(id, bool, version)` persists the selected game's - answer, so the running game and the in-game `ManagerState` see the change on - next boot. The MODS panel renders a coloured checkbox for Red, Blue, Yellow, - and Gold on every row and re-derives the list right away - (`RomImporter:_refreshMods`) so a status change (e.g. a new conflict) - shows without waiting for a reload. -- `LauncherMods.installZip(path)` mounts the archive with - `love.filesystem.mount`, locates the mod root via `locateRoot` (manifest at - the zip root, or inside one top-level folder), validates its manifest, and - copies the tree into the save-dir `mods//` before unmounting. Rejects a - duplicate of an already-installed mod id, and accepts either an external - path string or a LOVE `DroppedFile`, staging a dropped file into a save-dir - temp first (mount only reaches save-dir-relative paths), the same way - `RomImporter` handles a dropped ROM. A failed copy rolls its partial tree - back, and every path unmounts and clears the staged temp file. -- `LauncherMods.uninstall(id)` removes `mods//` and clears - `options.mods[id]` so a later reinstall starts from the loader's default - (enabled). The mods panel Delete control calls this and re-derives the list. -- A mod that declares `github` shows its total GitHub downloads (every - release's summed asset `download_count`, from the same cached release - fetch the update check uses) as a highlighted body line like "12,345 - downloads across all releases - Released 2024-05-31 - Updated 2026-07-01" - (first and latest `published_at`). Old cache entries written before the - counts existed show no line rather than a wrong zero; a manual check - refreshes them. -- The MODS panel sorts its rows by Name, Popularity (downloads), - Release date (first release), or Last updated, chosen by chips under the - header and persisted in `options.modSort`. Mods without release data - (no `github` field, or a stale cache) sink to the bottom of data sorts. - -## Import / Export save - -The SAVE FILES card wires a raw Gen1 `.sav` battery image to the save slots -through `src/import/SaveFileIO.lua`, which sits on top of -`src/save_convert/SaveConvert.lua` and the slot API in `SaveData`. - -- **Import save** is live once the game's ROM is imported (playable). - On desktop it opens a native `.sav` picker (`chooseSav`); on Android, - `love.system.pickFile("sav")` → `picked_save.sav`, same SAF path as ROMs. - On **NX (Switch)** there is no picker: copy a `.sav` into - `getSaveDirectory()/imports/saves//` via MTP / SD / FTP - (one folder per game), then press **Import save** on that game’s tab to - ensure the inbox and rescan (same pattern as the ROM `imports/` and mod - `imports/mods/` inboxes). Hidden `._*.sav` AppleDouble sidecars are skipped. - `SaveFileIO.importToSlot` reads the bytes (an absolute path, a save-dir - relative name, a dropped LOVE file, or raw bytes), - guards the 32768-byte size, runs `SaveConvert.importSav` (which also rejects - a bad main-data checksum), then registers a fresh slot (`SaveData.createSlot`), - writes it (`SaveData.writeSlot`), and makes it active (`SaveData.setActiveSlot`). - The meta stamp is re-stamped off `gen1_import` to the current numeric format - so `SaveData.load`'s migration pass accepts the slot. On success the SAVE SLOT - panel is refreshed with the new slot selected. On **NX**, a successful inbox - import retires the file to `*.sav.imported` and records a content hash in - `imports/saves//.imported-sha1` so a second **Import save** (or the same - bytes under a new name) does not clone slots; failures leave the original - `.sav`. Only that game’s folder is scanned. -- **Export save** is live only when the active slot actually holds a save - (checked against `listSlots`). `SaveFileIO.exportActiveSlot` loads the active - slot, encodes it back with `SaveConvert.exportSav` (a slot never keeps - `rawImport`, so this is a zero-filled template export, which is valid), and - writes `exports//gen1recomp--.sav` under the same - root `persistFs` writes slots to: the portable game folder when `portable.txt` - marks the install, otherwise the save directory (`exports/` and - `exports//` are created as needed; #752). On desktop it returns the - absolute path (`SaveData.portableBaseDir()` when portable, else - `love.filesystem.getSaveDirectory()`), which the notice line shows with an - "Open folder" affordance (`love.system.openURL("file://" .. dir)`). - On Android the bytes are also staged as `pending_export.sav` and - `love.system.createFile(suggestedName)` opens `ACTION_CREATE_DOCUMENT` so the - player can save to Downloads / Drive / etc.; on return `export_done.flag` - makes focus show "Save exported." - On **NX**, export success sets a notice with the `exports//` path and an - MTP-oriented hint — no `openURL` / Open folder (pull the file via MTP / - SD / FTP instead). -- **Drag-drop.** `filedropped` routes a `.sav` to the import path for the - currently active game tab; when a non-game tab (mods, or the locked yellow - placeholder) is showing it defaults to red, the always-present first game - (`_savedropTarget`). `.gb` (ROM) and `.zip` (mod) routing is unchanged. -- **Failure UX.** Every error path (wrong size, bad checksum, write failure, - nothing to export, ROM not imported yet) surfaces as a red notice line on the - card. Nothing raises and nothing silently no-ops. - -`SaveFileIO` is love-free enough to unit-test through the same in-memory -filesystem stub the slot backend uses (`tests/engine/save_file_io_tests.lua`). - -## Responsiveness - -Every measurement derives from `love.graphics.getDimensions()` each frame -plus the existing global scale `s = clamp(height / 768, 0.7, 1.6)`; nothing -assumes a fixed window size. The game panel's two-column grid (ROM/SAVE -FILES/Play on the left, SAVE SLOT on the right) collapses to one stacked -column, slot card below Play, when the window is too narrow for both -`~300 * s`-wide columns. The save-slot list and the mod list both scroll -(wheel, or drag on touch/desktop) clamped to their own content extent, -recomputed every draw. The tab bar labels only the active chip so it stays -narrow-safe, and content caps out at `~1440 * s` wide, centered. - -The desktop window has a floor of 480x360 (`conf.lua` `minwidth`/`minheight`), -under which the cards stop being readable at all. Mobile ignores it: those -windows are fullscreen. - -### Page scroll - -Two columns fit any window the launcher is likely to open in; one stacked -column does not. On a phone-shaped window the ROM card, SAVE FILES, Play and -SAVE SLOT together run past the bottom, and a footer pinned to the window -bottom painted over them with the overflow unreachable. - -So the whole column under the tab bar -- panel, updater banner, footer -- -scrolls as one page whenever it is taller than the room below the tab bar: - -- The strip, logo and tab bar stay pinned, so navigation is always on screen. - Everything else draws at `contentTop - pageScroll` inside a scissor, and the - footer is laid out downward from `footerTop` right after the content instead - of upward from the window bottom. -- `RomImporter.pageScrollFor(naturalH, viewportH, scroll)` is the whole - decision, pure and pinned by `tests/engine/launcher_page_scroll.lua`. A - window that grows back drags the offset down with it, so the page can never - stay parked past its own end. -- The panels report their natural height as they draw (`_drawGamePanel` and - `_drawModsPanel` return it), so the decision reads the previous frame's - measurement -- the same one-frame settle the two lists already rely on. -- **One scroll axis at a time.** While the page scrolls, the panels draw - `paged`: the slot and mod lists take their natural height, keep no inner - scroll region and report a max of 0, so the wheel, the right stick and a drag - all move the page and never fight a list for the same gesture. Two-column - layouts do not overflow, `paged` stays false, and every one of these behaves - exactly as it did before. -- Hit testing follows the clip: `inside` (clicks) and `_ptIn` (hover) reject a - rect that scrolled out of the viewport, so a control that slid under the tab - bar cannot be clicked through it. Tab chips carry `pinned = true` and are - exempt. `pageScroll` resets on a tab change, each tab being a different - length. -- A press on empty background pans the page, resolved in `_updateSlotDrag` like - every other drag here. - -### Dragging on Android - -The launcher is handed no move events on any platform: `main.lua` forwards -neither `touchmoved` nor `mousemoved` while it is up, which is why every drag -here is resolved by polling inside `draw` instead. Desktop polls the mouse; -Android used to poll nothing at all ("no reliable pointer polling" meant its -mouse emulation), so it had no scroll gesture whatsoever -- fine while every -scroll region was an inner list with a wheel alternative, useless the moment -the page itself became the thing that scrolls, since a phone is exactly where -it overflows. - -`love.touch` is pollable, so `_pointerHold` reads the first active touch there -and hands `_updateSlotDrag` the same (held, y) pair the mouse gives on desktop. -Consequences: - -- Slot rows and mod toggles ARM on press and commit on release on Android too, - matching desktop, so a swipe that starts on a card scrolls instead of - selecting the row it started on. -- `touchPollable` (set once in `new`) gates all of it. Where `love.touch` is - missing, every Android path is exactly what it was: act on press, never arm, - no drag. diff --git a/docs/new-features.md b/docs/new-features.md index b633e4cb..2fca8c70 100644 --- a/docs/new-features.md +++ b/docs/new-features.md @@ -19,15 +19,12 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow * **Soft reset button combination** * **Keyboard and controller rebinding** * **Mod profiles** with separate mod settings and save slots -* **Sandboxed mods**: an installed mod can read only its own folder and write only its own storage, so it cannot reach the rest of your device, and mods that need the internet or heavy background work do it through permissions the mod manager shows you, without freezing the game +* **Sandboxed mods**: an installed mod can read only its own folder and write only its own storage * **Improved launcher and save editor UI**, including background downloads and update checks * **Direct-launch options** for shortcuts, Steam entries, and handheld frontends -* **Custom boot branding** ## Pokémon Gold (Gen 2) -A fourth game the launcher can import and play, built from pret/pokegold the same way Red/Blue/Yellow are built from pokered (and pokeyellow). Port extras beyond the cartridge: - * **COLOR, zoom, tilt, GBC FX, and quick save/load** * **UI that stays fixed while the overworld zooms** * **Border-block surrounds** for maps smaller than the screen @@ -38,6 +35,4 @@ A fourth game the launcher can import and play, built from pret/pokegold the sam * **Mod manager** with Gen 1 mod adapters, per-game targeting, and `modkit gen2check` * **Followers** for mods, plus Gen 2-only registries and hooks * **On-screen touch pad** and controller SELECT for registered items - - * **Older mods keep loading** after the sandbox change, through per-mod compat stand-ins for the pre-sandbox globals diff --git a/docs/tiled-map-editing.md b/docs/tiled-map-editing.md deleted file mode 100644 index 6ec9f7dd..00000000 --- a/docs/tiled-map-editing.md +++ /dev/null @@ -1,65 +0,0 @@ -# Tiled map editing (mod authoring) - -`tools/tiled_export.py` turns the imported ROM cache into a -[Tiled](https://www.mapeditor.org) workspace, so maps can be edited in a -real map editor and exported back out as a mod. The original had no map -editor at all; the port's own map data is plain Lua, which is what makes -this a data path rather than an asset path. - -Editing is done in our own Tiled build, -[bryanthaboi/tiled_gen1recomp](https://github.com/bryanthaboi/tiled_gen1recomp/releases), -which ships the `gen1-mod-export` extension the workspace relies on. Grab it -from that repo's releases; upstream Tiled opens the workspace but cannot -export a mod out of it. - -```sh -python3 tools/tiled_export.py # -> build/tiled/ (gitignored) -``` - -Then open `build/tiled/gen1.tiled-project` in that build of Tiled. - -- **The overworld is one surface.** All 222 maps become `maps/*.tmj`, and - `kanto.world` places the 36 connected overworld maps at their real - connection offsets. That world is pre-loaded (seeded into the workspace's - Tiled session), so opening any one overworld map draws its neighbors around - it and you scroll and edit straight across the seams. Everything else is a - double-click away in Tiled's project panel. -- **Extending Kanto wires both ends.** A connection lives on both maps, so - hooking a new map onto a base map also emits the return connection as a - patch on that base map, keeping its other directions intact. The return - offset is derived, not guessed: all 78 vanilla reciprocal pairs satisfy - `back.offset == -offset`. -- **A Tiled tile is a gen1 block.** Each of the 24 tilesets becomes a Tiled - tileset whose tiles are its 32x32 blocks, composited from the 8x8 sheet, - so a tile layer *is* the map's `blocks` array. Warps, signs and objects - sit on the 16px cell grid in object layers, which is the grid the engine - addresses them on. -- **Collision is visible.** View > Show Tile Collision Shapes draws the real - walkability: a rectangle covers each cell whose feet tile is not in the - tileset's `walkable` list, which is the rule `src/world/Map.lua` applies. -- **Maps are shown in their real colors.** Each map is atlased in the SGB - palette it renders with, so Cerulean is blue and Lavender is purple in the - editor exactly as in game. Vanilla resolves that through a cascade with - interiors inheriting the last outdoor map, so the workspace mirrors the - cascade and walks the warp graph to colour interiors. Changing a map's - `palette` exports `palette = "..."` on the record, which beats the cascade, - and the editor offers the real palette names as a dropdown. -- **New blocks and new tilesets.** `blocksets/*.tmj` show a tileset's blocks - as raw 8x8 tiles, four by four, so new blocks can be composed there; - per-tile flags on `tilesets/tiles_*.tsj` become `walkable`, `waterTiles`, - `doorTiles` and the rest. -- **Export is a diff, not a fork of the data.** The `gen1-mod-export` - extension (shipped in `tiled_gen1recomp`) writes either one map file or a whole - loadable mod folder. An edited vanilla map diffs against the imported data - and emits `mod.content.maps:patch` carrying *only* the fields that moved, so - a mod covers the parts it changes and leaves the rest to the base game; a - new map gets `:register` at an index of 1000 or above. An unchanged map - exports nothing at all. Exports pass `tools/modkit.py validate` and `lint`. -- **Or the whole record, on request.** Ticking `exactExport` on a map switches - it to `mod.content.maps:override`, pinning the map to exactly what the - editor shows. It is off by default because an override wins outright over - any other mod patching that map, where a patch composes. - -No ROM-derived art travels into an exported mod: a tileset still drawing on -the player's own imported sheet references that path rather than shipping the -pixels, and only a sheet the author supplied is copied in. diff --git a/mods/example_mew_starter/README.md b/mods/examples/example_mew_starter/README.md similarity index 100% rename from mods/example_mew_starter/README.md rename to mods/examples/example_mew_starter/README.md diff --git a/mods/example_mew_starter/assets/mew_back_inverted.png b/mods/examples/example_mew_starter/assets/mew_back_inverted.png similarity index 100% rename from mods/example_mew_starter/assets/mew_back_inverted.png rename to mods/examples/example_mew_starter/assets/mew_back_inverted.png diff --git a/mods/example_mew_starter/assets/mew_front_inverted.png b/mods/examples/example_mew_starter/assets/mew_front_inverted.png similarity index 100% rename from mods/example_mew_starter/assets/mew_front_inverted.png rename to mods/examples/example_mew_starter/assets/mew_front_inverted.png diff --git a/mods/example_mew_starter/main.lua b/mods/examples/example_mew_starter/main.lua similarity index 100% rename from mods/example_mew_starter/main.lua rename to mods/examples/example_mew_starter/main.lua diff --git a/mods/example_mew_starter/manifest.json b/mods/examples/example_mew_starter/manifest.json similarity index 100% rename from mods/example_mew_starter/manifest.json rename to mods/examples/example_mew_starter/manifest.json diff --git a/mods/example_mew_starter/mod.card b/mods/examples/example_mew_starter/mod.card similarity index 100% rename from mods/example_mew_starter/mod.card rename to mods/examples/example_mew_starter/mod.card diff --git a/mods/examples/example_silly_oak/CHANGELOG.md b/mods/examples/example_silly_oak/CHANGELOG.md deleted file mode 100644 index ec81d39b..00000000 --- a/mods/examples/example_silly_oak/CHANGELOG.md +++ /dev/null @@ -1,12 +0,0 @@ -# Changelog - -Format: [keep a changelog](https://keepachangelog.com/en/1.1.0/). -Version headings match `manifest.json`'s `version`. - -## 1.0.0 - -### Added - -- `intro.oak_speech.build` wrap that injects toast / MEW / snack / rival-trust / pineapple beats. -- Answers written to `mod.save` via `intro.oak_speech.answered`. -- Custom `toast_kid.png` sprite shown mid-speech. diff --git a/mods/examples/example_silly_oak/README.md b/mods/examples/example_silly_oak/README.md deleted file mode 100644 index 7278827e..00000000 --- a/mods/examples/example_silly_oak/README.md +++ /dev/null @@ -1,43 +0,0 @@ -# Silly Oak Intro Example - -Hooks Oak's NEW GAME speech: extra questions, sprite swaps (Oak, rival, -player, MEW, and a custom Toast Kid pic), and answers stored in `mod.save`. - -## Try it (play through yourself) - -```sh -rm -rf mods/example_silly_oak -cp -r mods/examples/example_silly_oak mods/ -love . -``` - -Then **NEW GAME** and mash A / pick the menus. Disable or delete -`mods/example_silly_oak` when you're done so vanilla boots clean. - -## Headless check - -```sh -luajit mods/examples/example_silly_oak/tests/example_silly_oak_test.lua -``` - -## Auto driver (screenshots + save asserts) - -```sh -rm -rf mods/example_silly_oak -cp -r mods/examples/example_silly_oak mods/ -SHOT_DIR=/tmp/silly_oak POKEPORT_IDENTITY=silly_oak_driver_test \ - POKEPORT_DRIVER=tests/drivers/silly_oak_intro_test.lua POKEPORT_SPEED=8 love . -``` - -`POKEPORT_IDENTITY` keeps this run's save out of your normal slot. - -## What it demonstrates - -| Seam | Where | -|---|---| -| `hooks:wrap("intro.oak_speech.build")` | `main.lua` — reshape the step list | -| `mod.ui.insertStepAfter` / `insertStepBefore` | `main.lua` — anchored on vanilla step ids | -| step kinds `say` / `yesno` / `choice` | `main.lua` | -| pics: `"oak"`, `"rival"`, `"player"`, pokemon, custom image | `main.lua` | -| `events:on("intro.oak_speech.answered")` | `main.lua` → `mod.save` | -| `events:on("intro.oak_speech.finished")` | `main.lua` | diff --git a/mods/examples/example_silly_oak/assets/toast_kid.png b/mods/examples/example_silly_oak/assets/toast_kid.png deleted file mode 100644 index 4778bddd..00000000 Binary files a/mods/examples/example_silly_oak/assets/toast_kid.png and /dev/null differ diff --git a/mods/examples/example_silly_oak/main.lua b/mods/examples/example_silly_oak/main.lua deleted file mode 100644 index 8fe9ae02..00000000 --- a/mods/examples/example_silly_oak/main.lua +++ /dev/null @@ -1,107 +0,0 @@ --- Gallery entry: reshape Oak's intro speech with extra questions, sprite --- swaps (oak / rival / player / pokemon / a custom image), and answers --- that land in mod.save. --- --- Uses hooks:wrap("intro.oak_speech.build") plus intro.oak_speech.answered. - -return function(mod) - local toastPic = mod.path .. "/assets/toast_kid.png" - - mod.hooks:wrap("intro.oak_speech.build", function(next, steps, speech) - steps = next(steps, speech) - - -- after oak says hello, immediately derail - mod.ui.insertStepAfter(steps, "oak_welcome", { - id = "silly_quiz_intro", - kind = "say", - pic = "oak", - text = "Before we start,\nI have a few\vquestions.\fImportant ones.\nScientific ones.", - }) - - mod.ui.insertStepAfter(steps, "silly_quiz_intro", { - id = "silly_toast", - kind = "yesno", - pic = "oak", - saveKey = "likes_toast", - text = "Do you like\ntoast?", - }) - - -- brand new sprite mid-speech - mod.ui.insertStepAfter(steps, "silly_toast", { - id = "silly_toast_kid", - kind = "say", - pic = { type = "image", path = toastPic }, - reveal = "fade", - saveKey = nil, - text = "This is Toast Kid.\nHe is not a\vPOKéMON.\fHe just showed up\none day.\fAnyway.", - }) - - -- existing mon with a wipe + cry, parked after the real demo mon - mod.ui.insertStepAfter(steps, "demo_mon", { - id = "silly_mew", - kind = "say", - pic = { type = "pokemon", id = "MEW" }, - reveal = "wipe", - cry = "MEW", - text = "This is MEW.\nPlease do not\vtell anyone\vI showed you.", - }) - - mod.ui.insertStepAfter(steps, "silly_mew", { - id = "silly_snack", - kind = "choice", - pic = "oak", - saveKey = "snack", - text = "Pick a snack.\nThis goes on\vyour permanent\vrecord.", - choices = { "BERRIES", "LEFTOVERS", "OLD ROD" }, - }) - - -- swap to rival pic for a loaded question before naming him - mod.ui.insertStepBefore(steps, "ask_rival_name", { - id = "silly_trust", - kind = "choice", - pic = "rival", - reveal = "fade", - saveKey = "trusts_rival", - text = "Look at this kid.\nTrustworthy?", - choices = { "SURE", "NO" }, - values = { true, false }, - }) - - -- player pic for one last bit after both names are set - mod.ui.insertStepAfter(steps, "name_rival", { - id = "silly_pineapple", - kind = "yesno", - pic = "player", - saveKey = "pineapple_on_pizza", - text = "{PLAYER}. Be honest.\nPineapple on\vpizza?", - }) - - mod.ui.insertStepAfter(steps, "silly_pineapple", { - id = "silly_closing", - kind = "say", - pic = "oak", - text = "Great. Terrible.\nI have notes.\fLet's pretend this\nwas normal.", - }) - - return steps - end) - - -- every answered step with a saveKey lands in mod.save (and therefore - -- save.modData[mod.id] once the slot is written) - mod.events:on("intro.oak_speech.answered", function(ev) - if not ev.saveKey then return end - mod.save:set(ev.saveKey, ev.value) - mod.log:info("intro answer %s = %s", tostring(ev.saveKey), tostring(ev.value)) - end) - - mod.events:on("intro.oak_speech.finished", function(ev) - local answers = ev.answers or {} - for key, value in pairs(answers) do - if mod.save:get(key) == nil then - mod.save:set(key, value) - end - end - mod.save:set("quiz_done", true) - mod.log:info("silly oak quiz done") - end) -end diff --git a/mods/examples/example_silly_oak/manifest.json b/mods/examples/example_silly_oak/manifest.json deleted file mode 100644 index 928b5889..00000000 --- a/mods/examples/example_silly_oak/manifest.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "id": "example_silly_oak", - "name": "Silly Oak Intro Example", - "version": "1.0.0", - "api": 2, - "entry": "main.lua", - "profile": "content", - "category": "UI", - "game_version": ">=0.0.0-0 <2.0.0", - "priority": 100, - "dependencies": [], - "optional_dependencies": [], - "conflicts": [], - "description": "Reshapes Oak's intro speech with extra questions, sprite swaps, and answers saved to mod.save." -} diff --git a/mods/examples/example_silly_oak/mod.card b/mods/examples/example_silly_oak/mod.card deleted file mode 100644 index 93a0bef0..00000000 --- a/mods/examples/example_silly_oak/mod.card +++ /dev/null @@ -1,21 +0,0 @@ --- Sharing metadata for the manager detail pane. -return { - summary = "Oak asks dumb questions during the intro and remembers your answers.", - author = "Pokemon Gen 1 Recompilation Project", - contact = "https://github.com/bryanthaboi/gen1recomp", - tags = { "intro", "ui", "oak", "hooks" }, - differences = { - changed = { - "Oak's NEW GAME speech gains extra questions and sprite beats", - }, - added = { - "mod.save keys: likes_toast, snack, trusts_rival, pineapple_on_pizza, quiz_done", - "Custom Toast Kid pic mid-intro", - }, - known = { "vanilla naming and the shrink-away still run" }, - }, - credits = { - { who = "Pokemon Gen 1 Recompilation Project", for_ = "the intro.oak_speech hooks" }, - }, - compat = { engine = ">=0.0.0 <2.0.0", modApi = 2 }, -} diff --git a/mods/examples/example_silly_oak/tests/example_silly_oak_test.lua b/mods/examples/example_silly_oak/tests/example_silly_oak_test.lua deleted file mode 100644 index 1bceba2c..00000000 --- a/mods/examples/example_silly_oak/tests/example_silly_oak_test.lua +++ /dev/null @@ -1,159 +0,0 @@ --- Standalone: luajit mods/examples/example_silly_oak/tests/example_silly_oak_test.lua --- Covers the intro.oak_speech build hook, step helpers, sprite descriptors, --- and answers landing in mod.save. --- --- Needs an imported ROM dataset (data/generated/). Headless CI and a --- fresh checkout without a ROM skip cleanly -- the gallery is also --- covered by tests/mod_examples_tests.lua when generated data is present. -package.path = "./?.lua;./?/init.lua;" .. package.path - -local function hasGenerated() - local handle = io.open("data/generated/constants.lua", "r") - if handle then handle:close() return true end - return false -end -if not hasGenerated() then - print("example_silly_oak_test skipped (needs data/generated/)") - os.exit(0) -end - -local T = require("tests.modkit") -local Runtime = require("src.mods.Runtime") -local OakSpeech = require("src.ui.OakSpeech") -local Data = require("src.core.Data") -Data:load() - -local run = T.sdk.loadMod("mods/examples/example_silly_oak", { data = Data }) -T.eq(#run.errors, 0, "loads clean (" .. tostring(run.errors[1]) .. ")") - -local mod = run.mod -T.check(mod ~= nil and mod.state == "loaded", "mod reached the loaded state") -local ModUI = require("src.ui.ModUI") -local bucket = function() - return run.loader.modSave.example_silly_oak or {} -end -local toastPath = (mod.path or "mods/examples/example_silly_oak") - .. "/assets/toast_kid.png" - --- ------- build hook injects every silly beat around vanilla anchors - -local speech = OakSpeech.new({ - data = Data, - save = { player = { name = "RED", rival = "BLUE" } }, - stack = { push = function() end, pop = function() end }, -}, nil) -local steps = speech:buildSteps() - -local ids = {} -for _, step in ipairs(steps) do ids[#ids + 1] = step.id end -local function has(id) - for _, x in ipairs(ids) do if x == id then return true end end - return false -end - -T.check(has("oak_welcome") and has("name_player") and has("shrink"), - "vanilla anchors still present") -T.check(has("silly_quiz_intro") and has("silly_toast") and has("silly_toast_kid"), - "toast quiz beats injected") -T.check(has("silly_mew") and has("silly_snack"), - "MEW reveal and snack choice injected") -T.check(has("silly_trust") and has("silly_pineapple") and has("silly_closing"), - "rival trust + pineapple beats injected") - --- order: toast kid before demo_mon, mew after demo_mon, trust before rival ask -local function indexOf(id) - for i, x in ipairs(ids) do if x == id then return i end end - return 0 -end -T.check(indexOf("silly_toast_kid") < indexOf("demo_mon"), - "Toast Kid shows before the demo mon") -T.check(indexOf("demo_mon") < indexOf("silly_mew"), - "MEW shows after the demo mon") -T.check(indexOf("silly_trust") < indexOf("ask_rival_name"), - "trust question is before rival naming") -T.check(indexOf("name_rival") < indexOf("silly_pineapple") - and indexOf("silly_pineapple") < indexOf("legend"), - "pineapple lands between rival name and the legend beat") - --- ------- step shapes cover choice / yesno / custom image / pokemon - -local byId = {} -for _, step in ipairs(steps) do byId[step.id] = step end - -T.eq(byId.silly_toast.kind, "yesno", "toast is a yes/no") -T.eq(byId.silly_toast.saveKey, "likes_toast", "toast writes likes_toast") -T.eq(byId.silly_snack.kind, "choice", "snack is a multi choice") -T.eq(#byId.silly_snack.choices, 3, "snack has three options") -T.check(byId.silly_toast_kid.pic and byId.silly_toast_kid.pic.type == "image", - "Toast Kid uses a custom image pic") -T.check(byId.silly_mew.pic and byId.silly_mew.pic.type == "pokemon" - and byId.silly_mew.pic.id == "MEW" and byId.silly_mew.cry == "MEW", - "MEW beat uses pokemon pic + cry") -T.eq(byId.silly_trust.pic, "rival", "trust question shows the rival pic") - --- ------- resolvePic covers trainer / pokemon / player / image shorthand - -local oakImg = OakSpeech.resolvePic({ data = Data }, "oak", speech) -local rivalImg = OakSpeech.resolvePic({ data = Data }, "rival", speech) -local playerImg = OakSpeech.resolvePic({ data = Data }, "player", speech) -local mewImg, mewFlip = OakSpeech.resolvePic({ data = Data }, - { type = "pokemon", id = "MEW", flip = true }, speech) -local customImg = OakSpeech.resolvePic({ data = Data }, - { type = "image", path = toastPath }, speech) --- headless love stub may return nil images; the call itself must not throw -T.check(oakImg == speech.oakPic or oakImg == nil or type(oakImg) == "userdata" - or type(oakImg) == "table", - "oak shorthand resolves without error") -T.check(rivalImg == speech.rivalPic or rivalImg == nil or type(rivalImg) == "userdata" - or type(rivalImg) == "table", - "rival shorthand resolves without error") -T.check(playerImg == speech.playerPic or playerImg == nil - or type(playerImg) == "userdata" or type(playerImg) == "table", - "player shorthand resolves without error") -T.check(mewFlip == true, "pokemon flip flag is honored") -T.check(customImg ~= nil or true, "custom image path is accepted") - --- ------- answered event writes mod.save (loader.modSave bucket) - -Runtime.emit("intro.oak_speech.answered", { - saveKey = "likes_toast", value = true, label = "YES", index = 1, - step = byId.silly_toast, speech = speech, -}) -Runtime.emit("intro.oak_speech.answered", { - saveKey = "snack", value = "OLD ROD", label = "OLD ROD", index = 3, - step = byId.silly_snack, speech = speech, -}) -Runtime.emit("intro.oak_speech.answered", { - saveKey = "trusts_rival", value = false, label = "NO", index = 2, - step = byId.silly_trust, speech = speech, -}) -Runtime.emit("intro.oak_speech.answered", { - saveKey = "pineapple_on_pizza", value = true, label = "YES", index = 1, - step = byId.silly_pineapple, speech = speech, -}) -Runtime.emit("intro.oak_speech.finished", { - speech = speech, answers = speech.answers, -}) - -local saved = bucket() -T.eq(saved.likes_toast, true, "likes_toast saved") -T.eq(saved.snack, "OLD ROD", "snack saved") -T.eq(saved.trusts_rival, false, "trusts_rival saved") -T.eq(saved.pineapple_on_pizza, true, "pineapple_on_pizza saved") -T.eq(saved.quiz_done, true, "quiz_done stamped on finish") - --- ------- ModUI step helpers (public surface) - -local tiny = { - { id = "a", kind = "say" }, - { id = "b", kind = "say" }, -} -ModUI.insertStepAfter(tiny, "a", { id = "mid", kind = "choice" }) -T.eq(tiny[2].id, "mid", "insertStepAfter lands behind the anchor") -ModUI.insertStepBefore(tiny, "b", { id = "pre_b", kind = "yesno" }) -T.eq(tiny[3].id, "pre_b", "insertStepBefore lands ahead of the anchor") -ModUI.removeStep(tiny, "mid") -T.check(tiny[2].id ~= "mid", "removeStep drops by id") - -run.release() -T.finish("example_silly_oak")