diff --git a/CONTRIBUTING-mods.md b/CONTRIBUTING-mods.md index 3030138f..7a2f525d 100644 --- a/CONTRIBUTING-mods.md +++ b/CONTRIBUTING-mods.md @@ -140,8 +140,8 @@ ship text. ### 4. `games` (and the legacy `gen2compat`) -Pokemon Gold and Silver are Gen 2, and they run their own battle engine, -overworld, script VM and save format. The mod API is shared across both +Pokemon Gold, Silver and Crystal are Gen 2, and they run their own battle +engine, overworld, script VM and save format. The mod API is shared across both generations (same hook names, same event names, same registry names) but Gen 2 cannot serve all of it yet, so it is opt-in. Say which games the mod is for: @@ -150,19 +150,20 @@ cannot serve all of it yet, so it is opt-in. Say which games the mod is for: ``` Each entry is a version id (`"red"`, `"blue"`, `"yellow"`, `"gold"`, -`"silver"`), a generation (`"gen1"`, `"gen2"`) or `"all"`; +`"silver"`, `"crystal"`), a generation (`"gen1"`, `"gen2"`) or `"all"`; `src/mods/ModTargets.lua` resolves them off `GameVersion.ORDER` so nothing -restates the game list. `python3 tools/modkit.py scaffold my_mod --games -gen1,gen2` writes the key for you. The mod still installs to one directory, -`mods//`, shared by every game -- targeting is declared, never filed. +restates the game list, which is why `"gen2"` covers Crystal as well as Gold +and Silver. `python3 tools/modkit.py scaffold my_mod --games gen1,gen2` writes +the key for you. The mod still installs to one directory, `mods//`, shared +by every game -- targeting is declared, never filed. Absent means Gen 1 only, which is what every mod written before the key existed was tested as. `"gen2compat": true` is the legacy spelling, still accepted and purely additive (it *adds* the Gen 2 games), so no manifest can lose a game it -already ran on. On a Gold or Silver boot a mod claiming no Gen 2 game is not -loaded at all: the manager lists it as `ENABLED (NOT THIS GAME)` and says why, -because a mod that half-applies reads as a broken mod. Claim Gen 2 once you -have actually run your mod on Gold or Silver. +already ran on. On a Gen 2 boot a mod claiming no Gen 2 game is not loaded at +all: the manager lists it as `ENABLED (NOT THIS GAME)` and says why, because a +mod that half-applies reads as a broken mod. Claim Gen 2 once you have actually +run your mod on Gold, Silver or Crystal. Every token is enforced, per game: the loader gates on the same `ModTargets.supports` answer both mod surfaces draw, so `"games": ["blue"]` @@ -172,10 +173,11 @@ manifest with neither key still covers every Gen 1 game, so nothing written before the key existed changes behavior; list both generations or say `"all"` when you mean everywhere. -`docs/mod-api-gen2-compat.md` is the compatibility matrix: what works on Gold -and Silver today (40 of the 46 registries, 40 event and 44 hook names shared with Gen 1, -and 24 Gen 2-only ones), which registries have no Gen 2 home and drop their -writes with a report, and which hooks and events are still to come. +`docs/mod-api-gen2-compat.md` is the compatibility matrix: what works on Gold, +Silver and Crystal today (40 of the 46 registries, 40 event and 44 hook names +shared with Gen 1, and 24 Gen 2-only ones), which registries have no Gen 2 home +and drop their writes with a report, and which hooks and events are still to +come. `docs/preparing-your-mod-for-gen2.md` is the step-by-step migration guide for a Gen 1 mod, and it is the one to start from. diff --git a/README.md b/README.md index f213eb27..897a2874 100644 --- a/README.md +++ b/README.md @@ -56,14 +56,16 @@ And before you say, "that's not a recomp", you're wrong. Recomp is an acronym. * [![Watch the latest update video](https://img.youtube.com/vi/yi7LkWQPKKM/maxresdefault.jpg)](https://youtu.be/yi7LkWQPKKM) This project does not include a ROM, emulate the Game Boy, transpile assembly, -or download a disassembly. A canonical US Poke Red, Blue, Yellow, Gold, or -Silver ROM is the only game content input. +or download a disassembly. A canonical US Poke Red, Blue, Yellow, Gold, +Silver, or Crystal ROM is the only game content input. The ROM is verified, used during import, and then released from memory. It is not copied into the cache. Later launches load the private generated cache and -do not ask for the ROM again. Red, Blue, Yellow, Gold, and Silver can all be -imported side by side. Gold and Silver are Gen 2 Phase 1 (import + launcher; -see `docs/gold-phase1.md`): the Gen 2 engine is still under construction. +do not ask for the ROM again. Red, Blue, Yellow, Gold, Silver, and Crystal can +all be imported side by side. Gold, Silver, and Crystal are Gen 2 Phase 1 +(import + launcher; see `docs/gold-phase1.md`): the Gen 2 engine is still under +construction, and Crystal is the newest of the three, so the launcher lists it +as Crystal (Beta). ## Quick Start @@ -71,14 +73,16 @@ Open the desktop app. On first boot, choose your legally obtained `.gb` / `.gbc` file or drop it onto the window. Import takes a few seconds and the game starts automatically. -Only the canonical US Red, Blue, Yellow (1 MiB), Gold, and Silver (2 MiB) -ROMs are accepted. The importer verifies SHA-1 before creating any game data: +Only the canonical US Red, Blue, Yellow (1 MiB), Gold, Silver, and Crystal +(2 MiB) ROMs are accepted. The importer verifies SHA-1 before creating any +game data: - Red: `ea9bcae617fdf159b045185467ae58b2e4a48b9a` - Blue: `d7037c83e1ae5b39bde3c30787637ba1d4c48ce2` - Yellow: `cc7d03262ebfaf2f06772c1a480c7d9d5f4a38e1` - Gold: `d8b8a3600a465308c9953dfa04f0081c05bdcb94` - Silver: `49b163f7e57702bc939d642a18f591de55d92dae` +- Crystal: `f4cd194bdee0d04ca4eac29e09b8e4e9d818c133` The packaged app contains neither a ROM nor pre-extracted game data. Music, sound effects, and cries are synthesized while the game runs from compact @@ -122,24 +126,23 @@ supported out of the box. | `2` | Cycle COLORS | | `3` | Cycle TILT (free-roam overworld) | | `4` | Cycle ZOOM through every level (free-roam overworld) | -| `5` | Cycle GBC FX | | `F1` | Save | | `F2` | Load | | `F10` | Open / close the mod manager | -COLORS, TILT, ZOOM, GBC FX, GAME SPEED, and VOID FILL are also in the +COLORS, TILT, ZOOM, SHADER FX, GAME SPEED, and VOID FILL are also in the Options menu and persist in `options.lua`. ### Low-end devices **OPTIONS → PERFORMANCE** scales the port's optional extras for weaker -hardware: **HIGH** (everything on), **BALANCED** (no 3D tilt or GBC FX), +hardware: **HIGH** (everything on), **BALANCED** (no 3D tilt), **LOW** (also no survey zoom, FPS capped), or **AUTO** — the default, which picks a tier from your device (ARM handhelds → LOW, phones → BALANCED, normal desktops → HIGH, unchanged). It only scales presentation; the fixed-step game logic is identical on every tier, and a lower tier hides -your tilt/zoom/GBC-FX preferences without forgetting them. Details in +your tilt/zoom preferences without forgetting them. Details in [docs/new-features.md](docs/new-features.md#performance-tier-low-end-devices). ### Rulesets @@ -219,7 +222,7 @@ entry: a desktop shortcut per game, a Steam entry, or a handheld frontend. | Option | Effect | | --- | --- | -| `--game=red` | boot Red, skipping the launcher (`blue`, `yellow`, `gold` and `silver` too, or just `r` / `b` / `y` / `g` / `s`) | +| `--game=red` | boot Red, skipping the launcher (`blue`, `yellow`, `gold`, `silver` and `crystal` too, or just `r` / `b` / `y` / `g` / `s` / `c`) | | `--slot=2` | load that save slot; takes a slot number or a slot id | | `--launcher` | open the launcher anyway, so you can edit a shortcut you already made | diff --git a/build-linux-arm-sbc.sh b/build-linux-arm-sbc.sh index 0884f27d..43ee86a1 100755 --- a/build-linux-arm-sbc.sh +++ b/build-linux-arm-sbc.sh @@ -134,7 +134,7 @@ mkdir -p "$GAME_SRC" main.lua conf.lua src libs data assets tools/save-editor \ tools/rom_manifest.json tools/rom_manifest_blue.json \ tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \ - tools/rom_manifest_silver.json \ + tools/rom_manifest_silver.json tools/rom_manifest_crystal.json \ -x '*.DS_Store' 'data/generated/*' 'assets/generated/*') if unzip -Z1 "$WORK/game-payload.zip" \ | grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then diff --git a/build-rg34xxsp.sh b/build-rg34xxsp.sh index 3702d226..85c2bcfb 100755 --- a/build-rg34xxsp.sh +++ b/build-rg34xxsp.sh @@ -92,7 +92,7 @@ mkdir -p "$GAME_SRC" main.lua conf.lua src libs data assets tools/save-editor \ tools/rom_manifest.json tools/rom_manifest_blue.json \ tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \ - tools/rom_manifest_silver.json \ + tools/rom_manifest_silver.json tools/rom_manifest_crystal.json \ -x '*.DS_Store' 'data/generated/*' 'assets/generated/*') payload_list="$(unzip -Z1 "$WORK/game-payload.zip")" printf '%s\n' "$payload_list" \ @@ -102,6 +102,8 @@ printf '%s\n' "$payload_list" | grep -qxF "tools/rom_manifest_gold.json" \ || fail "payload is missing tools/rom_manifest_gold.json" printf '%s\n' "$payload_list" | grep -qxF "tools/rom_manifest_silver.json" \ || fail "payload is missing tools/rom_manifest_silver.json" +printf '%s\n' "$payload_list" | grep -qxF "tools/rom_manifest_crystal.json" \ + || fail "payload is missing tools/rom_manifest_crystal.json" unzip -q "$WORK/game-payload.zip" -d "$GAME_SRC" rm -f "$WORK/game-payload.zip" @@ -228,11 +230,6 @@ export LD_LIBRARY_PATH="$GAMEDIR/libs.aarch64:${LD_LIBRARY_PATH:-}" export SDL_GAMECONTROLLERCONFIG="${sdl_controllerconfig:-}" # Mali / H700: prefer GLES where available export LOVE_GRAPHICS_USE_OPENGLES="${LOVE_GRAPHICS_USE_OPENGLES:-1}" -# Same GPU class as a phone, but getOS() here says "Linux", so the Android -# gate (issue #136) would not fire on its own: refuse GBC FX explicitly. -# Hides the OPTIONS row, pins the level to OFF, and heals a level already -# persisted in options.lua. -export POKEPORT_GBCFX="${POKEPORT_GBCFX:-0}" $ESUDO chmod a+x ./bin/love.aarch64 2>/dev/null || chmod a+x ./bin/love.aarch64 $ESUDO chmod 666 /dev/uinput 2>/dev/null || true @@ -323,13 +320,6 @@ Native LÖVE 11.5 port of gen1recomp for Anbernic RG34XXSP on In-game controls use the normal PortMaster / SDL pad map (rebind under OPTIONS → CONTROLS). -### Display options - -GBC FX is disabled on this device (the H700's Mali GPU compiles that present -pass and then shows a black frame), so the OPTIONS row is hidden. COLORS, -TILT, ZOOM, VOID FILL and MAX FPS all work. To try it anyway, launch with -`POKEPORT_GBCFX=1`. - ### First run Stock OS has no zenity file picker. Put the `.gb` in `lovegame/`, then press diff --git a/conf.lua b/conf.lua index 1fe4d5da..974a9c86 100644 --- a/conf.lua +++ b/conf.lua @@ -66,6 +66,12 @@ function love.conf(t) t.modules.audio = not companion t.modules.joystick = not companion t.modules.physics = false + -- love.sensor exposes raw accelerometer/gyroscope data (love.sensor.getData), + -- independent of t.accelerometerjoystick below (which instead maps the + -- accelerometer onto joystick axes and stays off -- see that flag's + -- comment for why). Explicit here so a future effect (e.g. a tilt-driven + -- reflective-screen look) has sensor data available without reviving #468. + t.modules.sensor = true -- love.system is not loaded during love.conf; love._os is set by the -- engine before conf runs (LÖVE 11.x / 11.5). diff --git a/data/scripts/flavor/pokemon_fan_club.lua b/data/scripts/flavor/pokemon_fan_club.lua index 1efd93dd..f91866cc 100644 --- a/data/scripts/flavor/pokemon_fan_club.lua +++ b/data/scripts/flavor/pokemon_fan_club.lua @@ -36,19 +36,29 @@ M.POKEMON_FAN_CLUB = { { "clear_flag", "EVENT_SEEL_FAN_BOAST" }, -- 8 }, - -- PokemonFanClubPikachuText (scripts/PokemonFanClub.asm): the - -- PIKACHU itself, just a flavor line (its cry isn't playable in the - -- port's talk pipeline, so it's dropped like other cry-only lines). + -- PokemonFanClubPikachuText (scripts/PokemonFanClub.asm:71): PrintText, + -- then ld a, PIKACHU / call PlayCry (:76) / call WaitForSoundToFinish. TEXT_POKEMONFANCLUB_PIKACHU = { - { "show_text", "_PokemonFanClubPikachuText" }, + { "play_cry", "PIKACHU", true }, -- 1 PlayCry (#1649) + { "show_text", "_PokemonFanClubPikachuText" }, -- 2 PrintText }, - -- PokemonFanClubSeelText (scripts/PokemonFanClub.asm): the SEEL - -- itself, flavor line only. + -- PokemonFanClubSeelText (scripts/PokemonFanClub.asm:84): the same + -- shape with SEEL, PlayCry at :89. TEXT_POKEMONFANCLUB_SEEL = { - { "show_text", "_PokemonFanClubSeelText" }, + { "play_cry", "SEEL", true }, -- 1 PlayCry (#1649) + { "show_text", "_PokemonFanClubSeelText" }, -- 2 PrintText }, }, } +-- pokeyellow/scripts/PokemonFanClub.asm:149 PokemonFanClubClefairyText: Yellow's +-- pet is a CLEFAIRY on its own TEXT_POKEMONFANCLUB_CLEFAIRY, PlayCry at :154. +if require("src.core.GameVersion").isYellow() then + M.POKEMON_FAN_CLUB.talk.TEXT_POKEMONFANCLUB_CLEFAIRY = { + { "play_cry", "CLEFAIRY", true }, -- 1 PlayCry + { "show_text", "_PokemonFanClubClefairyText" }, -- 2 PrintText + } +end + return M diff --git a/data/scripts/flavor/ss_anne_1f_rooms.lua b/data/scripts/flavor/ss_anne_1f_rooms.lua index bde124b5..5d2fab56 100644 --- a/data/scripts/flavor/ss_anne_1f_rooms.lua +++ b/data/scripts/flavor/ss_anne_1f_rooms.lua @@ -1,10 +1,11 @@ --- pokered/scripts/SSAnne1FRooms.asm: SSAnne1FRoomsWigglytuffText --- text_far _SSAnne1FRoomsWigglytuffText; then ld a, WIGGLYTUFF / call PlayCry (cosmetic cry sound, not ported) +-- pokered/scripts/SSAnne1FRooms.asm:66 SSAnne1FRoomsWigglytuffText +-- text_far _SSAnne1FRoomsWigglytuffText, then ld a, WIGGLYTUFF / call PlayCry (:70) return { SS_ANNE_1F_ROOMS = { talk = { TEXT_SSANNE1FROOMS_WIGGLYTUFF = { {"face_player"}, + {"play_cry", "WIGGLYTUFF", true}, -- 1 PlayCry (#1687) {"show_text", "_SSAnne1FRoomsWigglytuffText"}, }, }, diff --git a/data/scripts/flavor/ss_anne_b1f_rooms.lua b/data/scripts/flavor/ss_anne_b1f_rooms.lua index 76f5bc3c..1c1038c3 100644 --- a/data/scripts/flavor/ss_anne_b1f_rooms.lua +++ b/data/scripts/flavor/ss_anne_b1f_rooms.lua @@ -1,12 +1,11 @@ --- pokered/scripts/SSAnneB1FRooms.asm: SSAnneB1FRoomsMachokeText --- text_far _SSAnneB1FRoomsMachokeText, then `ld a, MACHOKE / call PlayCry` --- (cry playback has no equivalent Commands.lua verb in this port, so only --- the flavor text is ported). +-- pokered/scripts/SSAnneB1FRooms.asm:82 SSAnneB1FRoomsMachokeText +-- text_far _SSAnneB1FRoomsMachokeText, then ld a, MACHOKE / call PlayCry (:86) return { SS_ANNE_B1F_ROOMS = { talk = { TEXT_SSANNEB1FROOMS_MACHOKE = { { "face_player" }, + { "play_cry", "MACHOKE", true }, -- 1 PlayCry (#1687) { "show_text", "_SSAnneB1FRoomsMachokeText" }, }, }, diff --git a/data/scripts/flavor/vermilion_city.lua b/data/scripts/flavor/vermilion_city.lua index c31d36fd..7c1bc24d 100644 --- a/data/scripts/flavor/vermilion_city.lua +++ b/data/scripts/flavor/vermilion_city.lua @@ -18,10 +18,11 @@ return { -- VermilionCityMachopText: cries out, then follows up with a -- second line about stomping the land flat. - -- (pokered/scripts/VermilionCity.asm) + -- (pokered/scripts/VermilionCity.asm:224, PlayCry at :228) TEXT_VERMILIONCITY_MACHOP = { - { "show_text", "_VermilionCityMachopText" }, -- 1 - { "show_text", "_VermilionCityMachopStompingTheLandFlatText" }, -- 2 + { "play_cry", "MACHOP", true }, -- 1 PlayCry (#1649) + { "show_text", "_VermilionCityMachopText" }, -- 2 + { "show_text", "_VermilionCityMachopStompingTheLandFlatText" }, -- 3 }, }, }, diff --git a/data/scripts/flavor/vermilion_pidgey_house.lua b/data/scripts/flavor/vermilion_pidgey_house.lua index 043c2c6b..29023bb5 100644 --- a/data/scripts/flavor/vermilion_pidgey_house.lua +++ b/data/scripts/flavor/vermilion_pidgey_house.lua @@ -1,14 +1,12 @@ --- pokered/scripts/VermilionPidgeyHouse.asm: VermilionPidgeyHousePidgeyText --- text_far _VermilionPidgeyHousePidgeyText, then text_asm plays the PIDGEY --- cry (ld a, PIDGEY / call PlayCry / call WaitForSoundToFinish) before --- TextScriptEnd. This port has no cry-playback command, so only the --- flavor line is ported. +-- pokered/scripts/VermilionPidgeyHouse.asm:15 VermilionPidgeyHousePidgeyText +-- text_far, then ld a, PIDGEY / call PlayCry (:19) / call WaitForSoundToFinish return { VERMILION_PIDGEY_HOUSE = { talk = { TEXT_VERMILIONPIDGEYHOUSE_PIDGEY = { {"face_player"}, + {"play_cry", "PIDGEY", true}, -- 1 PlayCry (#1649) {"show_text", "_VermilionPidgeyHousePidgeyText"}, }, }, diff --git a/data/scripts/story.lua b/data/scripts/story.lua index 4dc2507e..372a1836 100644 --- a/data/scripts/story.lua +++ b/data/scripts/story.lua @@ -381,22 +381,35 @@ M.VERMILION_CITY = { return true end, talk = { - -- the sailor guarding the dock gangway (VermilionCitySailor1Text): - -- flashing the ticket just lets you through -- he never hides, and - -- once the ship has sailed he only reports it gone - TEXT_VERMILIONCITY_SAILOR1 = { - { "face_player" }, -- 1 - { "check_flag", "EVENT_SS_ANNE_LEFT" }, -- 2 - { "jump_if_true", 11 }, -- 3 - { "show_text", "_VermilionCitySailor1DoYouHaveATicketText" }, -- 4 - { "check_item", "S_S_TICKET" }, -- 5 - { "jump_if_false", 9 }, -- 6 - { "show_text", "_VermilionCitySailor1FlashedTicketText" }, -- 7 - { "jump", 12 }, -- 8 - { "show_text", "_VermilionCitySailor1YouNeedATicketText" }, -- 9 - { "jump", 12 }, -- 10 - { "show_text", "_VermilionCitySailor1ShipSetSailText" }, -- 11 - }, + -- scripts/VermilionCity.asm:158 (#1651) + TEXT_VERMILIONCITY_SAILOR1 = function(game, ow, npc, done) + local Flags = require("src.script.Flags") + local TextBox = require("src.render.TextBox") + local t = game.data.text + if Flags.get(game.save, "EVENT_SS_ANNE_LEFT") then + game.stack:push(TextBox.new(game, + t._VermilionCitySailor1ShipSetSailText or "The ship set sail.", + done)) + return + end + -- scripts/VermilionCity.asm:195 + local p = ow and ow.player + if not p or p.facing == "right" + or (p.cellX == 19 and (p.cellY == 29 or p.cellY == 31)) then + game.stack:push(TextBox.new(game, + t._VermilionCitySailor1WelcomeToSSAnneText + or "Welcome to S.S.\nANNE!", done)) + return + end + local ask = t._VermilionCitySailor1DoYouHaveATicketText + or "Welcome to S.S.\nANNE!\fExcuse me, do you\nhave a ticket?" + local tail = ((game.save.inventory.S_S_TICKET or 0) > 0) + and (t._VermilionCitySailor1FlashedTicketText + or "{PLAYER} flashed\nthe S.S.TICKET!") + or (t._VermilionCitySailor1YouNeedATicketText + or "You need a ticket\nto get aboard.") + game.stack:push(TextBox.new(game, ask .. "\f" .. tail, done)) + end, }, } @@ -405,13 +418,14 @@ M.SS_ANNE_2F = { TEXT_SSANNE2F_RIVAL = { { "face_player" }, -- 1 { "check_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 2 - { "jump_if_true", 9 }, -- 3 + { "jump_if_true", 9 }, -- 3 (beaten: silent) { "show_text", "_SSAnne2FRivalText" }, -- 4 - { "rival_battle", "OPP_RIVAL2", 1 }, -- 5 - { "jump_if_false", 10 }, -- 6 - { "set_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 7 - { "show_text", "_SSAnne2FRivalDefeatedText" }, -- 8 - { "jump", 10 }, -- 9 (already beaten: silent) + -- SSAnne2FRivalText's text_asm arms SaveEndBattleTextPointers + -- (scripts/SSAnne2F.asm:199), so the line prints in battle (#1688) + { "save_end_battle_text", "_SSAnne2FRivalDefeatedText" }, -- 5 + { "rival_battle", "OPP_RIVAL2", 1 }, -- 6 + { "jump_if_false", 9 }, -- 7 + { "set_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 8 }, }, } diff --git a/data/scripts/story2.lua b/data/scripts/story2.lua index 09fea14d..a28ae8a9 100644 --- a/data/scripts/story2.lua +++ b/data/scripts/story2.lua @@ -731,12 +731,35 @@ M.MT_MOON_B2F = { local function museumClerk(game, ow, done, onDecline) local TextBox = require("src.render.TextBox") local t = game.data.text or {} + local p = ow and ow.player + -- scripts/Museum1F.asm:45 (#1690) + if p and ((p.cellY == 4 and p.cellX == 13) + or (p.cellY == 3 and p.cellX == 12)) then + game.stack:push(TextBox.new(game, + t._Museum1FScientist1DoYouKnowWhatAmberIsText + or "You can't sneak\nin the back way!\fOh, whatever!\nDo you know what\vAMBER is?", + nil, { choice = function(yes) + game.stack:push(TextBox.new(game, yes + and (t._Museum1FScientist1TheresALabSomewhereText + or "There's a lab\nsomewhere trying\vto resurrect\vancient POKéMON\vfrom AMBER.") + or (t._Museum1FScientist1AmberIsFossilizedTreeSapText + or "AMBER is fossil-\nized tree sap."), done)) + end })) + return + end if game.save.flags.EVENT_BOUGHT_MUSEUM_TICKET then game.stack:push(TextBox.new(game, t._Museum1FScientist1TakePlentyOfTimeText or "Take your time,\nand enjoy it all!", done)) return end + -- scripts/Museum1F.asm:58 + if p and p.cellY ~= 4 then + game.stack:push(TextBox.new(game, + t._Museum1FScientist1GoToOtherSideText + or "Please go to the\nother side!", done)) + return + end -- scripts/Museum1F.asm:72 local money = function() return game.save.money end game.stack:push(TextBox.new(game, diff --git a/data/scripts/story3.lua b/data/scripts/story3.lua index 826a7f96..9c6ae321 100644 --- a/data/scripts/story3.lua +++ b/data/scripts/story3.lua @@ -11,20 +11,30 @@ local M = {} -- FuchsiaGoodRodHouse.asm, Route12SuperRodHouse.asm) -- ------------------------------------------------------------------- -local function rodGiver(askText, receivedText, afterText, rodItem, flag) - return { - { "face_player" }, -- 1 - { "check_flag", flag }, -- 2 - { "jump_if_true", 9 }, -- 3 - { "ask", askText }, -- 4 - { "jump_if_false", 10 }, -- 5 +-- refusedText is the .ThatsSoDisappointingText tail on NO; followText is +-- the second half of the received chain (scripts/VermilionOldRodHouse.asm:45) +local function rodGiver(askText, receivedText, afterText, rodItem, flag, + refusedText, followText) + local rows = { + { "face_player" }, + { "check_flag", flag }, + { "jump_if_true", "already_got" }, + { "ask", askText }, + { "jump_if_false", "refused" }, -- give-then-print like the three rod-house scripts (GiveItem fills -- wStringBuffer; the received texts read OLD/GOOD/SUPER ROD from it) - { "give_item", rodItem, 1, false }, -- 6 - { "show_text", receivedText }, -- 7 - { "set_flag", flag }, -- 8 - { "jump", 10 }, -- 9 is below + { "give_item", rodItem, 1, false }, + { "set_flag", flag }, + { "show_text", receivedText }, } + if followText then rows[#rows + 1] = { "show_text", followText } end + rows[#rows + 1] = { "jump", "end" } + rows[#rows + 1] = { "label", "refused" } + rows[#rows + 1] = { "show_text", refusedText } + rows[#rows + 1] = { "jump", "end" } + rows[#rows + 1] = { "label", "already_got" } + rows[#rows + 1] = { "show_text", afterText } + return rows end M.VERMILION_OLD_ROD_HOUSE = { @@ -33,11 +43,11 @@ M.VERMILION_OLD_ROD_HOUSE = { "_VermilionOldRodHouseFishingGuruDoYouLikeToFishText", "_VermilionOldRodHouseFishingGuruTakeThisText", "_VermilionOldRodHouseFishingGuruHowAreTheFishBitingText", - "OLD_ROD", "EVENT_GOT_OLD_ROD"), + "OLD_ROD", "EVENT_GOT_OLD_ROD", + "_VermilionOldRodHouseFishingGuruThatsSoDisappointingText", + "_VermilionOldRodHouseFishingGuruFishingIsAWayOfLifeText"), }, } -M.VERMILION_OLD_ROD_HOUSE.talk.TEXT_VERMILIONOLDRODHOUSE_FISHING_GURU[9] = - { "show_text", "_VermilionOldRodHouseFishingGuruHowAreTheFishBitingText" } M.FUCHSIA_GOOD_ROD_HOUSE = { talk = { @@ -45,11 +55,10 @@ M.FUCHSIA_GOOD_ROD_HOUSE = { "_FuchsiaGoodRodHouseFishingGuruText", "_FuchsiaGoodRodHouseFishingGuruReceivedGoodRodText", "_FuchsiaGoodRodHouseFishingGuruHowAreTheFishText", - "GOOD_ROD", "EVENT_GOT_GOOD_ROD"), + "GOOD_ROD", "EVENT_GOT_GOOD_ROD", + "_FuchsiaGoodRodHouseFishingGuruThatsSoDisappointingText"), }, } -M.FUCHSIA_GOOD_ROD_HOUSE.talk.TEXT_FUCHSIAGOODRODHOUSE_FISHING_GURU[9] = - { "show_text", "_FuchsiaGoodRodHouseFishingGuruHowAreTheFishText" } M.ROUTE_12_SUPER_ROD_HOUSE = { talk = { @@ -57,11 +66,11 @@ M.ROUTE_12_SUPER_ROD_HOUSE = { "_Route12SuperRodHouseFishingGuruDoYouLikeToFishText", "_Route12SuperRodHouseFishingGuruReceivedSuperRodText", "_Route12SuperRodHouseFishingGuruTryFishingText", - "SUPER_ROD", "EVENT_GOT_SUPER_ROD"), + "SUPER_ROD", "EVENT_GOT_SUPER_ROD", + "_Route12SuperRodHouseFishingGuruThatsDisappointingText", + "_Route12SuperRodHouseFishingGuruFishingWayOfLifeText"), }, } -M.ROUTE_12_SUPER_ROD_HOUSE.talk.TEXT_ROUTE12SUPERRODHOUSE_FISHING_GURU[9] = - { "show_text", "_Route12SuperRodHouseFishingGuruTryFishingText" } -- ------------------------------------------------------------------- -- Pokemon Tower 5F purified zone (scripts/PokemonTower5F.asm @@ -895,16 +904,6 @@ local DOCK_SHIP_BLOCKS = { { bx = 7, by = 2, water = 13 }, { bx = 8, by = 2, water = 13 }, } --- her four hull columns bow-to-stern (upper-half / lower-half block ids) --- and the open-water ids of the rows she sits in -local DOCK_SHIP_COLUMNS = { - { bx = 5, top = 4, bottom = 8 }, - { bx = 6, top = 5, bottom = 9 }, - { bx = 7, top = 6, bottom = 10 }, - { bx = 8, top = 7, bottom = 11 }, -} -local DOCK_WATER_TOP, DOCK_WATER_BOTTOM = 1, 13 - M.VERMILION_DOCK = { onEnter = function(game, ow) local Flags = require("src.script.Flags") @@ -941,52 +940,29 @@ M.VERMILION_DOCK = { end elseif f.EVENT_GOT_HM01 and ow.player.cellY == 2 then -- VermilionDockSSAnneLeavesScript: only stepping OFF the ship - -- triggers the departure (wDestinationWarpID == 1 in pokered) -- - -- Music_Surfing plays for the sail-away cutscene, smoke puffs - -- drift off the funnel, the horn blows, the ship is erased to - -- open water, and the player is walked off the dock into the - -- city past the guard (VermilionCity's - -- SCRIPT_VERMILIONCITY_PLAYER_EXIT_SHIP walk) + -- triggers the departure (wDestinationWarpID == 1 in pokered) Flags.set(game.save, "EVENT_SS_ANNE_LEFT") local Music = require("src.core.Music") Music.stop() Music.play(game.data, "Music_Surfing") - local function puff(n, cx) - if n <= 0 then return end - ow:startDustAnim(cx, 1, function() puff(n - 1, cx + 2) end) - end - puff(3, 15) - -- scripts/VermilionDock.asm:182-203 - local rows = {} - local function setBlock(bx, by, block) - if bx < 1 or bx > 8 then return end - rows[#rows + 1] = { "replace_block", bx, by, block } - end - rows[#rows + 1] = { "wait", 120 } - rows[#rows + 1] = { "play_sound", "SS_Anne_Horn" } - -- .shift_columns_up slides her tile columns west behind a mid-frame - -- rSCX split; with no split scroll here she sails one block per beat - -- and the water closes in astern (#360) - for step = 1, 8 do - for _, col in ipairs(DOCK_SHIP_COLUMNS) do - setBlock(col.bx - step, 1, col.top) - setBlock(col.bx - step, 2, col.bottom) - end - setBlock(9 - step, 1, DOCK_WATER_TOP) - setBlock(9 - step, 2, DOCK_WATER_BOTTOM) - rows[#rows + 1] = { "wait", 20 } - end - -- the second horn as she clears the dock, then EraseSSAnne's 120 - -- frames before the walk out - rows[#rows + 1] = { "play_sound", "SS_Anne_Horn" } - rows[#rows + 1] = { "wait", 120 } - rows[#rows + 1] = { "move_player", "up", 2 } - -- no keepMusic on this warp: Music_Surfing belongs to the dock's - -- cutscene, and VERMILION_CITY's own theme has to take over as the - -- player crosses in (EnterMap's PlayDefaultMusic) - rows[#rows + 1] = { "warp", "VERMILION_CITY", 18, 31, "up" } - rows[#rows + 1] = { "move_player", "up", 2 } - ow:queueScript(rows) + ow:queueScript({ + -- scripts/VermilionDock.asm:50 zeroes the player image index and + -- :77 freezes sprite updates, so he faces DOWN throughout (#1689) + { "face_player_dir", "down" }, + { "wait", 120 }, + { "play_sound", "SS_Anne_Horn" }, + -- scripts/VermilionDock.asm:80 .shift_columns_up + { "ss_anne_departs" }, + -- scripts/VermilionDock.asm:205 VermilionDock_EraseSSAnne + { "play_sound", "SS_Anne_Horn" }, + { "wait", 120 }, + { "move_player", "up", 2 }, + -- no keepMusic on this warp: Music_Surfing belongs to the dock's + -- cutscene, and VERMILION_CITY's own theme has to take over as the + -- player crosses in (EnterMap's PlayDefaultMusic) + { "warp", "VERMILION_CITY", 18, 31, "up" }, + { "move_player", "up", 2 }, + }) end end, } diff --git a/data/scripts/story5.lua b/data/scripts/story5.lua index 98016848..87a4a4ff 100644 --- a/data/scripts/story5.lua +++ b/data/scripts/story5.lua @@ -922,10 +922,12 @@ M.SS_ANNE_2F = { { "move_npc_to", 2, 36, onLeft and 7 or 8 }, -- 2 { "face_object", 2, onLeft and "down" or "right" }, -- 3 { "show_text", "_SSAnne2FRivalText" }, -- 4 - { "rival_battle", "OPP_RIVAL2", 1 }, -- 5 - { "jump_if_false", 13 }, -- 6 - { "set_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 7 - { "show_text", "_SSAnne2FRivalDefeatedText" }, -- 8 + -- SSAnne2FRivalText's text_asm arms SaveEndBattleTextPointers + -- (scripts/SSAnne2F.asm:199), so the line prints in battle (#1688) + { "save_end_battle_text", "_SSAnne2FRivalDefeatedText" }, -- 5 + { "rival_battle", "OPP_RIVAL2", 1 }, -- 6 + { "jump_if_false", 13 }, -- 7 + { "set_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 8 { "show_text", "_SSAnne2FRivalCutMasterText" }, -- 9 { "play_music", "Music_MeetRival", { start = "rival" } }, -- 10 { "walk_npc", 2, ssAnne2FRivalExitDirs(onLeft) }, -- 11 diff --git a/docs/anbernic-rg34xxsp.md b/docs/anbernic-rg34xxsp.md index 441090ab..916aa25d 100644 --- a/docs/anbernic-rg34xxsp.md +++ b/docs/anbernic-rg34xxsp.md @@ -55,16 +55,6 @@ In-game controls use the normal PortMaster / SDL pad map, rebindable under ## Notes -**GBC FX is off on this device.** The launcher exports `POKEPORT_GBCFX=0`, -which hides the GBC FX row from OPTIONS, pins the level to OFF, and clears a -level carried over in an `options.lua` from another machine. The H700's Mali -GPU is in the same class as the phone GPUs that compile that present pass and -then show a black frame (issue #136), and `love.system.getOS()` reports -`"Linux"` here, so the Android gate would not have caught it. Every other -display option — COLORS, TILT, ZOOM, VOID FILL, MAX FPS — works normally. If -your device turns out to handle the pass, launch with `POKEPORT_GBCFX=1` to -put the row back. - **PERFORMANCE defaults to LOW here.** The OPTIONS → PERFORMANCE tier defaults to AUTO, which reads this device as an ARM Linux handheld and resolves to **LOW**: the 3D tilt and survey zoom stay off and the frame rate is capped, diff --git a/docs/mod-api-gen2-compat.md b/docs/mod-api-gen2-compat.md index b0c50d28..ddc00b15 100644 --- a/docs/mod-api-gen2-compat.md +++ b/docs/mod-api-gen2-compat.md @@ -55,10 +55,10 @@ The short version, for an author deciding what to write: ``` `games` is an optional array of version ids (`"red"`, `"blue"`, `"yellow"`, -`"gold"`, `"silver"`), generations (`"gen1"`, `"gen2"`, case-insensitive) or -`"all"`. `src/mods/ModTargets.lua` resolves the tokens off `GameVersion.ORDER` -and `GameVersion.generation`, so nothing anywhere restates the game list. -`"gen2"` now expands to both Gold and Silver. +`"gold"`, `"silver"`, `"crystal"`), generations (`"gen1"`, `"gen2"`, +case-insensitive) or `"all"`. `src/mods/ModTargets.lua` resolves the tokens off +`GameVersion.ORDER` and `GameVersion.generation`, so nothing anywhere restates +the game list. `"gen2"` now expands to Gold, Silver and Crystal. `Manifest.validate` stores the resolved, ORDER-sorted ids on `manifest.games` and **derives** `manifest.gen2compat` from them, which is the one field the loader's gate reads. @@ -576,7 +576,7 @@ gains a field instead of the name gaining a prefix. at the same moment `src/core/Game.lua` and `src/render/Renderer.lua` raise it -- the logic tick before the pad is read, a pointer the touch overlay gets first refusal on, the palette zone list handed to the present pass, the - composed frame before GBCFX, the letterbox, and the finished playfield rect + composed frame before ShaderFX, the letterbox, and the finished playfield rect -- and carries the same payload. `render.hud`'s `gameX` / `gameY` really is where Gold's dialogue boxes and menus land, because `Chrome.fitScale` / `fitOrigin` and `World:fitScale` diff --git a/docs/modding.md b/docs/modding.md index 70635e83..28a38c1d 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -963,7 +963,7 @@ contract, so a mod does not need a desktop-specific rendering path. `render.output_enabled` and `render.output` are the later, whole-window seam for mods that need the engine's normal composite rather than its separate -layers. It runs after registered present pipelines and before GBCFX, +layers. It runs after registered present pipelines and before ShaderFX, `render.hud`, and touch controls. A mod wraps both hooks: the first returns `true` only while output ownership is needed, and the second receives `(next, ctx)` with `canvas`, `width`, `height`, `gameX`, `gameY`, `gameWidth`, diff --git a/docs/new-features.md b/docs/new-features.md index 7d5f43f5..f58475cc 100644 --- a/docs/new-features.md +++ b/docs/new-features.md @@ -15,9 +15,13 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow * **Touch skins** in RetroArch overlay format and Delta `.deltaskin` (including PDF-wrapped bezel art), with per-button press states and Super Game Boy borders * **Pokédex diploma and printer image exports** * **Shareable mod lists** over save sync, optionally carrying the options set for those mods, which the receiving device is asked about before anything is changed +* **Custom carts**, a named mod set saved from the mods tab and picked from a game's page, with its own shell colour, label art, save slots and export file +* **Install required mods**, one press on a cart that will not start, fetching every pinned mod at the pinned version and refusing any archive whose hash is not the one the cart recorded +* **Browse carts in Find mods**, a Mods / Carts switch on the same community index, searched and filtered by base game, installing the cart file straight into that game's cart list ## Gen 2 Specifics * **Pokémon Silver** as an importable, launcher-selectable version alongside Gold +* **Pokémon Crystal** as an importable, launcher-selectable version alongside Gold and Silver * **Mod manager** with Gen 1 mod adapters, per-game targeting, and `modkit gen2check` * **Followers** for mods, plus Gen 2-only registries and hooks diff --git a/docs/preparing-your-mod-for-gen2.md b/docs/preparing-your-mod-for-gen2.md index 70a63db1..b4153ea0 100644 --- a/docs/preparing-your-mod-for-gen2.md +++ b/docs/preparing-your-mod-for-gen2.md @@ -176,7 +176,7 @@ something the filesystem encodes. | token | means | | --- | --- | -| `"red"`, `"blue"`, `"yellow"`, `"gold"`, `"silver"` | that one game (a version id from `GameVersion.ORDER`) | +| `"red"`, `"blue"`, `"yellow"`, `"gold"`, `"silver"`, `"crystal"` | that one game (a version id from `GameVersion.ORDER`) | | `"gen1"`, `"gen2"` | every game of that generation (case-insensitive; `"gen 2"` also parses) | | `"all"` | every game this engine has | diff --git a/docs/shaderfx.md b/docs/shaderfx.md new file mode 100644 index 00000000..03e34c5b --- /dev/null +++ b/docs/shaderfx.md @@ -0,0 +1,673 @@ +# ShaderFX: runtime slang shader presets + +ShaderFX plays real libretro `.slangp` shader presets over the finished frame. +It replaced `src/render/GBCFX.lua`, a hand-ported fixed four-level effect, with +a picker: any preset the player drops in a folder, or any preset pulled from +the RetroArch buildbot, can be selected and run. Engine: +`src/render/ShaderFX.lua` (discovery, download, translation call, pass-graph +runtime, render entry point), `src/render/ShaderFixup.lua` (GLSL rewrites), +`src/render/ShaderSourcePatches.lua` (pre-translation source patches), +`src/core/Sensors.lua` (accelerometer and gyroscope), `src/ui/ShaderFXScreen.lua` +(the picker), `src/ui/ShaderFXParamsScreen.lua` (per-preset parameter editor), +`tools/shaderfx-bridge/` (the Rust translator). Call sites: +`src/render/Renderer.lua` (Gen 1) and `src/core/Game2.lua` (Gen 2), both at the +end of the frame. Drivers: `tests/drivers/gold_shaderfx_zoom_sizing_test.lua`, +`tests/drivers/gold_shaderfx_menu_black_crop_test.lua`. + +This is first-party engine code, not a mod. It calls the native translator +directly through `ffi.load`, with no `Sandbox.lua`, no `native` permission +declaration, and no mod boundary. Mods do not get that, and the distinction is +deliberate: the upstream maintainer will not accept `native` in mods. + +## What a player sees + +**OPTIONS** carries two rows, `SHADER FX` and `SHADER FX 2`. Each opens the +same pushed list screen (`ShaderFXScreen`) on a different slot. The list is +`OFF`, then every `.slangp` found on disk, then a permanent `DOWNLOAD SHADERS` +action row at the bottom. + +A preset that has never been translated draws muted with a `CONVERT` hint on +the right. `A` on that row translates it in place and stays open: converting is +a preparation step, not a selection. `A` on a converted row activates it, +persists the choice, and closes. `SELECT` on a converted row opens +`ShaderFXParamsScreen`, which lists every `#pragma parameter` the preset +declares and lets the player step each one (`A` wraps, Left/Right clamp, +`SELECT` resets one row, `START` resets all behind a confirm). + +Presets live in a plain OS folder, `shaders/` under the portable base directory +when running portable (`SaveData.portableBaseDir()`, the SD-card convention the +Anbernic pack uses, see `docs/anbernic-rg34xxsp.md`) and otherwise under LOVE's +save directory. `ShaderFX.list()` scans it recursively, so a shader pack keeps +whatever nested layout it shipped with. These are real filesystem paths rather +than `love.filesystem` virtual paths on purpose: the native translator does +plain `std::fs` reads and knows nothing about LOVE's mounts, and neither do the +LUT loads. + +Persisted state, all in `save.options`: + +| Key | Meaning | +| --- | --- | +| `shaderfx` | main slot's preset name, or absent for OFF | +| `shaderfxSecondary` | secondary slot's preset name | +| `shaderfxParams[name][paramId]` | one preset's edited pragma values | + +`POKEPORT_SHADERFX=` activates a preset in the main slot for scratch +harnesses that never call `ShaderFX.applyOptions`. It is a stand-in that +predates the real OPTIONS row; `applyOptions` marks itself as having run so the +env var can never later override a real player choice, including a real choice +of OFF. + +One quiet behavior worth knowing about: if a slot wants a real preset while +PERFORMANCE is still on AUTO, and AUTO would resolve to a tier that caps +ShaderFX off, `ShaderFX.applyOptions` pins PERFORMANCE to `HIGH`. Without it +the saved choice was force-deactivated a few lines later on every boot, which +looks identical to "the setting does not save" from the player's side. On +Android and iOS that is the common case, because AUTO always resolves to +`balanced` there. It never touches an already-explicit performance choice and +never un-escalates. + +## The five stages + +| Stage | Owner | +| --- | --- | +| Fetch | `ShaderFX.list`, `ShaderFX.downloadPresets`, `ShaderFX.installDownloaded` | +| Translate | `tools/shaderfx-bridge/` via `ShaderFX.translate` | +| Fixup | `src/render/ShaderFixup.lua` | +| Cache | `ShaderFX.convert` writes, `ShaderFX.load` reads | +| Run | `ShaderFX.runChain` / `runPass` / `ShaderFX.render` | + +### Fetch + +Two acquisition paths, and they land in the same place. A player can copy a +shader pack into `shaders/` by hand, or press `DOWNLOAD SHADERS`, which fetches +`https://buildbot.libretro.com/assets/frontend/shaders_slang.zip` (the same +~54 MB archive RetroArch's own "Update Shaders" entry pulls) through +`src/net/Fetch.lua`, the curl-on-a-`love.thread` transport the self-updater and +mod index already use. + +Downloaded presets are *not* pre-converted. The buildbot is RetroArch's asset +mirror and has no notion of this project's cache format, so a downloaded preset +goes through the same `CONVERT` row a hand-copied one does. Every platform +ships both convert and use; there is no asymmetry to work around. + +Repeat downloads are conditional. The zip itself is deleted right after +extraction, so what is cached instead is the buildbot's own ETag +(`shaderfx_buildbot.etag`), replayed as `If-None-Match`. The server answers a +match with 304 and no body, and curl writes no file at all in that case, so +`installDownloaded(notModified)` short-circuits before touching the filesystem +and reports "already up to date" rather than "FAILED". + +The interesting part is what gets extracted. `handheld/` is not self-contained: +its 78 presets carry 101 references that escape the folder (color-mod LUTs +into `../shaders/color/`, console-border helpers into `../../reshade/`, shared +motion-blur and misc helpers, a shared `stock.slang`) across roughly 40 of +them. Extracting `handheld/` alone silently breaks about a third of its own +list; extracting the whole zip drags in ~5600 files of CRT, arcade and console +content nobody asked for. So `extractClosure` walks the real file-level +dependency closure in Lua before a single file is copied, the same +`#reference`-closure idea librashader applies internally, done up front because +deciding what to copy has to happen before the translator ever sees these +files. Against this zip that is 207 files and about 9.5 MB with zero broken +references. The closure seeds from `KEPT_PRESETS`, a curated shortlist rather +than all 78, after most of `color-mod/` and `console-border/` turned out either +irrelevant (color-only, no LCD effect) or broken for this project. That list +is a temporary trim pending wider testing and is expected to change. + +Two mechanical details in that walk that are easy to get wrong a second time. +`extractRefs` tries a quoted `key = "path"` match per line first, with an +unrestricted `[^"]+` capture, because the closing quote is an unambiguous +delimiter and real packs ship paths with spaces and parentheses in them +(`"shaders/handheld/color-mod/Game Boy (Color).slang"`); only a line with no +quoted match falls back to a conservative character class, since an unquoted +path has no delimiter to trust past. And `love.filesystem.write` does not +create intermediate directories, so each destination directory is created once +before anything is written into it; without that, every file under a subfolder +`handheld/` never had before was silently dropped while flat writes succeeded. +Cleanup is equally literal: `love.filesystem.unmount()` takes the archive path +originally passed to `mount()`, not the mountpoint. Called with the mountpoint +it returns false, leaves the zip's handle open, and the following `remove()` +silently fails too, so every download used to leave 54 MB on disk forever. + +### Translate: the native bridge + +`tools/shaderfx-bridge/` is a small Rust crate (`spike`) that builds a cdylib +named `librashader_bridge`. It wraps `librashader-presets`, +`librashader-preprocess` and `librashader-reflect` behind a two-function C ABI: + +``` +char* librashader_translate_preset(const char* preset_path, int es); +void librashader_free_string(char* s); +``` + +It returns a JSON `TranslateResult`: `pass_count`, a `passes` array (each with +its emitted `vertex`/`fragment` GLSL, `filter`, `wrap_mode`, `scale_x`/ +`scale_y`, its own `#pragma parameter` declarations, a classified `samplers` +list and a classified `size_uniforms` list), the preset's `textures` (LUTs, +with resolved absolute paths and filter/wrap settings), its +`parameter_overrides`, and an `error` field. + +**What the bridge is not.** No librashader runtime backend is linked in, for +any API: no GL, Vulkan, D3D or Metal crate is a dependency, and no live +graphics context is ever touched. It is the translation step only. LOVE still +owns every draw call, every canvas and every shader object. The bridge hands +back text and metadata and nothing else. + +**When it is called.** Only from `ShaderFX.convert()`. Translation is ahead of +time, not just in time. `ShaderFX.load()`, `ShaderFX.activate()`, boot-time +reactivation of a saved choice and every frame of `ShaderFX.render()` read the +cached artifact and never call the library. An entry with no cached artifact +fails `activate()` loudly instead of silently live-translating. + +The classification is done with librashader's real semantics resolution rather +than name matching on the Lua side, and that matters for correctness, not just +tidiness. Sampler classification uses `ShaderSemantics::create_pass_semantics` +plus the `TextureSemanticMap` lookup (explicit alias and LUT-name entries +first, then the built-in `Source`/`Original`/`OriginalHistoryN`/`PassOutputN`/ +`PassFeedbackN` conventions), so a pass reachable only by its real `.slangp` +alias resolves. The per-pass convenience API only registers the alias of the +pass being compiled, so the bridge mirrors upstream's `insert_pass_semantics` +loop and builds a preset-wide alias map first; without that, +`ds-hybrid-scalefx.slangp`'s pass 2 sampling `scalefx_pass0` by alias, with no +`PassOutput1`-shaped name anywhere, can never resolve. Size-uniform +classification runs the same resolution over the pass's `uniform_semantics` +map, which also covers shapes (`PassFeedbackSizeN`, `UserSizeN`) that no +present preset uses but that the convention allows. + +The `es` flag picks the emitted dialect: 1 for GLSL ES 1.00 (mobile, LOVE's ES +dialect), 0 for GLSL 1.20 (LOVE's desktop dialect). `ShaderFX` picks it from +`love.system.getOS()`, and the same function decides which dialect +`validateShader` is asked about at run time, so the two always agree. Convert +and render always happen on the same device; **artifacts are not portable +across platforms**. + +One class of fixup has to happen in the bridge rather than in `ShaderFixup.lua`, +on the raw `.slang` text before SPIR-V compilation: `textureSize`, +`texelFetchOffset` and `textureOffset` are all ES 3.00+ only, and spirv-cross +refuses to *emit* them for an ES 1.00 target, failing the whole pass with +`UnsupportedSpirv("textureSize is not supported in ESSL 100.")`. No GLSL text +is ever produced for a later pass to patch, so `rewrite_essl100_gaps` rewrites +the source first, and only for the ES target: + +- `textureSize(Tex, lod)` becomes a literal `ivec2(w, h)` when `Tex` is one of + the preset's declared static textures, with dimensions read straight out of + each PNG's IHDR chunk. A texture that is not one of those, or a non-literal + `lod`, is left alone so it fails as loudly as before instead of guessing. +- `texelFetchOffset` and `textureOffset` become ordinary `texture()` calls at + the equivalent texel-centre UV, using the texture's own `Size.zw` + reciprocal-size uniform. The containing block instance (`params`, `global`, + whatever) is discovered by scanning the source's own uniform block bodies, + never assumed. When a pass samples another pass purely through these calls it + may never declare that `Size` uniform at all, so one is injected first, + before any byte offsets are computed. + +That last rewrite has an honest limit: `texture()` honours the sampler's wrap +mode at out-of-range coordinates, which is not necessarily identical to +`texelFetch`'s implementation-defined out-of-bounds behavior. Any edge-of-image +discrepancy for passes whose offsets can leave the image is unmeasured. + +**Building it.** `cargo build --release` inside `tools/shaderfx-bridge/`. +`ShaderFX` looks for the library, most specific first: the +`LIBRASHADER_BRIDGE_DLL` environment variable, the source directory, +`/tools/shaderfx-bridge/target/release/`, the save directory, and +finally the bare name handed to the system loader. Per-OS names are +`librashader_bridge.dll` (Windows), `liblibrashader_bridge.dylib` or +`librashader_bridge.dylib` (macOS), and `liblibrashader_bridge.so` or +`librashader_bridge.so` (Linux and Android). Android resolves the bare name +because the `.so` ships as an ordinary `jniLibs` entry, so `dlopen` finds it +without a path. The desktop path is still a developer build sitting in cargo's +output directory; nothing packages it next to a shipped game yet. +`ShaderFX.canConvert()` reports whether the library resolved on this machine, +and `ShaderFX.bridgeError()` says why not. Activating an already-converted +preset never needs any of this. + +### Fixup + +`ShaderFixup.lua` mechanically rewrites the emitted GLSL into something LOVE +will accept. librashader emits a standalone `void main()` /`gl_FragData[0]` / +`gl_Position` shape (translated Vulkan GLSL); LOVE requires the `effect()` and +`position()` convention and refuses a raw `main()`-shaped source outright. This +is a targeted rewriter, not a GLSL parser, and every rule below exists because a +real preset in the corpus failed without it. This list is the least guessable +part of the whole feature. + +**`#version` line.** Stripped; LOVE prepends its own. + +**Array constructors.** SPIR-V Cross emits ES 3.0 array-constructor syntax +(`const float _17[5] = float[](0.0, 1.0, ...)`) for compile-time array +literals, which validation rejects with "arrayed constructor: not supported for +this version". GLSL ES 1.00 has no array-constructor syntax at all. There are +three real shapes in the corpus and each is handled: a `const` global (declared +without an initializer at global scope, with per-element assignments relocated +to the top of `main()`), a non-const local declaration with initializer +(rewritten in place, since a function body can hold assignment statements where +the literal was), and a bare reassignment of an array declared elsewhere (also +in place). Splitting the element list needs `splitTopLevelCommas`, because a +naive comma split breaks on any element containing its own parentheses +(`vec2(-1.0, 0.0)`), and the outer capture needs `%b()` rather than a +`[^%)]-` class for the same reason: the class stops at the first inner `)`, the +whole match fails, and the literal passes through completely untouched. + +**Whole-array copies.** `float param_1[7] = coeffs;` is how SPIR-V Cross clones +a function-parameter array before passing it on, since GLSL array arguments are +by value. ES 1.00 has no whole-array assignment either, so it becomes a bare +declaration plus an element-by-element copy. The size is known from the +declaration, so no comma splitting is involved. This one only became reachable +once the other array shapes stopped masking it in the same file. + +**Integer modulo.** ES 1.00 has no `%` operator, and SPIR-V Cross emits it +anyway for an upstream integer-modulo op. `%` in GLSL is only defined for +integer operands, so routing through float `mod()` and back is exact for the +non-negative operands this shader family uses (rotation and orientation enum +indices). Four patterns are tried in order (paren/paren, paren/bare, +bare/paren, bare/bare) because SPIR-V Cross fully parenthesizes a compound +operand and leaves a simple one bare, and the balanced form must be tried +before a plain identifier can partially match. Seen live on +`authentic_gbc`'s subpixel-rotation math. + +**Precision.** SPIR-V Cross hardcodes an unguarded `precision highp float;` / +`precision highp int;` pair with no toggle. That is removed and replaced with +the same guard the old hand-written `DotMatrix` port used: claim `highp` only +where `GL_FRAGMENT_PRECISION_HIGH` says the driver actually offers fragment +highp, and fall through to the stage default otherwise. + +**Struct flattening.** This is the big one. LOVE's `Shader:send` cannot address +a member of a custom struct-typed uniform: neither an `INSTANCE.member` dot path +nor sending the whole struct as a table works, both raise "Shader uniform '...' +does not exist." librashader emits every pass's `#pragma parameter`s and size +uniforms as exactly that kind of struct, so as shipped the output is unusable +from LOVE, not merely inefficient. `flattenStruct` deletes the struct and its +instance uniform and re-declares the members at top level. Scalar members are +*packed* four at a time into synthetic `uniform vec4 LIBRA_PACKED_N;` slots, +because GLSL ES 1.00 guarantees only 16 fragment uniform *vectors* and every +scalar costs a whole one; `gb-pass4`'s pass 0 alone has 14 scalars, already over +budget unpacked. Non-scalar members keep their own uniform, since packing an +existing `vec4` saves nothing. Every `instance.member` reference is rewritten to +`LIBRA_PACKED_N.x` (or `.y`/`.z`/`.w`), and a member whose original declared +type was `int` or `bool` gets an explicit cast back on every read, since a +packed slot only stores floats. Vertex and fragment declare identically ordered +structs for the same parameters, so packing both with the same prefix assigns +the same slot and component to the same parameter in both stages, and one +`shader:send` reaches whichever stage uses it. + +Two ordering constraints inside that function are load bearing and look +arbitrary from the outside. Packing must walk the members in the struct's own +declaration order, because that order is what keeps the scalars in one +contiguous run; an earlier version sorted them longest-name-first before +packing and produced six packed vec4s instead of four on `gb-pass4`, blowing the +budget. Substitution, separately, must go longest-name-first, so a replacement +can never land as a substring inside a still-pending member name that shares a +prefix. The two orders are separate copies of the list for exactly that reason. + +`Fixup.packValues` turns a flat `{name = value}` table back into the +`{uniform = value_or_vec4}` shape the packed shader expects, per the manifest +`flattenStruct` returned. `Fixup.countUniformSlots` counts declared uniform +slots against that same 16-vector budget, samplers excluded. Its pattern uses +`[%w_]+` rather than `%w+` because Lua's `%w` does not include underscore +unlike regex `\w`, and every generated name here is full of underscores. + +**UBO blocks.** `LIBRA_UBO_FRAGMENT` and `LIBRA_UBO_VERTEX` have the identical +problem and are flattened the same way, under a distinct `LIBRA_UBO_PACKED_` +prefix so their groups cannot collide with the push block's numbering. `MVP` is +the one special member: it is substituted directly to `transform_projection` +instead of becoming a uniform, because "multiply the incoming vertex by it" is +exactly what LOVE's `transform_projection` already is on a full-screen draw, and +nothing would ever supply a value for it. An earlier version assumed the UBO +block only ever carried `MVP` and deleted the whole declaration after +substituting it. That is true only for presets that declare their parameters in +a push-constant block; presets that use a UBO instead (many real handheld and +console-border presets do) had every other member reference left dangling, and +the driver then read `INSTANCE.PAR` as a swizzle, which is where the "undeclared +identifier" and "unknown swizzle selection" errors on real Android hardware came +from. + +**Fragment entry point.** `void main()` becomes LOVE's `effect()` signature. +The parameter list is qualified by an `EFFECT_PREC` define rather than a literal +precision, because LOVE forward-declares `effect()`'s prototype under its own +header's precision default before this source runs, which can mismatch whatever +the precision guard above raises the default to. `Fixup.PREC_HEADS` holds the +two variants (`mediump`, then unqualified) and the caller tries them in order +against `validateShader`, taking the first that passes. + +**Fragment output.** `gl_FragData` does not exist in LOVE's `effect()` +convention, so every `gl_FragData[0]` occurrence is rewritten to a local +`gbFragColor`, declared at the top of the function, with a single `return` +appended before the closing brace. Rewriting *every* occurrence regardless of +the operator that follows is necessary, not just symmetric with the vertex side: +an earlier assign-then-return pair assumed one write at the very end of +`main()`, which holds for most presets but not for ones like `ds-hybrid-sabr` +that write once with `=` and later accumulate with `+=`. The `+=` statement +passed through unconverted and collided with LOVE's own `gl_FragColor` write +("Cannot use both gl_FragColor and gl_FragData"). A bare early `return;` is +rewritten to `return gbFragColor;`, which holds the value assigned just before +it on every real shape seen. + +**Vertex entry point.** `void main()` becomes `position(mat4 +transform_projection, vec4 vertex_position)`, the source's own `attribute` +redeclarations of `Position`/`TexCoord` are dropped since LOVE supplies them, +`gl_Position = X;` becomes `gbClipPos = X;` (named to share no substring with +`Position`, or the next step would mangle it), and a `return gbClipPos;` is +appended. The `Position` and `TexCoord` substitutions are frontier-matched +whole identifiers (`%f[%w]...%f[%W]`), not blind substring replacements: real +presets declare their own unrelated locals such as `vec2 vTexCoord;`, and a +blind `gsub` turned that declaration into the invalid `vec2 vVertexTexCoord.xy;` +(`dot.slangp` pass 0, a real driver "unexpected DOT" error). + +### Cache + +`ShaderFX.convert(entry)` is the only path that calls the bridge. It runs +`ShaderSourcePatches.apply` first, translates, then serializes the decoded +result to `ShaderFX.artifactPath(entry)`: the source `.slangp`'s own absolute +path with the extension swapped to `.lua`, so the artifact sits next to the +preset it came from. The file is a plain `return { ... }` chunk written by +`serializeLua`, which handles the string/number/boolean/nested-table shape +`Json.decode` produces. Array detection walks every key rather than trusting +`#t`, since `#t` counts a trailing nil as absent and a sparse table can pass a +naive length check by accident. + +`ShaderFX.load(entry)` `loadfile`s that chunk and builds a chain state. On +failure it says "convert this preset first" rather than falling back to a live +translation. + +**AOT rather than JIT** is the whole point of this stage. Translation is a rare, +explicit, user-initiated action whose result is stable for a given preset and +dialect, so paying for it once and writing the answer to disk keeps `ffi.load` +and the native call off every activation, every boot and every frame. The cost +is a staleness gap: `ShaderFX.isConverted()` is a plain "does the artifact file +exist" check with no version or content stamp, and there is no explicit +"reconvert" action in the UI. A preset converted by an older build never picks +up a later translator fix on its own. This was seen on a real device, where +`sunlight_shimmer.slangp`'s `Accelerometer` uniform never reached the shader +because that device's cache predated the fix while `pixel_transparency`'s +happened to be fresher. Two places compensate by reconverting unconditionally: +`ShaderFXScreen`'s explicit selection of an already-converted row, and +`ShaderFX.applyOptions` on every boot and options save. Both are human-paced, +CPU-only work with no GPU compile, and neither is on the per-frame path. + +`ShaderSourcePatches.lua` sits just before translation and patches the raw +`.slang`/`.inc` files *on disk*, because only librashader's own preset parser, +reading the real files, discovers `#pragma parameter` lines and struct members; +nothing downstream can add one. Patches are small, explicit, per-preset literal +find/replace pairs (plain `find`, not `gsub`, since GLSL source is full of Lua +pattern magic), re-applied idempotently on every convert so a buildbot +re-download that replaces the upstream file wholesale does not quietly undo +them. **The patch table ships empty on purpose and nothing registers one.** Its +original use case, wiring gyroscope yaw into `sunlight_shimmer.slangp` as new +`PT_YAW_*` pragma parameters, was reverted precisely because of the staleness +gap above: a new pragma can only reach an artifact that gets reconverted, and at +the time nothing forced one. The mechanism is kept for a future preset that +genuinely needs a new declaration, but an already-wired engine-side channel is +preferred whenever one exists. + +### Run: the pass graph + +`ShaderFX.activate(slot, entry, paramOverrides)` loads the artifact, layers the +player's edited parameters over the artifact's own defaults, loads the preset's +LUTs once, and snapshots the accelerometer rest pose. `ShaderFX.render` then +runs the chain each frame. + +`newChainState` builds one instance of chain-local state per loaded preset, +never module-global, so switching presets cannot leak a previous preset's +canvases or dimensions. `ALL_DEFAULTS` is built in layers: each pass's declared +`initial`, then the preset's own `parameter_overrides`, then (in `activate`) the +player's `shaderfxParams` edits. + +Sizes resolve through `resolveScale`, which handles all four slang scale types +(`absolute`, `viewport`, `source`, `original`) against the viewport, the pass's +input dimensions and the original frame. Size uniforms are packed as +`{w, h, 1/w, 1/h}`, the slang convention. + +`runPass` caches two things per `(state, pass index)`. The **shader** is +compiled once for the state's lifetime, along with the fragment manifest it was +compiled against, since a pass's GLSL depends only on the preset and never on +per-frame input. The **canvas** is reallocated only when the pass's resolved +size actually changes, a window resize or a different preset. Every harness this +runtime was ported from ran the chain once and quit, so allocating a fresh +canvas and compiling a fresh shader on every call was invisible there. On a real +per-frame render path it is one GPU allocation per pass per frame, and a shader +recompile on top. The same discipline applies to the crop canvas in +`cropToGbSource`, which is called once per frame and whose size grows with the +world canvas as the player zooms out; leaving it uncached was a real cost that +scaled with zoom level even with a single preset active. + +Sampler binding is by semantic, from the bridge's classification, never by a +hardcoded per-preset name check: `Source` is the previous pass's output (or the +input frame for pass 0), `Original` and `OriginalHistory` are the input frame, +`PassOutput` indexes an earlier pass's canvas, and `User` resolves a LUT by the +real name the translation reported. A sampler that resolves to nothing asserts +rather than drawing garbage. + +**LUTs** are loaded once per `activate`. `ShaderFX.loadImageFromPath` reads the +bytes with plain `io.open` and goes through `love.data.newByteData` and +`love.image.newImageData`, because a preset's texture paths are arbitrary +absolute OS paths outside any LOVE mount and `love.graphics.newImage` refuses +those outright ("Could not open file ... Does not exist") even when the file is +real. Wrap modes are mapped from librashader's names to LOVE's +(`clamp_to_border` to `clampzero`, `clamp_to_edge` to `clamp`, `repeat`, +`mirrored_repeat` to `mirroredrepeat`). A LUT that fails to load is logged and +left nil; the fail-loud point is the sampler assertion in `runPass` that +actually needed it, not the loader. + +**History ring.** `OriginalHistoryN` currently resolves to a steady state: every +history slot reads the current frame, both for the sampler binding and for the +size uniform. Real per-frame history rotation has been proven out in a desktop +harness but is not wired into this path. + +**Feedback.** `PassFeedback` is not implemented. A `PassFeedback` or `User` size +uniform raises an explicit "not yet supported" error, and a `PassFeedback` +sampler resolves to nothing and trips the binding assertion. No preset in the +shipped shortlist uses it. + +**Blending.** Every pass draws with `replace`, and the chain's final pass uses +`replace, premultiplied`. Intermediate canvases are `nearest` filtered. + +`ShaderFX.render(canvas, rect, source, dpiX, dpiY)` is the entry point +`Renderer:endFrame` and `Game2` call. `canvas` is the finished window-sized +composite (world, UI, and any post-process pipeline that already ran); `rect` is +this frame's real playfield rectangle in physical framebuffer pixels and +`source` is the real pixel size of the content it frames. The sequence is: crop +`rect` out of the composite, run whichever slots are active over that crop, draw +the untouched composite, then stretch the chain output back over `rect`. UI and +letterbox bars outside the playfield pass through untouched. If the chain throws, +the frame still shows the unprocessed composite; a broken preset degrades to +"shader off", never to a crash or a blank frame. + +Three details in that path are non-obvious: + +- **DPI.** `love.graphics.newCanvas` and `draw` work in LOVE's DPI-aware + logical units, not raw pixels, so the viewport handed to the pass graph and + the final draw-back position are both converted from `rect`'s physical pixels + first. On a `dpiscale = 1` desktop the two are numerically identical and the + bug is invisible; at dpiscale 3 on real Android hardware the chain output + rendered about three times too large and at a pixel-valued offset in unit + space. +- **Draw color.** `cropToGbSource` sets `setColor(1, 1, 1, 1)` explicitly. The + caller can leave the draw color dirty (a menu's black text leaves it at + `(0,0,0,x)`), the crop draw multiplies the canvas texels by the active color, + and `push("all")` saves state for `pop()` without resetting it. That was the + root cause of the Gen 2 blank-menu bug, confirmed on a desktop repro where + `getColor()` read `0,0,0,1` here exactly when a menu was on the stack. + `flushBatch()` on the line above is cheap insurance against a read-after-write + ordering hazard between this draw and whatever last rendered into the canvas; + it was never confirmed to fix anything on its own. +- **The final blit stretches.** A slang chain's last pass is not required to + land on the viewport size, and most presets (21 of the 78 in the corpus) + declare their last pass `scale_type = "source"` and stay at native Game Boy + resolution, relying on the frontend's blit exactly as RetroArch does. + Requiring an exact size match here used to skip the draw outright for every + such preset on every frame, which is a silent total no-op rather than a sizing + quirk. The stretch uses the last-run chain's own final-pass `filter` to pick + nearest or linear. + +### What this engine feeds shaders that a libretro core does not + +A stock libretro core hands its frontend a raw framebuffer and a frame count. +This engine has more context available and passes some of it through. + +| Context | How it reaches the shader | +| --- | --- | +| Playfield rect and true source size | `rect`/`source` per frame from `Renderer:endFrame` or `Game2`, so the chain sees real on-screen geometry at any survey zoom or Faithful Ratio state rather than a fixed 160x144 assumption that then gets stretched | +| Blit scale | `rect.scale`, the crisp integer scale the composite was built at, used to derive the crop's own draw scale | +| SGB zone coloring and palette | Baked into the input frame. `PaletteFX` zone passes run before the composite reaches ShaderFX, so a preset shades an already-zone-tinted image | +| Performance tier | `chainRenderScale()` reads `Performance.CAPS[tier].shaderfx`, a chain-resolution multiplier; the viewport and the cropped source both shrink by it and the final blit upscales | +| Accelerometer | `Sensors.read("accelerometer")`, bound to the `Accelerometer` unique semantic | +| Gyroscope | `Sensors.read("gyroscope")`, bound to `Gyroscope`, plus the integrated yaw twist below | + +Two motion semantics are deliberately pinned rather than guessed. `Rotation` is +bound to 0 because librashader's own documentation is explicit that it is +`retroarch_get_rotation()`, the *content's* requested rotation (a vertically +oriented arcade core, say), not device orientation. Nothing here ever rotates +Game Boy content, so 0 is the correct answer, not a placeholder. +`AccelerometerRest` is bound to `{0, 0, 0}`: it is librashader's "reading at +rest" calibration reference, no preset in the corpus reads it, and a fixed +placeholder beats an invented value. + +`src/core/Sensors.lua` is what makes the two real motion semantics work. +`love.sensor` does not exist in LOVE 11.5, the version this project ships, on +any platform including Android; it is a LOVE 12 addition. The working path is +raw FFI into the SDL2 that LOVE already links, the same technique +`src/core/Orientation.lua` uses, opening the first `SDL_SENSOR_ACCEL` or +`SDL_SENSOR_GYRO` device via `SDL_NumSensors`/`SDL_SensorGetDeviceType`/ +`SDL_SensorOpen`. The `love.sensor` path is kept above it and simply stops being +dead code after a future LOVE 12 upgrade. Loading order matters: +`ffi.load("SDL2")` first, needed on desktop where SDL2 is a separate DLL, then +bare `ffi.C`, needed on Android where love-android links SDL2 statically into +`libmain.so` and there is no `libSDL2.so` for `ffi.load` to find by name. A +device with no sensor is probed once and then permanently reports zeros, so a +desktop run does not pay for it every frame. + +SDL keeps sensor readings in the device's fixed chassis frame regardless of +screen orientation, so `rotateForScreen` remaps x and y into +"as currently displayed" terms using `SDL_GetDisplayOrientation`. That +compensation is mobile-only: a desktop monitor is legitimately and permanently +"landscape" to that query, which says something about the monitor's shape and +nothing about how a player is holding anything. + +The accelerometer path in `sizeTable` does three things to the raw reading +before it becomes a uniform, all of them driven by real on-device data: + +1. **Rest-pose subtraction.** `activate()` snapshots whatever pose the player is + actually holding the device in and every later reading is measured relative + to that, rather than to an assumed idealized vertical. The shipped tilt maths + (`pt_base.inc`'s `getOrientedTilt`) was authored assuming gravity sits almost + entirely on one axis at rest; a natural, comfortable hold already puts 56 to + 66 percent of gravity's magnitude on the axis the shader reads as tilt, so + the effect sat near-saturated all the time instead of starting near neutral. +2. **Axis swap.** The tilt maths assumes a device resting flat, with gravity + dominant on Z, the one axis it never reads. This engine's rest pose is + upright portrait, where Y is gravity-dominant, so y and z are swapped to put + gravity back on the ignored axis. +3. **Denominator stabilization.** `getOrientedTilt` normalizes by the full + vector's magnitude. Before calibration that magnitude was a stable ~9.8 that + quietly damped tilt and noise alike by the same factor; calibration correctly + zeroes x and y at neutral but also shrinks the magnitude near rest, and real + logs showed it swinging between 0.65 and 11.4 second to second on ordinary + hand jitter, which reads as wildly bouncing. A fixed constant is re-injected + on the ignored axis to keep the denominator stable, but only when there is a + genuine live reading to calibrate against. An all-zero raw read is + `Sensors.lua`'s explicit "no hardware at all" sentinel, never a real value on + Earth, and injecting into that case would make the shader believe it had + sensor data and silently replace its own static fallback with fake motion. + +Yaw is a separate mechanism. A raw gyroscope reading is angular velocity, not an +angle, so it only becomes a usable on-screen offset by integrating over time, +and only the per-slot state persists frame to frame to do that. `updateYawTwist` +is deliberately a decaying spring rather than a true integrated heading: +gyro-only integration drifts without a magnetometer to correct it, so this +settles back toward neutral and stays bounded by construction. It is folded onto +the accelerometer's x component before the shader's own normalize and clamp, +because that is the only already-compiled channel the stock upstream maths +reads, and reaching an already-converted artifact with no reconvert was worth +the tradeoff that the twist reads as an added simulated tilt rather than a +cleanly separate motion. It applies to `sunlight_shimmer.slangp` only, the one +preset in the shortlist with a twist-reactive channel. `YAW_GAIN = 0.6` and a +clamp of +/-2 were tuned against real device data (a moderate real yaw turn +peaks around 1.5 to 1.7 rad/s); a much larger gain was tried on-device and +looked worse, because overshooting a comfortable range reads worse than being +subtle. Retune in small steps with real device checks, not big jumps. + +## Two slots + +`ShaderFX.SLOTS` is `{"main", "secondary"}` and `ShaderFX.OPTION_KEY` maps each +to its save key. The slots are activated and persisted independently, and the +same `ShaderFXScreen` serves both, opened with the slot as its argument. Pragma +parameter edits are keyed by *preset name*, not by slot, because a preset's +values are a property of the preset the same way its cached artifact is; +editing them re-activates every slot currently showing that preset and persists +for the next load in either. + +When both slots are active, `render` runs main's chain first and hands its +finished output to secondary as secondary's own input frame, along with its +dimensions, so a secondary preset that scales off its input sees main's real +output size rather than the original crop. Either slot alone behaves exactly as +a single-preset path; neither active is a plain passthrough. + +**This is not how RetroArch composes multiple presets.** RetroArch merges +presets into a *single* pass list through `#reference` and `Append`, producing +one pass graph with one shared semantics map, where a later pass can reference +an earlier one's output by alias and the whole thing resolves as one unit. Two +slots here are two independent librashader chains run back to back, which is +what stacking two separate preset chains would give you, not what merging them +gives you. Presets that assume merged semantics will not behave the same way. + +## Test seams + +`ShaderFX` exposes a few fields purely so a headless harness can assert on real +per-frame values without taking a screenshot: `_lastRect` and `_lastSource` +(the rect and source dimensions a caller handed in), `_lastCrop` (the exact crop +canvas, which the later unconditional draw-back would otherwise mask), +`_lastYawTwist` and `_lastAccelPacked` (the integrated twist and the values that +actually reached the packed uniform, per slot). `Sensors.setOverride`, +`Sensors.clearOverride` and `Sensors.setOrientationOverride` inject synthetic +readings on a machine with no hardware. + +## Limitations + +None of these are theoretical. + +- **Tested on very little real hardware.** Essentially one Android phone, one + desktop, and the automated harnesses. Anything about how a preset actually + looks or performs elsewhere is unverified. +- **No performance tier is actually tuned.** The chain-resolution multiplier in + `Performance.CAPS` is a working mechanism, but every tier that permits + ShaderFX at all sets it to 1.0. Nothing runs at reduced chain resolution + today. Picking a real value for weak hardware needs a device this project does + not have. +- **The dual-slot design does not match RetroArch.** See above. Two chains in + sequence is not one merged pass list. +- **`OriginalHistoryN` is a steady state.** Real per-frame history rotation + falls back to "every slot is the current frame" in the live render path, and + is unverified there. +- **`PassFeedback` is unimplemented.** Its size uniform raises an explicit + error and its sampler trips an assertion. +- **`ShaderFXScreen` has a known text-overlap bug on long preset names.** + `ListMenu`'s `fitLabel` truncation covers the ordinary case, but a long enough + player-supplied filename still collides with the row's right-hand hint. +- **`ShaderSourcePatches` ships with an empty patch table and nothing uses it.** + Intentional, for the reason given above, but it means the mechanism has no + live coverage. +- **Cached artifacts have no staleness detection.** Existence is the only check. + The two unconditional reconvert points paper over it; anything that does not + go through them can be running a stale translation. +- **Artifacts are per-device.** The GLSL dialect is baked in at convert time. + Copying a converted preset folder between a phone and a desktop copies a + wrong artifact along with it. +- **The bundled bridge is only as good as the build machine.** + `scripts/build.sh` bundles the cdylib for mac, win and linux via + `bundle_shader_bridge`, building it with cargo when a prebuilt one is not + supplied through `SHADERFX_BRIDGE`. A build host without cargo produces a + package that can run converted presets but cannot CONVERT new ones, and says + so rather than failing. Android ships the `.so` via `jniLibs`. +- **The buildbot shortlist is a temporary trim.** `KEPT_PRESETS` reflects one + manual pass over `handheld/` and is expected to change, most likely to shrink. +- **Tilt direction is unverified.** Which way forward and back rocking moves the + effect was never confirmed on a device; if it feels backwards the fix is a + sign flip on the swapped axis, not a deeper bug. Likewise, whether the + landscape rotation compensation matches real RetroArch is genuinely unknown: + RetroArch's Android input driver computes a screen rotation but does not + visibly apply it to the accelerometer values that reach shader uniforms, so + this project's compensation may be an improvement over upstream rather than a + match to it. +- **`texelFetch` wrap behavior at image edges may differ.** The ES 1.00 rewrite + in the bridge turns those calls into `texture()`, which honours the sampler's + wrap mode out of range where `texelFetch`'s out-of-bounds behavior is + implementation defined. Unmeasured. diff --git a/docs/switch-install.md b/docs/switch-install.md index a9ad0ade..282d5cc3 100644 --- a/docs/switch-install.md +++ b/docs/switch-install.md @@ -183,7 +183,6 @@ hotkeys (`2`/`3`/`5` are claimed before any mod pipeline hotkey runs). | ----- | -------------- | ------------------- | | Select + **A** | `2` | COLORS | | Select + **B** | `3` | TILT | -| Select + **Y** | `5` | GBC FX | | Select + **X** | `6` | Mod pipeline hotkey (if a mod registers `6`) | | Select + **L** | `7` | Mod pipeline hotkey (if a mod registers `7`) | diff --git a/docs/switch-transfer.md b/docs/switch-transfer.md index 798a8f85..ed363464 100644 --- a/docs/switch-transfer.md +++ b/docs/switch-transfer.md @@ -22,8 +22,8 @@ Player install (what to download, title override) stays in | Loose iteration pair | `sdmc:/switch/gen1recomp/gen1recomp.nro` **and** `game.love` beside it | | ROM inbox | LÖVE save dir → `imports/` (launcher shows the live `getSaveDirectory()` path; under MTP often `1: SD Card//imports/`) | | Mod zip inbox | Same save dir → `imports/mods/` then MODS → **Scan again** | -| Save `.sav` inbox | Same save dir → `imports/saves/red\|blue\|yellow\|gold\|silver/` then that game's SAVE FILES → **Import save** (Gold / Silver cart `.sav` not supported yet) | -| Save exports | Same save dir → `exports/red\|blue\|yellow\|gold\|silver/` (pull after **Export save**; Gold / Silver cart `.sav` not supported yet) | +| Save `.sav` inbox | Same save dir → `imports/saves/red\|blue\|yellow\|gold\|silver\|crystal/` then that game's SAVE FILES → **Import save** (Gen 2 cart `.sav` not supported yet, on Gold, Silver or Crystal) | +| Save exports | Same save dir → `exports/red\|blue\|yellow\|gold\|silver\|crystal/` (pull after **Export save**; Gen 2 cart `.sav` not supported yet, on Gold, Silver or Crystal) | | Opt-in diagnostics | Empty `switch-debug.txt` in the save dir → `switch.log` | | Lua error log | `lua-error.log` in the save dir | @@ -54,8 +54,8 @@ macOS, not a Mac-only requirement. 3. Create `switch/gen1recomp/` if needed; extract the release zip at SD root (or copy NRO / `game.love` for loose). 4. For ROMs/mods/saves, open the save-dir `imports/`, `imports/mods/`, - `imports/saves//`, or - `exports//` + `imports/saves//`, or + `exports//` path the launcher prints. 5. Wait for the queue; refresh; exit MTP responder; title-override launch. diff --git a/main.lua b/main.lua index 04cf3dc6..8220c9c2 100644 --- a/main.lua +++ b/main.lua @@ -287,9 +287,9 @@ end local function makeLauncher() local RomImporter = require("src.import.RomImporter") local forceImport = os.getenv("POKEPORT_FORCE_IMPORT") == "1" - return RomImporter.new(function(version) + return RomImporter.new(function(version, cartId) Importer = nil - bootGame(version) + bootGame(version, cartId) end, { launcher = true, forceImport = forceImport, @@ -311,6 +311,11 @@ local function returnToLauncher() Game = nil autopilot = nil driverCo = nil + -- Leave the cart's scope behind: the launcher's own settings and slots are + -- the base game's, not the cart's. The speed ladder is cart state too, so + -- a 1x/2x cart must not pin the launcher or the next game. + require("src.core.SaveData").setCart(nil) + require("src.core.GameSpeed").setAllowed(nil) SessionLifecycle.endMountedSession(currentVersion) @@ -328,7 +333,7 @@ local function returnToLauncher() Importer = makeLauncher() end -function bootGame(version) +function bootGame(version, cartId) -- The launcher hands us the chosen game (Red / Blue / Yellow / Gold); -- scripted and headless runs fall back to POKEPORT_VERSION, then Red. -- Set the active version and overlay its extracted cache BEFORE anything @@ -341,6 +346,25 @@ function bootGame(version) -- (Blue/Yellow/Gold caches live under blue/ / yellow/ / gold/). CacheFs.prefix = GameVersion.cachePrefix() CacheFs.mountVersion(GameVersion.get()) + local cartHash, cartSpeeds, cartOptions + if cartId then + local ok, cart, hash = pcall(function() + return require("src.carts.CartStore").get(cartId) + end) + if ok and cart then + cartHash, cartSpeeds, cartOptions = hash, cart.speeds, cart.options + else + cartId = nil + end + end + local SaveData = require("src.core.SaveData") + SaveData.setCart(cartId, cartHash) + -- The author's settings land in the cart's own scope the first time only; + -- after that the player owns them. + if cartOptions then SaveData.seedCartOptions(cartOptions) end + -- A cart may narrow or pin the speed ladder; nil restores the full one. + require("src.core.GameSpeed").setAllowed(cartSpeeds) + if cartId then SaveData.adoptCartSeal(cartId) end -- NX: always write nx-asset-probe.log so Yellow/Blue art failures are -- diagnosable from the SD without enabling switch-debug.txt. pcall(function() diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index 2c7d8798..6c8010de 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -30,6 +30,10 @@ + + /dev/null 2>&1; then + say "building the ShaderFX bridge with cargo" + if (cd "$crate" && cargo build --release >/dev/null 2>&1); then + src="$crate/target/release/$name" + fi + fi + if [ -n "$src" ] && [ -f "$src" ]; then + mkdir -p "$dest" + cp "$src" "$dest/$name" + say "bundled $name for SHADER FX preset conversion" + else + warn "$name not found: this build can run converted presets but not CONVERT new ones (set SHADERFX_BRIDGE or install cargo)" + fi +} + build_mac() { say "building macOS app" local love_app="${LOVE_APP:-/Applications/love.app}" @@ -179,6 +205,7 @@ build_mac() { # drop any bundled placeholder .love and fuse ours in find "$out_app/Contents/Resources" -maxdepth 1 -name '*.love' -delete cp "$LOVE_FILE" "$out_app/Contents/Resources/game.love" + bundle_shader_bridge "$out_app/Contents/MacOS" "liblibrashader_bridge.dylib" local plist="$out_app/Contents/Info.plist" /usr/libexec/PlistBuddy -c "Set :CFBundleName $APP_NAME" "$plist" 2>/dev/null \ @@ -292,9 +319,11 @@ build_win() { cp "$tls_dll" "$out_dir/gen1tls.dll" say "bundled gen1tls.dll for Windows TLS (wss://)" else - warn "gen1tls.dll not found — Windows zip will not support wss:// (set GEN1TLS_DLL or build native/tls_dial)" + warn "gen1tls.dll not found: Windows zip will not support wss:// (set GEN1TLS_DLL or build native/tls_dial)" fi + bundle_shader_bridge "$out_dir" "librashader_bridge.dll" + # The exe's icon lives in love.exe's PE resources, so it must be patched # BEFORE the .love is appended: peresed rewrites the whole file and would # drop the fused bytes. peresed (pipx install pe_tools) has no .ico input, @@ -390,6 +419,7 @@ build_linux() { unsquashfs -q -no-xattrs -o "$sfs_offset" -d "$appdir" "$love_appimage" >/dev/null cp "$LOVE_FILE" "$appdir/game.love" + bundle_shader_bridge "$appdir" "liblibrashader_bridge.so" # Replace LÖVE's own desktop entry rather than keeping it: it says # Name=LÖVE / Icon=love, which is what appimaged, app menus and file diff --git a/scripts/build_android.sh b/scripts/build_android.sh index e5b508a5..7451e87a 100755 --- a/scripts/build_android.sh +++ b/scripts/build_android.sh @@ -2,11 +2,22 @@ # Packages the LÖVE2D Pokémon Red port into an Android APK via love-android 11.5a. # # Usage: scripts/build_android.sh [--version X.Y.Z] [--release] [--package-only] +# [--test-application-id ID] # -# --version X.Y.Z set app.version_name / app.version_code (else left as-is) -# --release build the production-signed release APK (requires the -# GEN1RECOMP_ANDROID_* signing environment variables) -# --package-only zip game.love + apply branding; skip gradle +# --version X.Y.Z set app.version_name / app.version_code (else left as-is) +# --release build the production-signed release APK (requires the +# GEN1RECOMP_ANDROID_* signing environment variables) +# --package-only zip game.love + apply branding; skip gradle +# --test-application-id ID install under a distinct application id (e.g. +# com.theboisclub.pokemonred.shaderfxtest) so a +# test build installs side by side with a real +# played copy instead of overwriting it. App name +# gets a " (test)" suffix so it's distinguishable +# in the launcher too. Without this flag, +# apply_android_branding always writes back the +# real shipping identity, which previously had to +# be restored by hand in gradle.properties after +# every test build. # # Prerequisites: # - mobile/android vendored love-android tree at tag 11.5a (in-repo; see mobile/ANDROID.md) @@ -34,10 +45,13 @@ GOLD_MANIFEST_RELATIVE="tools/rom_manifest_gold.json" GOLD_MANIFEST_URL="${GOLD_MANIFEST_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/tools/rom_manifest_gold.json}" SILVER_MANIFEST_RELATIVE="tools/rom_manifest_silver.json" SILVER_MANIFEST_URL="${SILVER_MANIFEST_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/tools/rom_manifest_silver.json}" +CRYSTAL_MANIFEST_RELATIVE="tools/rom_manifest_crystal.json" +CRYSTAL_MANIFEST_URL="${CRYSTAL_MANIFEST_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/tools/rom_manifest_crystal.json}" VERSION="" PACKAGE_ONLY=false RELEASE=false +TEST_APPLICATION_ID="" say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; } warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; } @@ -48,11 +62,12 @@ while [ $# -gt 0 ]; do --version) VERSION="$2"; shift ;; --package-only) PACKAGE_ONLY=true ;; --release) RELEASE=true ;; + --test-application-id) TEST_APPLICATION_ID="$2"; shift ;; -h|--help) - sed -n '2,20p' "$0" + sed -n '2,28p' "$0" exit 0 ;; - *) fail "unknown argument: $1 (try --version X.Y.Z, --release, or --package-only)" ;; + *) fail "unknown argument: $1 (try --version X.Y.Z, --release, --package-only, or --test-application-id ID)" ;; esac shift done @@ -84,6 +99,14 @@ if $RELEASE; then || fail "Android signing keystore does not exist: $GEN1RECOMP_ANDROID_KEYSTORE" fi +if [ -n "$TEST_APPLICATION_ID" ]; then + if ! printf '%s' "$TEST_APPLICATION_ID" | grep -Eq '^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)+$'; then + fail "invalid --test-application-id '$TEST_APPLICATION_ID' (expected a dotted package id, e.g. com.theboisclub.pokemonred.shaderfxtest)" + fi + APPLICATION_ID="$TEST_APPLICATION_ID" + APP_NAME="$APP_NAME (test)" +fi + # --------------------------------------------------------------- preconditions if [ ! -f "$ANDROID_DIR/settings.gradle" ] || [ ! -f "$ANDROID_DIR/gradlew" ]; then fail "love-android not found at mobile/android/. @@ -246,6 +269,54 @@ ensure_silver_manifest() { fail "Silver import manifest is unavailable. Git recovery failed and could not download $SILVER_MANIFEST_URL" } +crystal_manifest_is_valid() { + local path="$1" + python3 - "$path" <<'PY' +import json, pathlib, sys + +try: + manifest = json.loads(pathlib.Path(sys.argv[1]).read_text()) +except (OSError, ValueError): + raise SystemExit(1) + +raise SystemExit(0 if manifest.get("romSha1") == + "f4cd194bdee0d04ca4eac29e09b8e4e9d818c133" else 1) +PY +} + +ensure_crystal_manifest() { + local manifest="$ROOT/$CRYSTAL_MANIFEST_RELATIVE" + local staged + staged="$(mktemp)" + + if crystal_manifest_is_valid "$manifest"; then + rm -f "$staged" + return + fi + + warn "Crystal import manifest is missing or invalid; recovering it before packaging" + if git -C "$ROOT" show "HEAD:$CRYSTAL_MANIFEST_RELATIVE" > "$staged" 2>/dev/null \ + && crystal_manifest_is_valid "$staged"; then + mkdir -p "$(dirname "$manifest")" + mv "$staged" "$manifest" + say "restored Crystal import manifest from this checkout's Git data" + return + fi + + if command -v curl >/dev/null 2>&1 \ + && curl --fail --location --retry 2 --connect-timeout 15 \ + --output "$staged" "$CRYSTAL_MANIFEST_URL" \ + && crystal_manifest_is_valid "$staged"; then + mkdir -p "$(dirname "$manifest")" + mv "$staged" "$manifest" + say "downloaded Crystal import manifest from the project repository" + return + fi + + rm -f "$staged" + fail "Crystal import manifest is unavailable. Git recovery failed and could not download $CRYSTAL_MANIFEST_URL" +} + # --------------------------------------------------------------- branding # love-android 11.5+ reads app id / name / orientation from gradle.properties. # Manifest still gets permission trims. Re-applied every build so refreshing @@ -312,6 +383,7 @@ pack_game_love() { ensure_yellow_manifest ensure_gold_manifest ensure_silver_manifest + ensure_crystal_manifest mkdir -p "$EMBED_ASSETS" rm -f "$LOVE_FILE" # tools/save-editor ships with the app: the launcher's Edit button on a save @@ -326,7 +398,7 @@ pack_game_love() { main.lua conf.lua src data assets tools/save-editor \ tools/rom_manifest.json tools/rom_manifest_blue.json \ tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \ - tools/rom_manifest_silver.json \ + tools/rom_manifest_silver.json tools/rom_manifest_crystal.json \ -x '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \ -x 'data/generated/*' -x 'assets/generated/*') # List once and match against the captured text: piping unzip straight into @@ -350,6 +422,8 @@ pack_game_love() { || fail "game.love is missing the Gold ROM import manifest" grep -qx 'tools/rom_manifest_silver.json' <<< "$archive_entries" \ || fail "game.love is missing the Silver ROM import manifest" + grep -qx 'tools/rom_manifest_crystal.json' <<< "$archive_entries" \ + || fail "game.love is missing the Crystal ROM import manifest" # This gate exists because the launcher's UI toolkit once lived outside # src/ (libs/flexlove) and was added to scripts/build.sh's payload and to # no other packager, so Android and iOS built an APK/IPA whose launcher diff --git a/scripts/linux-arm64/build_appimage.sh b/scripts/linux-arm64/build_appimage.sh index 59d59a71..1c6af56d 100755 --- a/scripts/linux-arm64/build_appimage.sh +++ b/scripts/linux-arm64/build_appimage.sh @@ -368,6 +368,8 @@ grep -qxF "tools/rom_manifest_gold.json" "$WORK/love-listing.txt" \ || fail "game.love is missing tools/rom_manifest_gold.json" grep -qxF "tools/rom_manifest_silver.json" "$WORK/love-listing.txt" \ || fail "game.love is missing tools/rom_manifest_silver.json" +grep -qxF "tools/rom_manifest_crystal.json" "$WORK/love-listing.txt" \ + || fail "game.love is missing tools/rom_manifest_crystal.json" # The .desktop's Icon= resolves against the AppDir root by basename, and # .DirIcon is what appimaged and file-manager thumbnailers read. cp "$IN/icon.png" "$APPDIR/$APP_NAME.png" diff --git a/scripts/linux-arm64/selftest_build_linux_arm64.sh b/scripts/linux-arm64/selftest_build_linux_arm64.sh index 8320222b..47fc5e59 100755 --- a/scripts/linux-arm64/selftest_build_linux_arm64.sh +++ b/scripts/linux-arm64/selftest_build_linux_arm64.sh @@ -171,5 +171,7 @@ grep -qxF "tools/rom_manifest_gold.json" "$temp_dir/love-listing.txt" \ || fail "shared payload is missing tools/rom_manifest_gold.json" grep -qxF "tools/rom_manifest_silver.json" "$temp_dir/love-listing.txt" \ || fail "shared payload is missing tools/rom_manifest_silver.json" +grep -qxF "tools/rom_manifest_crystal.json" "$temp_dir/love-listing.txt" \ + || fail "shared payload is missing tools/rom_manifest_crystal.json" say "Linux arm64 self-test passed" diff --git a/scripts/pack_love.sh b/scripts/pack_love.sh index a32653b5..7136088d 100755 --- a/scripts/pack_love.sh +++ b/scripts/pack_love.sh @@ -50,7 +50,7 @@ rm -f "$OUTPUT" main.lua conf.lua src data assets tools/save-editor \ tools/rom_manifest.json tools/rom_manifest_blue.json \ tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \ - tools/rom_manifest_silver.json \ + tools/rom_manifest_silver.json tools/rom_manifest_crystal.json \ -x '*.DS_Store' 'data/generated/*' 'assets/generated/*') if [ -f "$ROOT/PATCH_NOTES.md" ]; then (cd "$ROOT" && zip -q "$OUTPUT" PATCH_NOTES.md) @@ -96,6 +96,7 @@ for required in tools/save-editor/App.lua tools/save-editor/Kit.lua \ tools/rom_manifest.json tools/rom_manifest_blue.json \ tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \ tools/rom_manifest_silver.json \ + tools/rom_manifest_crystal.json \ src/ui/kit/Kit.lua \ src/import/LauncherView.lua; do grep -qxF "$required" "$LISTING" \ diff --git a/scripts/run.sh b/scripts/run.sh index 6cfefde1..cf89e593 100755 --- a/scripts/run.sh +++ b/scripts/run.sh @@ -19,7 +19,8 @@ if [ ! -f "$ROOT/data/generated/maps.lua" ] \ && [ ! -f "$ROOT/blue/data/generated/maps.lua" ] \ && [ ! -f "$ROOT/yellow/data/generated/maps.lua" ] \ && [ ! -f "$ROOT/gold/data/generated/maps.lua" ] \ - && [ ! -f "$ROOT/silver/data/generated/maps.lua" ]; then + && [ ! -f "$ROOT/silver/data/generated/maps.lua" ] \ + && [ ! -f "$ROOT/crystal/data/generated/maps.lua" ]; then fail "generated data missing, run scripts/setup.sh first" fi diff --git a/scripts/switch/pack_sd_zip.sh b/scripts/switch/pack_sd_zip.sh index 1d0478a9..d6277899 100755 --- a/scripts/switch/pack_sd_zip.sh +++ b/scripts/switch/pack_sd_zip.sh @@ -71,15 +71,15 @@ First install or update (same steps): your saves, imported ROMs, mods, and options. Re-extracting only replaces the NRO(s) and these help files. 3. Launch with title override (hold R on HOME, open any title → hbmenu). - 4. Copy a legal Pokemon Red/Blue .gb or Yellow/Gold/Silver .gbc into: + 4. Copy a legal Pokemon Red/Blue .gb or Yellow/Gold/Silver/Crystal .gbc into: switch/gen1recomp/pokemon-love2d/imports/ then use Scan again in the launcher if needed. Inboxes (drop files here via MTP / SD / FTP): imports/ — ROM .gb / .gbc imports/mods/ — community mod .zip - imports/saves/red|blue|yellow|gold|silver/ — raw .sav import (Gold/Silver cart .sav not yet) - exports/red|blue|yellow|gold|silver/ — pull after Export save (Gold/Silver not yet) + imports/saves/red|blue|yellow|gold|silver|crystal/ - raw .sav import (Gen 2 cart .sav not yet) + exports/red|blue|yellow|gold|silver|crystal/ - pull after Export save (Gen 2 not yet) Full guide: https://github.com/bryanthaboi/gen1recomp/blob/main/docs/switch-install.md EOF @@ -92,7 +92,7 @@ write_readme() { } write_readme "$SAVE_ROOT/imports/README.txt" \ - "Put a legal Pokemon Red/Blue .gb or Yellow/Gold/Silver .gbc here, then Scan again in the launcher." + "Put a legal Pokemon Red/Blue .gb or Yellow/Gold/Silver/Crystal .gbc here, then Scan again in the launcher." write_readme "$SAVE_ROOT/imports/mods/README.txt" \ "Put community mod .zip files here, then MODS → Scan again." write_readme "$SAVE_ROOT/imports/saves/red/README.txt" \ @@ -105,6 +105,8 @@ write_readme "$SAVE_ROOT/imports/saves/gold/README.txt" \ "Gold cart .sav import is not supported yet. Folder reserved so MTP matches the other games." write_readme "$SAVE_ROOT/imports/saves/silver/README.txt" \ "Silver cart .sav import is not supported yet. Folder reserved so MTP matches the other games." +write_readme "$SAVE_ROOT/imports/saves/crystal/README.txt" \ + "Crystal cart .sav import is not supported yet. Folder reserved so MTP matches the other games." write_readme "$SAVE_ROOT/exports/red/README.txt" \ "After Export save (Red), copy the .sav out of this folder via MTP / SD / FTP." write_readme "$SAVE_ROOT/exports/blue/README.txt" \ @@ -115,6 +117,8 @@ write_readme "$SAVE_ROOT/exports/gold/README.txt" \ "Gold cart .sav export is not supported yet. Folder reserved so MTP matches the other games." write_readme "$SAVE_ROOT/exports/silver/README.txt" \ "Silver cart .sav export is not supported yet. Folder reserved so MTP matches the other games." +write_readme "$SAVE_ROOT/exports/crystal/README.txt" \ + "Crystal cart .sav export is not supported yet. Folder reserved so MTP matches the other games." rm -f "$OUT_ZIP" ( @@ -145,11 +149,13 @@ REQUIRED=( "switch/gen1recomp/pokemon-love2d/imports/saves/yellow/README.txt" "switch/gen1recomp/pokemon-love2d/imports/saves/gold/README.txt" "switch/gen1recomp/pokemon-love2d/imports/saves/silver/README.txt" + "switch/gen1recomp/pokemon-love2d/imports/saves/crystal/README.txt" "switch/gen1recomp/pokemon-love2d/exports/red/README.txt" "switch/gen1recomp/pokemon-love2d/exports/blue/README.txt" "switch/gen1recomp/pokemon-love2d/exports/yellow/README.txt" "switch/gen1recomp/pokemon-love2d/exports/gold/README.txt" "switch/gen1recomp/pokemon-love2d/exports/silver/README.txt" + "switch/gen1recomp/pokemon-love2d/exports/crystal/README.txt" ) for rel in "${REQUIRED[@]}"; do printf '%s\n' "$LISTING" | grep -Fq "$rel" || fail "zip missing $rel" diff --git a/scripts/switch/selftest_build_switch.sh b/scripts/switch/selftest_build_switch.sh index 946a1107..88fa4846 100755 --- a/scripts/switch/selftest_build_switch.sh +++ b/scripts/switch/selftest_build_switch.sh @@ -272,11 +272,13 @@ for rel in \ "switch/gen1recomp/pokemon-love2d/imports/saves/yellow/README.txt" \ "switch/gen1recomp/pokemon-love2d/imports/saves/gold/README.txt" \ "switch/gen1recomp/pokemon-love2d/imports/saves/silver/README.txt" \ + "switch/gen1recomp/pokemon-love2d/imports/saves/crystal/README.txt" \ "switch/gen1recomp/pokemon-love2d/exports/red/README.txt" \ "switch/gen1recomp/pokemon-love2d/exports/blue/README.txt" \ "switch/gen1recomp/pokemon-love2d/exports/yellow/README.txt" \ "switch/gen1recomp/pokemon-love2d/exports/gold/README.txt" \ - "switch/gen1recomp/pokemon-love2d/exports/silver/README.txt" + "switch/gen1recomp/pokemon-love2d/exports/silver/README.txt" \ + "switch/gen1recomp/pokemon-love2d/exports/crystal/README.txt" do printf '%s\n' "$ZIP_LIST" | grep -Fq "$rel" || PACK_MISSING="${PACK_MISSING} ${rel}" done diff --git a/scripts/test.sh b/scripts/test.sh index 57be8d0a..71760e19 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -5,8 +5,8 @@ # them fails. The tier split is what makes that possible: T1/T2/T4 need # nothing but the committed fixture dataset, so they run anywhere -- # including CI, which has no ROM. T3 asserts Pokemon Red facts and needs -# data/generated/, so it is skipped automatically when the ROM has never -# been imported rather than failing the run. +# data/generated/ or an imported Red cache, so it is skipped automatically +# when neither exists rather than failing the run. # # scripts/test.sh every tier this checkout can run # scripts/test.sh --quick skip the slow content tier @@ -16,6 +16,13 @@ # # LUA overrides the interpreter (luajit here; CI installs lua5.4 too, but # the engine targets LuaJIT/5.1 semantics so luajit is the default). +# +# POKEPORT_TEST_CACHES points at one LOVE identity holding an imported cache +# per version (red/ blue/ yellow/ gold/ silver/ crystal/); each is exported as +# RED_CACHE .. CRYSTAL_CACHE for the suites that read one. Build it with: +# POKEPORT_IDENTITY=pokeport-test-caches POKEPORT_VERSION= \ +# POKEPORT_IMPORT_ONLY=1 POKEPORT_IMPORT_ROM="" love . +# An explicit RED_CACHE/GOLD_CACHE/... in the environment always wins. set -uo pipefail @@ -32,7 +39,7 @@ for arg in "$@"; do --bless) BLESS=1 ;; --bless-shots) SHOTS=1; BLESS=1 ;; --quick) QUICK=1 ;; - --help|-h) sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + --help|-h) sed -n '2,26p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; *) echo "unknown option: $arg" >&2; exit 2 ;; esac done @@ -49,6 +56,36 @@ fi # save-editor suite pins that to the default identity. SANDBOX_IDENTITY="ci-$$" +# Per-version caches. POKEPORT_TEST_CACHES names one LOVE identity holding an +# imported cache per version (red/ blue/ yellow/ gold/ silver/ crystal/). +CACHE_IDENTITY=${POKEPORT_TEST_CACHE_IDENTITY:-pokeport-test-caches} +if [ -n "${POKEPORT_TEST_CACHES:-}" ]; then + CACHE_ROOT="$POKEPORT_TEST_CACHES" +elif [ -d "$HOME/Library/Application Support/LOVE/$CACHE_IDENTITY" ]; then + CACHE_ROOT="$HOME/Library/Application Support/LOVE/$CACHE_IDENTITY" +else + CACHE_ROOT="$HOME/.local/share/love/$CACHE_IDENTITY" +fi + +# Only a cache with the importer's completion marker is offered: a half-written +# one would fail suites that are meant to self-skip. +adopt_cache() { + local var="$1" dir="$CACHE_ROOT/$2" + [ -z "${!var:-}" ] || return 0 + [ -f "$dir/rom-cache.complete" ] || return 0 + export "$var=$dir" + echo " $var=$dir" +} + +echo "" +echo "-- per-version caches under $CACHE_ROOT" +adopt_cache RED_CACHE red +adopt_cache BLUE_CACHE blue +adopt_cache YELLOW_CACHE yellow +adopt_cache GOLD_CACHE gold +adopt_cache SILVER_CACHE silver +adopt_cache CRYSTAL_CACHE crystal + FAILED=() run_tier() { local label="$1"; shift @@ -77,6 +114,7 @@ fi run_tier "T0 ROM builder version routing" python3 tests/build_rom_data_cli_test.py run_tier "T0 ROM manifest generator pin/overrides" python3 tests/rom_manifest_generator_test.py +run_tier "T0 Crystal manifest + specials coverage" "$LUA" tests/crystal_import_test.lua run_tier "T0 switch CI workflow content gate" "$LUA" tests/switch_ci_workflows_test.lua run_tier "T0 switch transfer docs gate" "$LUA" tests/switch_transfer_docs_test.lua # NX Blue/Yellow asset overlay: ROM-free, must run on every checkout so a @@ -87,6 +125,9 @@ run_tier "T0 NX Yellow/Blue boot (dynamic paths)" "$LUA" tests/engine/nx_yellow_ run_tier "T0 NX Gold cache load (maps.lua prefix)" "$LUA" tests/engine/cache_fs_gold_nx_load_test.lua run_tier "T0 touch-controls pad cursor" "$LUA" tests/engine/touch_controls_pad_cursor_test.lua run_tier "T1/T2 engine invariants + parity gates" "$LUA" tests/run_engine.lua +# Gen 2 / Crystal: ROM-free (own fixtures, or a self-skip on a missing cache), +# so it runs here rather than behind the Red content gate below. +run_tier "T2 Gen 2 / Crystal suites" "$LUA" tests/run_gen2.lua run_tier "T4 mod-SDK" "$LUA" tests/run_modkit.lua run_tier "T4 title checkpoint cold restart" \ bash tests/integration/title_checkpoint_cold_start.sh @@ -133,7 +174,19 @@ run_content_behavior() { return 1 } +# The Red dataset is the source tree's when tools/build_data.py wrote one, and +# otherwise the imported Red cache, through Data:load's POKEPORT_DATA_DIR hook. +HAVE_RED_DATA=0 if [ -f data/generated/maps.lua ]; then + HAVE_RED_DATA=1 +elif [ -n "${RED_CACHE:-}" ] && [ -f "$RED_CACHE/data/generated/maps.lua" ]; then + HAVE_RED_DATA=1 + export POKEPORT_DATA_DIR="$RED_CACHE/data/generated" + echo "" + echo "-- T3 content: reading Red from $POKEPORT_DATA_DIR" +fi + +if [ "$HAVE_RED_DATA" = "1" ]; then if [ "$QUICK" = "1" ]; then echo "" echo "-- T3 content: skipped (--quick)" @@ -163,8 +216,9 @@ if [ -f data/generated/maps.lua ]; then fi else echo "" - echo "-- T3 content + run_link_tests: skipped (no data/generated/ --" - echo " import a ROM to run them; the modded-link cases ran in T4)" + echo "-- T3 content + run_link_tests: skipped (no data/generated/ and no" + echo " RED_CACHE -- import a ROM to run them; the modded-link cases ran" + echo " in T4 and the Gen 2 suites in T2)" fi # ------- golden screenshots: needs love + a display diff --git a/src/battle/AnimPlayer.lua b/src/battle/AnimPlayer.lua index ef2b9b00..7f305432 100644 --- a/src/battle/AnimPlayer.lua +++ b/src/battle/AnimPlayer.lua @@ -348,6 +348,12 @@ local EMITTERS = { SE_PETALS_FALLING = function() return fallingObjectSteps(20, PETAL_TILE, "e4") end, } +-- home/copy2.asm:62 CopyVideoData -- 8 tiles a frame plus the tail frame +local function tilesetLoadFrames(data, ts) + local sheet = data and data.tilesheets and data.tilesheets[ts or 0] + return math.floor((sheet and sheet.tiles or 79) / 8) + 1 +end + -- One OAM entry for tile `t` of a frame block anchored at base coord `bc`, -- with the subanimation transform applied (DrawFrameBlock). local function placeTile(transform, bc, t, tileset) @@ -456,6 +462,11 @@ function AnimPlayer:start(moveId, attackerIsPlayer, opts) local obp0Flip = false for _, row in ipairs(anim.seq) do + -- engine/battle/animations.asm:252 LoadMoveAnimationTiles, before the + -- row's sound and its first frame block (#1653) + if not row.effect then + emit(tilesetLoadFrames(self.data, row.tileset)) + end -- PlayAnimation/PlaySubanimation: each row's sound byte is a move id -- whose MoveSoundTable entry (sfx + pitch/tempo modifiers) plays as -- the row starts (GetMoveSound) diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index c4e50160..300cc345 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -638,6 +638,9 @@ local function newBattle(game) -- wPartyAndBillsPCSavedMenuItem, so entering a battle drops the party -- cursor the field menu has been carrying (src/ui/PartyMenu.lua). #768 game.partyMenuSavedIndex = nil + -- the same run covers wBagSavedMenuItem, and wListScrollOffset follows it + -- (init_battle_variables.asm:7-12) #1732 + game.bagSavedMenuItem, game.bagListScrollOffset = nil, nil self.data = game.data -- ruleset from the merged registry (the requires above are the same -- records on a mod-free boot); an unknown save value falls back to the @@ -1918,6 +1921,8 @@ function BattleState:exit() -- end_of_battle.asm clears wPartyAndBillsPCSavedMenuItem as well, so the -- field party menu comes back on slot 1 after a battle. #768 self.game.partyMenuSavedIndex = nil + -- and wBagSavedMenuItem / wListScrollOffset (end_of_battle.asm:57-62) #1732 + self.game.bagSavedMenuItem, self.game.bagListScrollOffset = nil, nil -- Free this battle's own GPU objects now rather than waiting on a GC -- finalizer: the two full-screen wavy-effect canvases (colorMode) and -- the AnimPlayer's per-instance tilesheet images/quads. The shared @@ -1942,6 +1947,7 @@ local function clearTrapping(battler) battler.trappingTurns = nil battler.trapMove = nil battler.trapDamage = nil + battler.trapHitSfx = nil end -- SendOutMon (core.asm:1733-1735) clears both battle cursors, though the @@ -4176,12 +4182,15 @@ function BattleState:continueTrapping(user, target) -- .MultiturnMoveCheck (core.asm:3554-3566) prints AttackContinuesText -- then jumps to GetPlayerAnimationType, so the trapping move's full -- animation replays each locked turn (same damage, animation shown). - -- Mirror performMove's anim row (BattleState.lua ~1307), gated on the - -- OPTIONS animation toggle. - if user.trapMove and self:animationsOn() then + -- Mirror performMove's anim row (BattleState.lua ~1307), with the + -- applying-attack shake GetPlayerAnimationType picks (core.asm:3159 / + -- :5555 -- a trapping move's effect is nonzero, so type 5 / 2) (#1653). + if user.trapMove then self.nextInsert = (self.nextInsert or 0) + 1 table.insert(self.queue, self.nextInsert, - { anim = user.trapMove, attackerIsPlayer = user.isPlayer }) + { anim = user.trapMove, attackerIsPlayer = user.isPlayer, + hit = { animType = user.isPlayer and 5 or 2, + sfx = user.trapHitSfx } }) end -- the counter can sit at 0 until the END of the turn: the trapping -- bit is only cleared by CheckNumAttacksLeft (core.asm:439/467) @@ -4356,7 +4365,7 @@ function BattleState:awardExp() local levels, gained = Experience.apply(self.data, mon, self.enemy.def, self.enemy.mon.level, self.kind == "trainer", split, traded) - -- Track level-ups for EvolveAfterBattle (OverworldState:afterBattle -> + -- Track level-ups for EvolveAfterBattle (BattleState:finish -> -- Evolution.checkParty). B-cancel leaves the mon at/above threshold; -- without this gate it re-triggers after every later fight (#213). if #levels > 0 then @@ -4733,6 +4742,14 @@ function BattleState:playerMonFainted() -- Exception: the Oak's Lab starter rival (HandlePlayerBlackOut). if not nextMon and self.result ~= "lose" then if self.oppClass == "OPP_RIVAL1" then + -- HandlePlayerBlackOut (core.asm:1139-1146): ClearScreenArea, the pic + -- scroll-in and DelayFrames 40 all run before Rival1WinText (#1721) + self:actNext(function() + self.showEnemyTrainer = self.trainerPic ~= nil + if self.showEnemyTrainer then self:slidePic("foe", 64, 16, 2) end + end) + self.nextInsert = (self.nextInsert or 0) + 1 + table.insert(self.queue, self.nextInsert, { wait = 64 }) local TextBox = require("src.render.TextBox") local raw = (self.data.text and self.data.text._Rival1WinText) or Strings("{RIVAL}: Yeah! Am\nI great or what?") @@ -5322,6 +5339,14 @@ function BattleState:finish() self.phase = "messages" return end + -- EndOfBattle runs EvolutionAfterBattle on the battle screen, before the + -- GBPalWhiteOut back to the map (end_of_battle.asm:42-45) (#1656, #213) + if not self.evolutionsChecked then + self.evolutionsChecked = true + require("src.pokemon.Evolution").checkParty(self.game, + function() self:finish() end, self.leveledUp) + return + end -- Invariant: a battle can never hand the overworld a party with nothing -- healthy in it -- except the Oak's Lab starter rival, where pret skips -- the blackout and OaksLabRivalEndBattleScript HealParty's immediately. @@ -5362,6 +5387,13 @@ function BattleState:finish() local result = self.result or "run" local onFinish = self.onFinish if result == "lose" then + -- .battleOccurred skips the faint check in OAKS_LAB and re-enters the map + -- through MapEntryAfterBattle (home/overworld.asm:343-352) (#1721) + if BattleState.isOaksLabStarterRival(self) then + self.game.stack:push(require("src.render.Transition").battleReturn( + self.game, function() if onFinish then onFinish(result) end end)) + return + end -- the blackout path warps to the heal point with its own transition if onFinish then onFinish(result) end return @@ -6226,8 +6258,10 @@ function BattleState:drawHUDs(slide) self:drawBallRow(self:playerPartyView(), 88, 80, 8) end local hidePlayer = self.safari or self.demo + -- RemoveFaintedPlayerMon clears the player HUD (core.asm:1024-1026) and + -- nothing redraws it until the next SendOutMon (#1721) if showStatus and self.player and not hidePlayer and not self.showPlayerBack - and slide == 0 then + and slide == 0 and not self.player.fainted then -- player HUD (DrawPlayerHUDAndHPBar): name (10,7), +level -- (14,8), HP bar (10,9), HP numbers row 10, underline row 11 with -- the tick at (18,10) and the triangle at (9,11) diff --git a/src/battle/EffectRegistry.lua b/src/battle/EffectRegistry.lua index 525a6c58..999a6637 100644 --- a/src/battle/EffectRegistry.lua +++ b/src/battle/EffectRegistry.lua @@ -260,6 +260,7 @@ function EffectRegistry.runDamaging(battle, ctx, record) local totalDealt = 0 local landed, brokeSub = 0, false + local critPending, ohkoPending = info.crit, info.ohko for h = 1, hits do if target.mon.hp <= 0 then break end local hitRow @@ -280,12 +281,16 @@ function EffectRegistry.runDamaging(battle, ctx, record) totalDealt = totalDealt + dealt landed = h if dealt > 0 then hitRow.hit = hitFx end - -- PrintCriticalOHKOText + DisplayEffectiveness run inside the - -- multi-hit loop (core.asm .moveDidNotMiss before the jump back - -- to GetPlayerAnimationType), so crit/effectiveness reprint on - -- every strike -- damage was only rolled once - if info.crit then battle:sayNext(romText(battle.data, "_CriticalHitText", "Critical hit!")) end - if info.ohko then battle:sayNext(romText(battle.data, "_OHKOText", "One-hit KO!")) end + -- PrintCriticalOHKOText zeroes wCriticalHitOrOHKO after printing + -- (core.asm:3809-3811); DisplayEffectiveness re-reads its own flag (#1720) + if critPending then + battle:sayNext(romText(battle.data, "_CriticalHitText", "Critical hit!")) + critPending = false + end + if ohkoPending then + battle:sayNext(romText(battle.data, "_OHKOText", "One-hit KO!")) + ohkoPending = false + end -- PrintCriticalOHKOText closes with `ld c, 20 / jp DelayFrames` at its -- .done label (core.asm:3812-3814) -- and the no-crit path jumps to that -- same label (:3799), so this hold is paid on EVERY landed hit, not just @@ -323,6 +328,7 @@ function EffectRegistry.runDamaging(battle, ctx, record) -- post-damage effect bookkeeping (recoil/drain/trap/thrash/...) ctx.rawDamage, ctx.totalDealt = dmg, totalDealt ctx.brokeSub, ctx.hits = brokeSub, hits + ctx.hitSfx = hitSfx if record and record.afterDamage then record.afterDamage(ctx, totalDealt) elseif moveInst.struggle then diff --git a/src/battle/MoveEffects.lua b/src/battle/MoveEffects.lua index 2dfdc191..39d96387 100644 --- a/src/battle/MoveEffects.lua +++ b/src/battle/MoveEffects.lua @@ -574,6 +574,8 @@ MoveEffects.full = { local r = ctx.rng(0, 7) user.trappingTurns = ({ 1, 1, 1, 2, 2, 2, 3, 4 })[r + 1] user.trapDamage = ctx.rawDamage + -- PlayApplyingAttackSound (animations.asm:2639) replays it each turn + user.trapHitSfx = ctx.hitSfx -- remember the move so its animation can replay on each locked -- continuation (core.asm:3554-3566 -> GetPlayerAnimationType) user.trapMove = ctx.move.id diff --git a/src/battle/WideBattle.lua b/src/battle/WideBattle.lua index 75df4b6f..ad459574 100644 --- a/src/battle/WideBattle.lua +++ b/src/battle/WideBattle.lua @@ -161,8 +161,10 @@ local function drawHUDs(battle, slide) -- item, not a HUD element (DisplayBattleMenu prints wNumSafariBalls inside -- the battle menu box, engine/battle/core.asm:2074-2079), so it rides in -- drawCommandMenu below like the classic layout's (#540). + -- RemoveFaintedPlayerMon clears the player HUD (core.asm:1024-1026) (#1721) if showStatus and not battle.safari and battle.player and not battle.demo - and not battle.showPlayerBack and slide == 0 then + and not battle.showPlayerBack and slide == 0 + and not battle.player.fainted then drawStatusPanel(battle, battle.player, 184, 56, true) end end diff --git a/src/battle/gen2/Battle.lua b/src/battle/gen2/Battle.lua index a9da9619..2b0b0543 100644 --- a/src/battle/gen2/Battle.lua +++ b/src/battle/gen2/Battle.lua @@ -16,7 +16,7 @@ -- -- Status handling follows Gen 2's rules rather than Gen 1's: burn is 1/8 max HP -- (not 1/16) and halves physical Attack, poison is 1/8, and sleep counts down --- from 1-7 turns. +-- from 2-7 turns. local Damage = require("src.battle.gen2.Damage") local Ai = require("src.battle.gen2.Ai") @@ -166,6 +166,9 @@ Battle.SUBSTATUS_ITEMS = { -- be run from or Roared away. Battle.BATTLETYPE_FORCESHINY = 7 Battle.BATTLETYPE_TRAP = 9 +-- ../pokecrystal/constants/battle_constants.asm:102-103, Crystal-only appends +Battle.BATTLETYPE_CELEBI = 11 +Battle.BATTLETYPE_SUICUNE = 12 -- LostBattle's .canlose arm (engine/battle/core.asm:2766): the only battle -- type whose loss still prints the trainer's own line instead of a whiteout. Battle.BATTLETYPE_CANLOSE = 1 @@ -247,6 +250,13 @@ function Battle.new(opts) -- (BATTLETYPE_FISH is the one condition LureBallMultiplier reads), and the -- FORCESHINY / TRAP no-escape rules will hang off the same field. self.battleType = opts.battleType + -- wInBattleTowerBattle (../pokecrystal/constants/ram_constants.asm:38), set + -- around the Tower's own StartBattle (engine/events/battle_tower/ + -- battle_tower.asm:220-223) and cleared again at :253-254. + self.inBattleTowerBattle = opts.battleTower and true or false + -- wTimeOfDay: only BattleCommand_TimeBasedHealContinue reads it in battle + -- (engine/battle/effect_commands.asm:6401-6404). + self.timeOfDay = opts.timeOfDay self.events = {} self.turn = 0 self.over = false @@ -765,6 +775,9 @@ end function Battle:battleStat(mon, key) local value = (mon.stats or {})[key] or 1 if mon ~= self.player then return value end + -- BadgeStatBoosts' second early return (engine/battle/core.asm:6786-6788): + -- adventure badges do not follow the player into the standardised Tower. + if self.inBattleTowerBattle then return value end local badge = Battle.BADGE_STAT_BOOSTS[key] if badge and self:hasBadge("badges", badge) then return Battle.boostStat(value) @@ -783,6 +796,8 @@ end -- boosts the damage. Each type appears once, so this is a plain scan. function Battle:badgeTypeBoost(attacker, moveType) if attacker ~= self.player or not moveType then return false end + -- DoBadgeTypeBoosts' own tower guard (engine/battle/misc.asm:152-154). + if self.inBattleTowerBattle then return false end for _, row in ipairs(Battle.BADGE_TYPE_BOOSTS) do if row.type == moveType then return self:hasBadge(row.store, row.badge) @@ -2457,7 +2472,7 @@ end -- BattleCommand_TimeBasedHealContinue (effect_commands.asm:6374) answers the -- same two lines BattleCommand_Heal does: HPIsFullText (:6447) and -- RegainedHealthText (:6440). -for effect in pairs(Effects.SUN_HEAL) do +for effect, wants in pairs(Effects.SUN_HEAL) do Battle.MOVE_EFFECTS[effect] = function(self, attacker) local maxHp = attacker.maxHp or (attacker.stats and attacker.stats.hp) or 1 if (attacker.hp or 0) >= maxHp then @@ -2466,7 +2481,9 @@ for effect in pairs(Effects.SUN_HEAL) do text = self:monName(attacker) .. "'s HP is full!" }) return end - local fraction = Effects.weatherHealFraction(self.weather) + -- effect_commands.asm:6396-6417, the time of day and the weather (#1751). + local fraction = Effects.timeBasedHealFraction(self.weather, wants, + self.timeOfDay) self:heal(attacker, math.max(1, math.floor(maxHp * fraction))) self:emit({ kind = "message", text = self:monName(attacker) .. " regained health!" }) @@ -2913,9 +2930,12 @@ Battle.STATUSES = { id = "sleep", label = "SLP", hudLabel = "SLP", healClass = "slp", inflictText = " fell asleep!", catchBonus = 10, catchBonusIntended = 10, - -- BattleCommand_Sleep rolls a 3-bit value, retried until nonzero. + -- BattleCommand_SleepTarget's .random_loop rerolls 0 and SLP_MASK before + -- `inc a`, so sleep opens at 2 (effect_commands.asm:3591-3598, #1707). + -- Crystal masks the roll to %011 in the Battle Tower, capping it at 4 + -- (../pokecrystal/engine/battle/effect_commands.asm:3609-3613). onInflict = function(battle, mon) - mon.statusTurns = rand(battle.random, 7) + 1 + mon.statusTurns = rand(battle.random, battle.inBattleTowerBattle and 3 or 6) + 2 end, beforeMovePriority = 40, beforeMove = function(battle, mon, name) @@ -3965,6 +3985,16 @@ function Battle:usableMoves(mon) return out end +-- ../pokecrystal/engine/battle/core.asm:3687-3694 refuses TRAP, CELEBI, +-- FORCESHINY and SUICUNE; pokegold's :3476-3479 has only the first and third. +function Battle:noEscapeBattleType() + local t = self.battleType + return t == Battle.BATTLETYPE_FORCESHINY + or t == Battle.BATTLETYPE_TRAP + or t == Battle.BATTLETYPE_CELEBI + or t == Battle.BATTLETYPE_SUICUNE +end + -- Running: Gen 2's odds (engine/battle/core.asm TryToRunAwayFromBattle) are -- based on the speed ratio and how many times you have tried this battle. -- Trainers never let you run. @@ -3978,8 +4008,7 @@ function Battle:tryRun(pSpd) -- BATTLETYPE_FORCESHINY jump straight to .cant_escape, ahead of the -- trainer check and any speed math. Without this, running from the Red -- Gyarados returned a WIN to the script and forfeited the one-shot shiny. - if self.battleType == Battle.BATTLETYPE_FORCESHINY - or self.battleType == Battle.BATTLETYPE_TRAP then + if self:noEscapeBattleType() then self:emit({ kind = "message", text = "Can't escape!" }) self.runRefused = true return false diff --git a/src/battle/gen2/Catching.lua b/src/battle/gen2/Catching.lua index 8284add5..625159d4 100644 --- a/src/battle/gen2/Catching.lua +++ b/src/battle/gen2/Catching.lua @@ -25,6 +25,7 @@ -- names src/battle/BattleState.lua raises on Gen 1, with the same argument -- order and the same payload keys (docs/mod-api-gen2-compat.md). local Runtime = require("src.mods.Runtime") +local Mon = require("src.battle.gen2.Mon") local Catching = {} @@ -337,6 +338,47 @@ function Catching.statusBonus(status, opts) return bonuses[status] or 0 end +-- constants/landmark_constants.asm:24, the fallback when no cache is passed. +Catching.LANDMARK_NATIONAL_PARK = 19 + +local function landmarkIndex(opts, id, fallback) + local data = opts and (opts.data or (opts.battle and opts.battle.data)) + local rows = (opts and opts.landmarks) + or (data and data.gen2Landmarks and data.gen2Landmarks.landmarks) + local row = rows and rows[id] + return (row and row.index) or fallback +end + +-- GetWorldMapLocation with the POKECENTER_2F backup-map swap +-- (engine/pokemon/caught_data.asm:177-193) and the Bug Contest override (:73-81). +function Catching.caughtLandmark(opts) + opts = opts or {} + if opts.bugContest then + return landmarkIndex(opts, "LANDMARK_NATIONAL_PARK", + Catching.LANDMARK_NATIONAL_PARK) + end + if opts.landmark then return opts.landmark end + local map = opts.map + if map and map.id == "POKECENTER_2F" and opts.backupMap then + map = opts.backupMap + end + return (map and map.landmark) or 0 +end + +-- SetCaughtData (engine/pokemon/caught_data.asm:163-199); no-op off Crystal. +function Catching.stampCaughtData(mon, opts) + opts = opts or {} + if not Mon.hasCaughtData(opts.version) then return mon end + local save = opts.save or (opts.battle and opts.battle.save) + return Mon.setCaughtData(mon, { + level = opts.level or (type(mon) == "table" and mon.level) or 0, + timeOfDay = opts.timeOfDay, + landmark = Catching.caughtLandmark(opts), + playerGender = opts.playerGender + or (save and save.player and save.player.gender), + }) +end + -- Does the ball catch? Returns caught and the final rate (wFinalCatchRate). -- A rate of 255 or a Master Ball is certain. -- diff --git a/src/battle/gen2/Damage.lua b/src/battle/gen2/Damage.lua index 58a2b112..df97a1d4 100644 --- a/src/battle/gen2/Damage.lua +++ b/src/battle/gen2/Damage.lua @@ -21,8 +21,13 @@ -- per-move as in Gen 4: type ids below FIRE are physical. type_chart.lua's -- records carry that as `category`. +local GameVersion = require("src.core.GameVersion") + local Damage = {} +-- ../pokecrystal/constants/battle_constants.asm:78 +Damage.MAX_STAT_VALUE = 999 + -- data/battle/critical_hit_chances.asm, as "1 in N". Damage.CRITICAL_CHANCES = { [0] = 15, 8, 4, 3, 2, 2, 2 } @@ -60,7 +65,23 @@ end function Damage.applyStage(value, stage) local numerator, denominator = Damage.stageMultiplier(stage) local out = math.floor(value * numerator / denominator) - return math.max(1, out) + -- ../pokecrystal/engine/battle/core.asm:6739 + return math.max(1, math.min(Damage.MAX_STAT_VALUE, out)) +end + +-- TruncateHL_BC: ../pokegold/engine/battle/effect_commands.asm:2625 runs one +-- pass, ../pokecrystal/engine/battle/effect_commands.asm:2644 loops it. +function Damage.truncateStats(attack, defense, fixed) + local a = math.max(0, math.floor(attack or 0)) + local d = math.max(0, math.floor(defense or 0)) + while a > 255 or d > 255 do + d = math.floor(d / 4) + if d == 0 then d = 1 end + a = math.floor(a / 4) + if a == 0 then a = 1 end + if not fixed then break end + end + return a % 256, d % 256 end -- Is this move physical? `types` is type_chart.lua's `types` table. @@ -169,6 +190,8 @@ end -- random(n) -- 0..n-1, for the variation roll -- screen -- Reflect/Light Screen active on the defender -- defenseHalved -- EFFECT_SELFDESTRUCT's srl c +-- reflectOverflowFixed -- override GameVersion.fixes()'s TruncateHL_BC +-- answer; ../pokecrystal/engine/battle/effect_commands.asm:2644 -- -- Returns damage, info where info carries the pieces a battle message needs: -- effectiveness (x10), critical, physical, variation. @@ -204,6 +227,12 @@ function Damage.calc(opts) defense = defense * 2 end + -- PlayerAttackDamage hands DamageCalc one-byte stats + -- (../pokecrystal/engine/battle/effect_commands.asm:2604). + local fixed = opts.reflectOverflowFixed + if fixed == nil then fixed = GameVersion.fixes().reflectOverflow == true end + attack, defense = Damage.truncateStats(attack, defense, fixed) + -- BattleCommand_DamageCalc (effect_commands.asm:2905-2913): Selfdestruct and -- Explosion halve the defence, never below 1. if opts.defenseHalved then defense = math.max(1, math.floor(defense / 2)) end diff --git a/src/battle/gen2/Effects.lua b/src/battle/gen2/Effects.lua index 00dc8c13..a36f8ea3 100644 --- a/src/battle/gen2/Effects.lua +++ b/src/battle/gen2/Effects.lua @@ -267,7 +267,7 @@ Effects.MAGNITUDE_POWER = { -- power and the magnitude number the text prints. -- -- `random` is BattleRandom (0..n-1). If none is supplied, roll via love.math --- / math.random — never hard-code 0 (that always yields Magnitude 4). +-- / math.random -- never hard-code 0 (that always yields Magnitude 4). function Effects.magnitudePower(random) local roll if type(random) == "function" then @@ -356,19 +356,38 @@ function Effects.sandstormHits(types) return true end --- Morning Sun / Synthesis / Moonlight heal a HALF normally, and the weather --- shifts that one step either way: x2 in sun, /2 in rain or sandstorm --- (BattleCommand_Heal's .Weather block walks a multiplier index). +-- BattleCommand_TimeBasedHealContinue's .Multipliers +-- (engine/battle/effect_commands.asm:6450-6454). +Effects.HEAL_MULTIPLIERS = { 1 / 8, 1 / 4, 1 / 2, 1 } + +-- wTimeOfDay (constants/ram_constants.asm:134-139): MORN_F 0, DAY_F 1, +-- NITE_F 2, DARKNESS_F 3. +Effects.TIME_OF_DAY_ID = { MORN = 0, DAY = 1, NITE = 2, DARK = 3 } + +-- Morning Sun / Synthesis / Moonlight and the wTimeOfDay each one wants +-- (engine/battle/effect_commands.asm:6362-6371). Effects.SUN_HEAL = { - EFFECT_MORNING_SUN = true, - EFFECT_SYNTHESIS = true, - EFFECT_MOONLIGHT = true, + EFFECT_MORNING_SUN = 0, + EFFECT_SYNTHESIS = 1, + EFFECT_MOONLIGHT = 2, } -function Effects.weatherHealFraction(weather) - if weather == "sun" then return 2 / 3 end - if weather == "rain" or weather == "sandstorm" then return 1 / 4 end - return 1 / 2 +function Effects.timeOfDayIndex(timeOfDay) + if type(timeOfDay) == "number" then return math.floor(timeOfDay) end + return Effects.TIME_OF_DAY_ID[timeOfDay] +end + +-- engine/battle/effect_commands.asm:6388-6417: the index opens at a half, the +-- wrong time of day steps it down, sun steps it up, rain and sandstorm down. +function Effects.timeBasedHealFraction(weather, wants, timeOfDay) + local index = 3 + local now = Effects.timeOfDayIndex(timeOfDay) + if wants ~= nil and now ~= nil and now ~= wants then index = index - 1 end + if weather then + index = index + 1 + if weather ~= "sun" then index = index - 2 end + end + return Effects.HEAL_MULTIPLIERS[math.max(1, math.min(4, index))] end -- ---------------------------------------------------------------- Perish Song diff --git a/src/battle/gen2/Mon.lua b/src/battle/gen2/Mon.lua index 5f515567..ca7d363e 100644 --- a/src/battle/gen2/Mon.lua +++ b/src/battle/gen2/Mon.lua @@ -17,6 +17,7 @@ -- with a sign bit on the n^2 term. pokemon.lua carries those five numbers per -- GROWTH_* so this needs no hardcoded table. +local GameVersion = require("src.core.GameVersion") local Unown = require("src.core.gen2.Unown") -- The mod event bus. pokemon.level_up and pokemon.move_learned are the SAME -- names src/battle/Experience.lua and src/battle/BattleState.lua raise on @@ -32,6 +33,102 @@ Mon.PARTY_SIZE = 6 -- DVs are 0..15 each; Attack's low bit pair also decides gender and shininess. Mon.MAX_DV = 15 +-- MON_CAUGHTDATA's two packed bytes and their masks -- +-- constants/pokemon_data_constants.asm:93-99, :120-130. +Mon.CAUGHT_TIME_MASK = 0xc0 +Mon.CAUGHT_LEVEL_MASK = 0x3f +Mon.CAUGHT_GENDER_MASK = 0x80 +Mon.CAUGHT_LOCATION_MASK = 0x7f +Mon.CAUGHT_EGG_LEVEL = 1 +-- constants/landmark_constants.asm:111-113 +Mon.LANDMARK_EVENT = 0x7f +Mon.LANDMARK_GIFT = 0x7e + +-- Gold spends the same word on `rb_skip 2` and has no SetCaughtData -- +-- pokegold constants/pokemon_data_constants.asm:93. +function Mon.hasCaughtData(version) + return GameVersion.engine(version) == "crystal" +end + +-- wTimeOfDay is only MORN / DAY / NITE (engine/rtc/rtc.asm:48-55), stored +-- `inc a`'d so 0 stays free -- engine/pokemon/caught_data.asm:169-172. +local CAUGHT_TIME = { MORN = 1, DAY = 2, NITE = 3, DARK = 3 } + +function Mon.caughtTimeOf(timeOfDay) + if type(timeOfDay) == "string" then return CAUGHT_TIME[timeOfDay] or 0 end + if type(timeOfDay) ~= "number" then return 0 end + local id = math.floor(timeOfDay) + if id < 0 or id > 3 then return 0 end + if id > 2 then id = 2 end + return id + 1 +end + +-- wPlayerGender's bit 0 is PLAYERGENDER_FEMALE_F (constants/ram_constants.asm:177). +local CAUGHT_GENDER = { + girl = "girl", female = "girl", boy = "boy", male = "boy", +} + +function Mon.caughtGenderOf(gender) + if gender == true then return "girl" end + if gender == false then return "boy" end + if type(gender) ~= "string" then return nil end + return CAUGHT_GENDER[gender:lower()] +end + +local function landmarkByte(landmark) + if type(landmark) ~= "number" then return 0 end + return math.floor(landmark) % 0x80 +end + +-- SetBoxmonOrEggmonCaughtData (engine/pokemon/caught_data.asm:168-199); the +-- egg path hands CAUGHT_EGG_LEVEL in as `level` (:239-242). +function Mon.setCaughtData(mon, opts) + if type(mon) ~= "table" then return mon end + opts = opts or {} + mon.caughtTime = Mon.caughtTimeOf(opts.timeOfDay) + mon.caughtLevel = math.max(0, math.floor(opts.level or mon.level or 0)) + mon.caughtLocation = landmarkByte(opts.landmark) + mon.caughtByGender = Mon.caughtGenderOf(opts.playerGender) or "boy" + return mon +end + +-- CAUGHT_BY_UNKNOWN / GIRL / BOY, the code SetGiftMonCaughtData takes in `b` +-- (constants/pokemon_data_constants.asm:126-128). +Mon.CAUGHT_BY = { unknown = 0, girl = 1, boy = 2 } + +-- SetGiftMonCaughtData (engine/pokemon/caught_data.asm:226-233): `rrc b / or +-- LANDMARK_GIFT` puts CAUGHT_BY_BOY on $7f, LANDMARK_EVENT, not on a gender bit. +function Mon.setGiftCaughtData(mon, caughtBy) + if type(mon) ~= "table" then return mon end + local code = Mon.CAUGHT_BY[tostring(caughtBy):lower()] or 0 + local rotated = math.floor(code / 2) + (code % 2) * Mon.CAUGHT_GENDER_MASK + local unpacked = Mon.unpackCaughtData(0, Mon.LANDMARK_GIFT + rotated) + mon.caughtTime, mon.caughtLevel = 0, 0 + mon.caughtLocation = unpacked.caughtLocation + mon.caughtByGender = unpacked.caughtByGender + return mon +end + +-- The packed pair, as engine/pokemon/caught_data.asm:169-199 stores it. +function Mon.packCaughtData(mon) + mon = type(mon) == "table" and mon or {} + local time = math.floor(tonumber(mon.caughtTime) or 0) % 4 + local level = math.floor(tonumber(mon.caughtLevel) or 0) % 0x40 + local location = landmarkByte(tonumber(mon.caughtLocation) or 0) + local female = Mon.caughtGenderOf(mon.caughtByGender) == "girl" + return time * 0x40 + level, (female and Mon.CAUGHT_GENDER_MASK or 0) + location +end + +function Mon.unpackCaughtData(byte0, byte1) + byte0, byte1 = math.floor(byte0 or 0) % 256, math.floor(byte1 or 0) % 256 + return { + caughtTime = math.floor(byte0 / 0x40), + caughtLevel = byte0 % 0x40, + caughtLocation = byte1 % 0x80, + caughtByGender = (byte1 >= Mon.CAUGHT_GENDER_MASK) and "girl" or "boy", + } +end + local function rand(a, b) if love and love.math and love.math.random then return love.math.random(a, b) @@ -362,7 +459,11 @@ function Mon.new(data, species, level, opts) status = nil, -- 70 for a caught mon, 120 for a gift/hatched one. happiness = opts.happiness or 70, - caughtLevel = level, + caughtLevel = opts.caughtLevel or level, + -- MON_CAUGHTDATA, absent on Gold -- constants/pokemon_data_constants.asm:93-99. + caughtTime = opts.caughtTime, + caughtLocation = opts.caughtLocation, + caughtByGender = opts.caughtByGender, -- shiny.roll / gender.roll get the species and level as context; opts.shiny -- still wins, because a FORCED shiny battle (Red Gyarados) is the cart -- overriding the roll rather than a roll to be hooked. diff --git a/src/carts/CartManifest.lua b/src/carts/CartManifest.lua new file mode 100644 index 00000000..40c91d5b --- /dev/null +++ b/src/carts/CartManifest.lua @@ -0,0 +1,501 @@ +local Base64 = require("src.core.Base64") +local GameVersion = require("src.core.GameVersion") +local SafePath = require("src.mods.SafePath") +local SaveSerializer = require("src.core.SaveSerializer") +local Semver = require("src.mods.Semver") +local StreamMD5 = require("src.mods.StreamMD5") + +local CartManifest = {} + +CartManifest.SCHEMA = 1 +CartManifest.EXT = ".g1rcart" +CartManifest.FORMAT = "g1rcart" +CartManifest.DIR = "carts" +-- sealed: the pinned set runs exactly as pinned. sealed+: the same fixed set, +-- but the player may switch any pinned mod on or off. open: additive. +CartManifest.SEALS = { sealed = true, ["sealed+"] = true, open = true } +-- Shell and label finishes the launcher cartridge can render. +CartManifest.FINISHES = { + sparkle = true, holo = true, ["sparkle+holo"] = true, +} +CartManifest.SOURCES = { github = true, gamebanana = true, ["local"] = true } +CartManifest.ART_ENCODINGS = { base64 = true } +CartManifest.PNG_SIGNATURE = "\137PNG\r\n\26\10" + +CartManifest.MAX_ID = 64 +CartManifest.MAX_TITLE = 48 +CartManifest.MAX_AUTHOR = 64 +CartManifest.MAX_SUMMARY = 120 +CartManifest.MAX_LABEL = 128 +CartManifest.MAX_LABEL_ART = 1024 * 1024 +CartManifest.MAX_MODS = 64 +CartManifest.MAX_OPTIONS = 64 +CartManifest.MAX_OPTION_KEY = 64 +CartManifest.MAX_OPTION_TEXT = 256 + +local function trim(text) + return text:match("^%s*(.-)%s*$") +end + +local function isId(value) + return type(value) == "string" and value ~= "" and #value <= CartManifest.MAX_ID + and value:match("^[%w_%-]+$") ~= nil +end + +local function isRepo(value) + if type(value) ~= "string" then return false end + local owner, name = value:match("^([%w%._%-]+)/([%w%._%-]+)$") + return owner ~= nil and name ~= nil +end + +local function isHex(value, width) + return type(value) == "string" and #value == width + and value:match("^[0-9a-f]+$") ~= nil +end + +local function isCount(value) + return type(value) == "number" and value > 0 and value % 1 == 0 +end + +local function isSemver(value) + return type(value) == "string" and Semver.parse(value) ~= nil +end + +local function parseOptions(raw, label) + if raw == nil then return nil end + if type(raw) ~= "table" then + return nil, label .. " options must be a table" + end + local keys = {} + for key in pairs(raw) do + if type(key) ~= "string" then + return nil, label .. " option keys must be strings" + end + if key == "" or #key > CartManifest.MAX_OPTION_KEY then + return nil, ("%s option keys must be 1 to %d characters") + :format(label, CartManifest.MAX_OPTION_KEY) + end + keys[#keys + 1] = key + end + if #keys > CartManifest.MAX_OPTIONS then + return nil, ("%s carries more than %d options") + :format(label, CartManifest.MAX_OPTIONS) + end + local out = {} + for _, key in ipairs(keys) do + local value = raw[key] + local kind = type(value) + if kind == "string" then + if #value > CartManifest.MAX_OPTION_TEXT then + return nil, ("%s option %q must be %d characters or fewer") + :format(label, key, CartManifest.MAX_OPTION_TEXT) + end + elseif kind ~= "number" and kind ~= "boolean" then + return nil, ("%s option %q must be a string, number or boolean") + :format(label, key) + end + out[key] = value + end + return out +end + +local function parseMod(raw, index, seen) + local label = ("cart mod #%d"):format(index) + if type(raw) ~= "table" then return nil, label .. " must be a table" end + if not isId(raw.id) then + return nil, ("%s id must be 1 to %d characters of letters, numbers, _ or -") + :format(label, CartManifest.MAX_ID) + end + label = ("cart mod %q"):format(raw.id) + if seen[raw.id] then return nil, label .. " is pinned twice" end + seen[raw.id] = true + + local source = raw.source + if type(source) ~= "string" or not CartManifest.SOURCES[source] then + return nil, label .. " source must be github, gamebanana or local" + end + + local entry = { id = raw.id, source = source } + if source == "github" then + if not isRepo(raw.repo) then + return nil, label .. " repo must be owner/name" + end + if not isSemver(raw.version) then + return nil, label .. " version must be a semantic version" + end + if not isHex(raw.sha256, 64) then + return nil, label .. " sha256 must be 64 lowercase hex characters" + end + entry.repo = raw.repo + entry.version = trim(raw.version) + entry.sha256 = raw.sha256 + elseif source == "local" then + if not isSemver(raw.version) then + return nil, label .. " version must be a semantic version" + end + entry.version = trim(raw.version) + else + if not isCount(raw.mod) then + return nil, label .. " mod must be a positive integer" + end + if not isCount(raw.file) then + return nil, label .. " file must be a positive integer" + end + if not isHex(raw.md5, 32) then + return nil, label .. " md5 must be 32 lowercase hex characters" + end + entry.mod = raw.mod + entry.file = raw.file + entry.md5 = raw.md5 + end + + if raw.enabled ~= nil and type(raw.enabled) ~= "boolean" then + return nil, label .. " enabled must be true or false" + end + -- Only a declared OFF is carried: an absent or explicit true is the default, + -- so a cart that says nothing serializes exactly as it did before this field. + if raw.enabled == false then entry.enabled = false end + + local options, err = parseOptions(raw.options, label) + if err then return nil, err end + entry.options = options + return entry +end + +-- A pin the cart ships switched off is installed and configured but not run. +function CartManifest.modEnabled(entry) + return type(entry) == "table" and entry.enabled ~= false +end + +local function parseOrder(raw, mods) + local out = {} + if raw == nil then + for i, entry in ipairs(mods) do out[i] = entry.id end + return out + end + if type(raw) ~= "table" then return nil, "cart load_order must be an array" end + if #raw ~= #mods then + return nil, "cart load_order must list every pinned mod exactly once" + end + local pinned, seen = {}, {} + for _, entry in ipairs(mods) do pinned[entry.id] = true end + for i = 1, #raw do + local id = raw[i] + if type(id) ~= "string" or not pinned[id] then + return nil, ("cart load_order names %s, which the cart does not pin") + :format(tostring(id)) + end + if seen[id] then + return nil, ("cart load_order names %s twice"):format(id) + end + seen[id] = true + out[i] = id + end + return out +end + +function CartManifest.parse(tbl) + if type(tbl) ~= "table" then return nil, "cart must be a table" end + + if not isId(tbl.id) then + return nil, ("cart id must be 1 to %d characters of letters, numbers, _ or -") + :format(CartManifest.MAX_ID) + end + + if type(tbl.title) ~= "string" then return nil, "cart title is required" end + local title = trim(tbl.title) + if title == "" or #title > CartManifest.MAX_TITLE then + return nil, ("cart title must be 1 to %d characters"):format(CartManifest.MAX_TITLE) + end + + if not isSemver(tbl.version) then + return nil, "cart version must be a semantic version" + end + + if type(tbl.author) ~= "string" then return nil, "cart author is required" end + local author = trim(tbl.author) + if author == "" or #author > CartManifest.MAX_AUTHOR then + return nil, ("cart author must be 1 to %d characters"):format(CartManifest.MAX_AUTHOR) + end + + local repo = nil + if tbl.repo ~= nil then + if not isRepo(tbl.repo) then return nil, "cart repo must be owner/name" end + repo = tbl.repo + end + + local summary = nil + if tbl.summary ~= nil then + if type(tbl.summary) ~= "string" then + return nil, "cart summary must be a string" + end + summary = trim(tbl.summary) + if #summary > CartManifest.MAX_SUMMARY then + return nil, ("cart summary must be %d characters or fewer") + :format(CartManifest.MAX_SUMMARY) + end + end + + local shell = type(tbl.shell) == "string" and tbl.shell:match("^#(%x%x%x%x%x%x)$") + if not shell then return nil, "cart shell must be a #RRGGBB colour" end + shell = "#" .. shell:lower() + + local label = nil + if tbl.label ~= nil then + if type(tbl.label) ~= "string" or #tbl.label > CartManifest.MAX_LABEL then + return nil, ("cart label must be a path of %d characters or fewer") + :format(CartManifest.MAX_LABEL) + end + label = SafePath.safe(tbl.label) + if not label then return nil, "cart label must stay inside the cart" end + end + + if type(tbl.base) ~= "string" or not GameVersion.VERSIONS[tbl.base] then + return nil, "cart base must name a game this engine knows" + end + + local engine = nil + if tbl.engine ~= nil then + if type(tbl.engine) ~= "string" or trim(tbl.engine) == "" then + return nil, "cart engine must be a non-empty version range" + end + engine = trim(tbl.engine) + end + + local speeds + if tbl.speeds ~= nil then + if type(tbl.speeds) ~= "table" or #tbl.speeds == 0 then + return nil, "cart speeds must be a non-empty array of multipliers" + end + local GameSpeed = require("src.core.GameSpeed") + local valid, seen = {}, {} + for _, want in ipairs(GameSpeed.LEVELS) do + for _, have in ipairs(tbl.speeds) do + if have == want and not seen[want] then + seen[want] = true + valid[#valid + 1] = want + end + end + end + if #valid ~= #tbl.speeds then + return nil, "cart speeds must all be GameSpeed levels" + end + speeds = valid + end + + local finish = tbl.finish + if finish ~= nil then + if type(finish) ~= "string" or not CartManifest.FINISHES[finish] then + return nil, "cart finish must be sparkle, holo or sparkle+holo" + end + end + + local seal = tbl.seal + if seal == nil then seal = "sealed" end + if type(seal) ~= "string" or not CartManifest.SEALS[seal] then + return nil, "cart seal must be sealed, sealed+ or open" + end + + -- The author's preferred game settings, seeded once per cart + -- (SaveData.CART_OPTION_KEYS); unknown keys are kept but ignored. + local cartOptions, cartOptErr = parseOptions(tbl.options, "cart") + if cartOptErr then return nil, cartOptErr end + + if type(tbl.mods) ~= "table" then return nil, "cart mods must be an array" end + local count = #tbl.mods + if count < 1 or count > CartManifest.MAX_MODS then + return nil, ("cart must pin 1 to %d mods"):format(CartManifest.MAX_MODS) + end + local mods, seen = {}, {} + for i = 1, count do + local entry, err = parseMod(tbl.mods[i], i, seen) + if not entry then return nil, err end + mods[i] = entry + end + + local order, orderErr = parseOrder(tbl.load_order, mods) + if not order then return nil, orderErr end + + return { + id = tbl.id, + title = title, + version = trim(tbl.version), + author = author, + repo = repo, + summary = summary, + shell = shell, + finish = finish, + speeds = speeds, + label = label, + base = tbl.base, + engine = engine, + seal = seal, + options = cartOptions, + mods = mods, + load_order = order, + } +end + +function CartManifest.parseLabelArt(raw) + if raw == nil then return nil, "cart carries no label art" end + if type(raw) ~= "table" then return nil, "cart label art must be a table" end + + if type(raw.encoding) ~= "string" or not CartManifest.ART_ENCODINGS[raw.encoding] then + return nil, "cart label art encoding must be base64" + end + if type(raw.data) ~= "string" or raw.data == "" then + return nil, "cart label art data must be a base64 string" + end + + local tooBig = ("cart label art must be %d bytes or fewer") + :format(CartManifest.MAX_LABEL_ART) + if #raw.data > math.ceil(CartManifest.MAX_LABEL_ART / 3) * 4 then + return nil, tooBig + end + + local bytes, err = Base64.decode(raw.data) + if not bytes then return nil, "cart label art " .. err end + if #bytes > CartManifest.MAX_LABEL_ART then return nil, tooBig end + if not isCount(raw.bytes) or raw.bytes ~= #bytes then + return nil, ("cart label art declares %s bytes but decodes to %d") + :format(tostring(raw.bytes), #bytes) + end + if bytes:sub(1, #CartManifest.PNG_SIGNATURE) ~= CartManifest.PNG_SIGNATURE then + return nil, "cart label art must be a PNG" + end + + local name = nil + if raw.name ~= nil then + if type(raw.name) ~= "string" or #raw.name > CartManifest.MAX_LABEL then + return nil, ("cart label art name must be a path of %d characters or fewer") + :format(CartManifest.MAX_LABEL) + end + name = SafePath.safe(raw.name) + if not name then return nil, "cart label art name must stay inside the cart" end + end + + return { name = name, encoding = raw.encoding, bytes = raw.bytes, data = raw.data }, + nil, bytes +end + +function CartManifest.labelArtBytes(cart) + if type(cart) ~= "table" then return nil, "cart must be a table" end + local art, err, bytes = CartManifest.parseLabelArt(cart.labelArt) + if not art then return nil, err end + return bytes, art.name +end + +function CartManifest.publishable(cart) + if type(cart) ~= "table" or type(cart.mods) ~= "table" then + return false, "a cart must be parsed before it can be published" + end + local unpinned = {} + for _, entry in ipairs(cart.mods) do + if type(entry) == "table" and entry.source == "local" then + unpinned[#unpinned + 1] = tostring(entry.id) + end + end + if #unpinned == 0 then return true end + table.sort(unpinned) + return false, ("%s %s pinned to this install only, so nobody else can fetch %s: publish needs a repo and an archive hash for %s") + :format(table.concat(unpinned, ", "), + #unpinned == 1 and "is" or "are", + #unpinned == 1 and "it" or "them", + #unpinned == 1 and "it" or "each") +end + +local function number(value) + return ("%.17g"):format(value) +end + +local function writeText(out, prefix, text) + out[#out + 1] = ("%s%d:%s"):format(prefix, #text, text) +end + +local function writeValue(out, value) + local kind = type(value) + if kind == "number" then + out[#out + 1] = "#" .. number(value) + elseif kind == "boolean" then + out[#out + 1] = value and "T" or "F" + elseif kind == "table" then + -- Length-prefixed so a list can never collide with another field's text. + out[#out + 1] = "*" .. tostring(#value) + for _, item in ipairs(value) do writeValue(out, item) end + else + writeText(out, "$", tostring(value)) + end +end + +local function writeField(out, name, value) + if value == nil then return end + writeText(out, ".", name) + writeValue(out, value) +end + +local CART_FIELDS = { "author", "base", "engine", "finish", "id", "label", + "repo", "seal", "shell", "speeds", "summary", "title", + "version" } +local MOD_FIELDS = { "enabled", "file", "id", "md5", "mod", "repo", "sha256", + "source", "version" } + +local function writeOptions(out, marker, options) + out[#out + 1] = marker + local keys = {} + for key in pairs(options or {}) do keys[#keys + 1] = key end + table.sort(keys) + for _, key in ipairs(keys) do writeField(out, key, options[key]) end +end + +function CartManifest.canonical(cart) + local out = { "[cart]" } + for _, field in ipairs(CART_FIELDS) do writeField(out, field, cart[field]) end + -- Absent when the cart ships no settings, so a cart written before this + -- field hashes byte for byte as it did. + if cart.options ~= nil then writeOptions(out, "[cart_options]", cart.options) end + out[#out + 1] = "[mods]" + for _, entry in ipairs(cart.mods or {}) do + writeText(out, "@", tostring(entry.id)) + for _, field in ipairs(MOD_FIELDS) do writeField(out, field, entry[field]) end + writeOptions(out, "[options]", entry.options) + end + out[#out + 1] = "[order]" + for _, id in ipairs(cart.load_order or {}) do writeText(out, "@", tostring(id)) end + return table.concat(out) +end + +function CartManifest.hash(cart) + return StreamMD5.new():update(CartManifest.canonical(cart)):final() +end + +function CartManifest.encode(cart) + return SaveSerializer.encode({ + format = CartManifest.FORMAT, + formatVersion = CartManifest.SCHEMA, + labelArt = CartManifest.parseLabelArt(cart.labelArt), + -- Every field parse() keeps: a name missing here is a field the file + -- round trip silently drops, as finish and speeds were. + cart = { id = cart.id, title = cart.title, version = cart.version, + author = cart.author, repo = cart.repo, summary = cart.summary, + shell = cart.shell, finish = cart.finish, speeds = cart.speeds, + label = cart.label, base = cart.base, + engine = cart.engine, seal = cart.seal, options = cart.options, + mods = cart.mods, load_order = cart.load_order }, + }) +end + +function CartManifest.decode(str) + if type(str) ~= "string" or str == "" then return nil, "EMPTY FILE" end + local data = SaveSerializer.decode(str) + if type(data) ~= "table" then return nil, "BAD FILE" end + if data.format ~= CartManifest.FORMAT then return nil, "NOT A CART" end + if data.formatVersion ~= CartManifest.SCHEMA then + return nil, ("unknown cart schema %s"):format(tostring(data.formatVersion)) + end + local cart, err = CartManifest.parse(data.cart) + if not cart then return nil, err end + cart.labelArt = CartManifest.parseLabelArt(data.labelArt) + return cart +end + +return CartManifest diff --git a/src/carts/CartStore.lua b/src/carts/CartStore.lua new file mode 100644 index 00000000..7d2c1d81 --- /dev/null +++ b/src/carts/CartStore.lua @@ -0,0 +1,355 @@ +local CartManifest = require("src.carts.CartManifest") +local SaveData = require("src.core.SaveData") +local Semver = require("src.mods.Semver") + +local CartStore = {} + +CartStore.DIR = CartManifest.DIR +CartStore.EXT = CartManifest.EXT +CartStore.OPTIONS_KEY = "carts" +CartStore.UNPINNED_VERSION = "0.0.0" + +local RECORD_FIELDS = { "id", "title", "version", "author", "base", "seal", + "shell", "finish", "speeds", "summary", "hash", "file" } + +local function fsOr(fs) + return fs or (love and love.filesystem) or nil +end + +local function safeId(id) + return type(id) == "string" and id ~= "" and #id <= CartManifest.MAX_ID + and id:match("^[%w_%-]+$") ~= nil +end + +local function fileFor(id) + return CartStore.DIR .. "/" .. id .. CartStore.EXT +end +CartStore.fileFor = fileFor + +local function readOptions(fs) + local ok, opts = pcall(SaveData.loadOptions, fs) + if not ok or type(opts) ~= "table" then return {} end + return opts +end + +local function writeOptions(opts, fs) + local ok = pcall(SaveData.saveOptions, opts, fs) + return ok and true or false +end + +local function registry(opts) + local reg = opts[CartStore.OPTIONS_KEY] + return type(reg) == "table" and reg or nil +end + +local function ensureRegistry(opts) + if type(opts[CartStore.OPTIONS_KEY]) ~= "table" then + opts[CartStore.OPTIONS_KEY] = {} + end + return opts[CartStore.OPTIONS_KEY] +end + +local function recordFor(cart, hash) + return { id = cart.id, title = cart.title, version = cart.version, + author = cart.author, base = cart.base, seal = cart.seal, + shell = cart.shell, finish = cart.finish, speeds = cart.speeds, + summary = cart.summary, + hash = hash, file = fileFor(cart.id) } +end + +local function sameValue(a, b) + if type(a) == "table" and type(b) == "table" then + if #a ~= #b then return false end + for i = 1, #a do + if a[i] ~= b[i] then return false end + end + return true + end + return a == b +end + +local function sameRecord(a, b) + if type(a) ~= "table" or type(b) ~= "table" then return false end + for _, field in ipairs(RECORD_FIELDS) do + if not sameValue(a[field], b[field]) then return false end + end + return true +end + +local function entryFor(cart, hash) + return { id = cart.id, title = cart.title, version = cart.version, + author = cart.author, base = cart.base, seal = cart.seal, + shell = cart.shell, finish = cart.finish, speeds = cart.speeds, + summary = cart.summary, label = cart.label, + cart = cart, cartHash = hash, file = fileFor(cart.id) } +end + +local function readCart(fs, id) + if not safeId(id) then return nil, "unknown cart id" end + if not (fs and fs.read) then return nil, "NO FILESYSTEM" end + local path = fileFor(id) + if fs.getInfo and not fs.getInfo(path) then + return nil, ("cart %q is not installed"):format(id) + end + local body = fs.read(path) + if type(body) ~= "string" or body == "" then + return nil, ("cart %q is not installed"):format(id) + end + local ok, cart, err = pcall(CartManifest.decode, body) + if not ok then return nil, "BAD CART" end + if not cart then return nil, err or "BAD CART" end + if cart.id ~= id then + return nil, ("cart file %s names %q"):format(path, tostring(cart.id)) + end + local hashed, hash = pcall(CartManifest.hash, cart) + if not hashed then return nil, "BAD CART" end + return cart, hash +end + +local function strayIds(fs, seen) + local out = {} + if not (fs and fs.getDirectoryItems) then return out end + if fs.getInfo and not fs.getInfo(CartStore.DIR) then return out end + local ok, items = pcall(fs.getDirectoryItems, CartStore.DIR) + if not ok then return out end + for _, name in ipairs(items or {}) do + if type(name) == "string" and name:sub(-#CartStore.EXT) == CartStore.EXT then + local id = name:sub(1, #name - #CartStore.EXT) + if safeId(id) and not seen[id] then + seen[id] = true + out[#out + 1] = id + end + end + end + table.sort(out) + return out +end + +function CartStore.index(fs) + local opts = readOptions(fsOr(fs)) + local reg = registry(opts) or {} + local out = {} + for id, record in pairs(reg) do + if safeId(id) and type(record) == "table" then + local row = { id = id, file = fileFor(id) } + for _, field in ipairs(RECORD_FIELDS) do + if record[field] ~= nil then row[field] = record[field] end + end + row.id, row.title = id, record.title or id + out[#out + 1] = row + end + end + table.sort(out, function(a, b) + local at, bt = tostring(a.title):lower(), tostring(b.title):lower() + if at ~= bt then return at < bt end + return a.id < b.id + end) + return out +end + +function CartStore.list(fs) + fs = fsOr(fs) + local rows = {} + if not fs then return rows end + local opts = readOptions(fs) + local reg = registry(opts) or {} + local ids, seen = {}, {} + for id in pairs(reg) do + if safeId(id) and not seen[id] then + seen[id] = true + ids[#ids + 1] = id + end + end + table.sort(ids) + for _, id in ipairs(strayIds(fs, seen)) do ids[#ids + 1] = id end + for _, id in ipairs(ids) do + local cart, hash = readCart(fs, id) + if cart then rows[#rows + 1] = entryFor(cart, hash) end + end + table.sort(rows, function(a, b) + local at, bt = tostring(a.title):lower(), tostring(b.title):lower() + if at ~= bt then return at < bt end + return a.id < b.id + end) + local healed, changed = {}, false + for _, row in ipairs(rows) do + healed[row.id] = recordFor(row.cart, row.cartHash) + if not sameRecord(reg[row.id], healed[row.id]) then changed = true end + end + for id in pairs(reg) do + if healed[id] == nil then changed = true end + end + if changed then + opts[CartStore.OPTIONS_KEY] = healed + writeOptions(opts, fs) + end + return rows +end + +function CartStore.listFor(version, fs) + local out = {} + for _, row in ipairs(CartStore.list(fs)) do + if row.base == version then out[#out + 1] = row end + end + return out +end + +function CartStore.get(id, fs) + return readCart(fsOr(fs), id) +end + +function CartStore.labelArt(id, fs) + local cart, err = readCart(fsOr(fs), id) + if not cart then return nil, err end + return CartManifest.labelArtBytes(cart) +end + +function CartStore.export(id, fs) + local cart, err = readCart(fsOr(fs), id) + if not cart then return nil, err end + return CartManifest.encode(cart), CartManifest.hash(cart) +end + +function CartStore.install(bytes, fs) + fs = fsOr(fs) + if not (fs and fs.write) then return nil, "NO FILESYSTEM" end + local ok, cart, err = pcall(CartManifest.decode, bytes) + if not ok then return nil, "BAD CART" end + if not cart then return nil, err or "BAD CART" end + local existing = readCart(fs, cart.id) + if existing then + local order = Semver.compare(cart.version, existing.version) + if order and order < 0 then + return nil, ("%s %s is older than the installed %s") + :format(cart.title, cart.version, existing.version) + end + end + if fs.createDirectory then fs.createDirectory(CartStore.DIR) end + local wrote, writeErr = fs.write(fileFor(cart.id), CartManifest.encode(cart)) + if not wrote then return nil, tostring(writeErr) end + local hash = CartManifest.hash(cart) + local opts = readOptions(fs) + ensureRegistry(opts)[cart.id] = recordFor(cart, hash) + writeOptions(opts, fs) + return cart, hash +end + +function CartStore.uninstall(id, fs) + fs = fsOr(fs) + if not safeId(id) then return nil, "unknown cart id" end + if not fs then return nil, "NO FILESYSTEM" end + local path = fileFor(id) + local present = not fs.getInfo or fs.getInfo(path) ~= nil + if present and fs.read and fs.read(path) == nil then present = false end + local opts = readOptions(fs) + local reg = registry(opts) + local known = reg ~= nil and reg[id] ~= nil + if not present and not known then + return nil, ("cart %q is not installed"):format(id) + end + if present and fs.remove then fs.remove(path) end + if known then + reg[id] = nil + writeOptions(opts, fs) + end + return true +end + +local function manifestOf(row) + return type(row.manifest) == "table" and row.manifest or nil +end + +local function textOf(...) + for i = 1, select("#", ...) do + local value = select(i, ...) + if type(value) == "string" and value ~= "" then return value end + end + return nil +end + +local function pinRepo(row) + local m = manifestOf(row) + return textOf(row.github, m and m.github) +end + +local function pinHash(row) + local m = manifestOf(row) + local hash = textOf(row.sha256, row.archiveSha256, + m and m.sha256, m and m.archiveSha256) + if hash and #hash == 64 and hash:match("^[0-9a-f]+$") then return hash end + return nil +end + +local function frozenOptions(bucket) + if type(bucket) ~= "table" then return nil end + local out, any = {}, false + for key, value in pairs(bucket) do + local kind = type(value) + if type(key) == "string" and key ~= "" + and (kind == "string" or kind == "number" or kind == "boolean") then + out[key] = value + any = true + end + end + return any and out or nil +end + +local function pinReason(repo, version, semver, hash) + local why = {} + if not repo then why[#why + 1] = "no GitHub repo is recorded" end + if not semver then + why[#why + 1] = ("version %s is not a semantic version, so it pins as %s") + :format(version and ("%q"):format(version) or "is missing", + CartStore.UNPINNED_VERSION) + end + if repo and semver and not hash then + why[#why + 1] = "no archive hash is known yet" + end + return table.concat(why, " and ") +end + +function CartStore.capture(identity, available, modOptions) + if type(identity) ~= "table" then return nil, "cart identity is required" end + local mods, order, unresolved = {}, {}, {} + for _, row in ipairs(available or {}) do + if type(row) == "table" and safeId(row.id) then + local m = manifestOf(row) + local version = textOf(row.version, m and m.version) + local semver = version and Semver.parse(version) and version or nil + local repo = pinRepo(row) + local hash = pinHash(row) + local entry + if repo and semver and hash then + entry = { id = row.id, source = "github", repo = repo, + version = semver, sha256 = hash } + else + entry = { id = row.id, source = "local", + version = semver or CartStore.UNPINNED_VERSION } + unresolved[#unresolved + 1] = { + id = row.id, + name = textOf(row.name, m and m.name) or row.id, + version = version, + reason = pinReason(repo, version, semver, hash), + } + end + -- A switched-off mod is pinned OFF rather than dropped, so the cart + -- still ships it with the author's settings. + if not row.enabled then entry.enabled = false end + entry.options = frozenOptions(modOptions and modOptions[row.id]) + mods[#mods + 1] = entry + order[#order + 1] = row.id + end + end + local cart, err = CartManifest.parse({ + id = identity.id, title = identity.title, version = identity.version, + author = identity.author, repo = identity.repo, summary = identity.summary, + shell = identity.shell, finish = identity.finish, speeds = identity.speeds, + label = identity.label, base = identity.base, + engine = identity.engine, seal = identity.seal, options = identity.options, + mods = mods, load_order = order, + }) + if not cart then return nil, err end + return cart, unresolved +end + +return CartStore diff --git a/src/core/Base64.lua b/src/core/Base64.lua new file mode 100644 index 00000000..1af84ac3 --- /dev/null +++ b/src/core/Base64.lua @@ -0,0 +1,75 @@ +local Base64 = {} + +local ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" +local PAD = 61 + +local ENC, DEC = {}, {} +for i = 0, 63 do + local c = ALPHABET:sub(i + 1, i + 1) + ENC[i] = c + DEC[c:byte()] = i +end + +local floor = math.floor +local char = string.char +local concat = table.concat + +function Base64.encode(bytes) + if type(bytes) ~= "string" then return nil, "base64 input must be a string" end + local out, n, i = {}, #bytes, 1 + while i + 2 <= n do + local a, b, c = bytes:byte(i, i + 2) + local word = a * 65536 + b * 256 + c + out[#out + 1] = ENC[floor(word / 262144)] .. ENC[floor(word / 4096) % 64] + .. ENC[floor(word / 64) % 64] .. ENC[word % 64] + i = i + 3 + end + local rest = n - i + 1 + if rest == 1 then + local a = bytes:byte(i) + out[#out + 1] = ENC[floor(a / 4)] .. ENC[(a % 4) * 16] .. "==" + elseif rest == 2 then + local a, b = bytes:byte(i, i + 1) + local word = a * 256 + b + out[#out + 1] = ENC[floor(word / 1024)] .. ENC[floor(word / 16) % 64] + .. ENC[(word % 16) * 4] .. "=" + end + return concat(out) +end + +function Base64.decode(text) + if type(text) ~= "string" then return nil, "base64 input must be a string" end + local n = #text + if n % 4 ~= 0 then return nil, "base64 length must be a multiple of four" end + if n == 0 then return "" end + local out = {} + local last = n - 3 + for i = 1, n, 4 do + local b1, b2, b3, b4 = text:byte(i, i + 3) + local v1, v2 = DEC[b1], DEC[b2] + if not v1 or not v2 then + return nil, "base64 holds a character outside the alphabet" + end + if i == last and b3 == PAD then + if b4 ~= PAD then return nil, "base64 padding is malformed" end + if v2 % 16 ~= 0 then return nil, "base64 padding carries data bits" end + out[#out + 1] = char(v1 * 4 + floor(v2 / 16)) + elseif i == last and b4 == PAD then + local v3 = DEC[b3] + if not v3 then return nil, "base64 holds a character outside the alphabet" end + if v3 % 4 ~= 0 then return nil, "base64 padding carries data bits" end + local word = v1 * 1024 + v2 * 16 + floor(v3 / 4) + out[#out + 1] = char(floor(word / 256), word % 256) + else + local v3, v4 = DEC[b3], DEC[b4] + if not v3 or not v4 then + return nil, "base64 holds a character outside the alphabet" + end + local word = v1 * 262144 + v2 * 4096 + v3 * 64 + v4 + out[#out + 1] = char(floor(word / 65536), floor(word / 256) % 256, word % 256) + end + end + return concat(out) +end + +return Base64 diff --git a/src/core/ChipSynth.lua b/src/core/ChipSynth.lua index df9173b5..382e8e82 100644 --- a/src/core/ChipSynth.lua +++ b/src/core/ChipSynth.lua @@ -1147,6 +1147,7 @@ function Engine:drumInstrumentGen2(kit, pitch) ticks = ticks + duration end end + extendDrumEnvelope(segments) self.noiseInstruments[key] = segments return segments end diff --git a/src/core/Game.lua b/src/core/Game.lua index 24e32a09..2cc890d7 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -830,14 +830,6 @@ function Game:keypressed(key) self:writeOptions() end return - elseif key == "5" then - -- cycle GBC FX OFF → 1 → 2 → 3 → 4 (unlit-GBC ladder); always on - -- desktop. Mobile refuses the present shader (issue #136). - local GBCFX = require("src.render.GBCFX") - if not GBCFX.isSupported() then return end - self.save.options.gbcfx = GBCFX.cycle() - self:writeOptions() - return end -- Mod render pipelines claim their hotkeys last, so one can never shadow -- an engine display key however a mod declares it (12 §rendering @@ -1271,8 +1263,10 @@ function Game:applyOptions(opts) require("src.render.Pipelines").applyOptions(opts) require("src.render.Zoom").applyOptions(opts) require("src.render.TileRenderer").applyOptions(opts) - -- returns true when a persisted GBC FX level was cleared on mobile - local gbcCleared = require("src.render.GBCFX").applyOptions(opts) + -- returns true when a persisted preset name no longer resolves (deleted + -- from the drop-in folder, or failed to (re)translate) and had to be + -- cleared back to OFF -- "sanitized, please persist" contract + local shaderfxCleared = require("src.render.ShaderFX").applyOptions(opts) require("src.core.VideoMode").applyOptions(opts) -- Android orientation lock (#592); no-op everywhere else require("src.core.Orientation").applyOptions(opts) @@ -1287,12 +1281,12 @@ function Game:applyOptions(opts) -- tier. Every heavy feature was just applied from the stored options -- above; here we clamp the *live* state down for a weaker device without -- rewriting what the player saved, so raising the tier later restores - -- their exact TILT / GBC FX / ZOOM / MAX FPS choices. A HIGH tier (the + -- their exact TILT / ZOOM / MAX FPS choices. A HIGH tier (the -- default on a normal desktop, and every options.lua predating this -- option) clamps nothing, so it is a no-op for the common case. local caps = require("src.core.Performance").applyOptions(opts) if not caps.tilt then require("src.render.Tilt").setLevel(0) end - if not caps.gbcfx then require("src.render.GBCFX").setLevel(0) end + if not caps.shaderfx then require("src.render.ShaderFX").deactivate() end local Zoom = require("src.render.Zoom") Zoom.allowSurvey = caps.survey if not caps.survey and Zoom.offset < 0 then Zoom.offset = 0 end @@ -1302,8 +1296,7 @@ function Game:applyOptions(opts) end Input:applyBindings(opts.bindings) TouchControls:applyOptions(opts) - -- heal soft-bricked APK installs that already saved gbcfx > 0 (#136) - if gbcCleared then self:writeOptions() end + if shaderfxCleared then self:writeOptions() end end function Game:restoreSave(loaded, recovered, opts) diff --git a/src/core/Game2.lua b/src/core/Game2.lua index 145c97ed..22d914ff 100644 --- a/src/core/Game2.lua +++ b/src/core/Game2.lua @@ -19,6 +19,7 @@ local Clock = require("src.core.gen2.Clock") local FixedStep = require("src.core.FixedStep") local Font = require("src.render.Font") local GamepadMap = require("src.core.GamepadMap") +local GameVersion = require("src.core.GameVersion") local Input = require("src.core.Input") local Music = require("src.core.Music") local Save = require("src.core.gen2.Save") @@ -288,8 +289,6 @@ function Game2:continueGame(save) self:startWorld() -- After the adopt and after the world is standing, which is where Gen 1 -- emits it (src/core/Game.lua:1127, once the stack has been rebuilt). - -- `meta` stays absent on a Gold save, which stamps no meta block; modsDiff - -- is derived from it and so comes back empty rather than missing. if modsDiff then local notice = SaveData.modsDiffNotice(modsDiff, save.meta) if notice then require("src.core.Logger").warn("%s", notice) end @@ -354,24 +353,36 @@ function Game2:showTitle() }) end +-- ../pokegold/engine/movie/intro.asm:1 GoldSilverIntro, and Crystal's own +-- program at ../pokecrystal/engine/movie/intro.asm:1 CrystalIntro. function Game2:showIntro() self.stack:clear() self.phase = "boot" - Screens.push(self, "Gen2GoldSilverIntro", { + local id = (GameVersion.engine() == "crystal") + and "Gen2CrystalIntro" or "Gen2GoldSilverIntro" + Screens.push(self, id, { onDone = function() self:showTitle() end, }) end +-- ../pokegold/engine/menus/intro_menu.asm:848-851 IntroSequence, and Crystal's +-- at ../pokecrystal/engine/menus/intro_menu.asm:964-967: a skip means the title. function Game2:showGameFreak() self.stack:clear() self.phase = "boot" - Screens.push(self, "Gen2GameFreakPresents", { + local id = (GameVersion.engine() == "crystal") + and "Gen2CrystalSplash" or "Gen2GameFreakPresents" + Screens.push(self, id, { title = self.titleData or {}, oakSpeech = self.oakSpeechData or {}, - onDone = function() - self:showIntro() + onDone = function(skipped) + if skipped then + self:showTitle() + else + self:showIntro() + end end, }) end @@ -1095,18 +1106,26 @@ function Game2:load() -- The play clock only runs in the overworld, the way wGameTimerPaused is -- set while the intro menu is up. Save.tickPlayTime(self.save) - -- START and SELECT are read only at the tail of OWPlayerInput, which - -- PlayerEvents never reaches while a script is running or the player is - -- mid-step (World:acceptsMenuInput transcribes the three gates). A press - -- that arrives under one of them is dropped, not queued -- and the frame - -- still runs, so world:step must not be skipped on the swallowed press. - if self.input:wasPressed("start") and self.world:acceptsMenuInput() then - self:openStartMenu() - return - end - if self.input:wasPressed("select") and self.world:acceptsMenuInput() then - self:useSelectItem() - return + -- MAPEVENTS_OFF skips GetJoypad for the whole of a step, so the hJoyDown + -- mirror is frozen -- events.asm:190-198, :211-227 (#525, #1718) + local accepts = self.world:acceptsMenuInput() + local latch = self.joyLatch + if accepts then + self.joyLatch = nil + if self.input:wasPressed("start") + or (latch and latch.start and self.input:isDown("start")) then + self:openStartMenu() + return + end + if self.input:wasPressed("select") + or (latch and latch.select and self.input:isDown("select")) then + self:useSelectItem() + return + end + else + if not latch then latch = {}; self.joyLatch = latch end + if self.input:wasPressed("start") then latch.start = true end + if self.input:wasPressed("select") then latch.select = true end end self.world:pollInput(self.input) if self.input:wasPressed("a") then @@ -1158,8 +1177,8 @@ end -- The screen-pixels-per-GB-pixel scale the post passes need so their grid and -- shadow offsets stay window-size independent. Always the plain letterbox --- fit, never the survey zoom: GBC FX is simulating the PANEL the picture is --- being shown on, and the panel does not resize when the player zooms the map +-- fit, never the survey zoom: SHADER FX is simulating the PANEL the picture +-- is being shown on, and the panel does not resize when the player zooms the map -- -- Gen 1 hands the same pass its `Renderer:fitScale()` for that reason -- (Renderer:endFrame's Sp). Following the zoom used to shrink the LCD grid to -- one screen pixel a cell out at survey range. @@ -1356,25 +1375,29 @@ end -- Gold's frame, and then the passes that run over it. -- -- The Gen 1 path gets these for free because everything it draws goes through --- src/render/Renderer.lua, which owns a present canvas and calls GBCFX there. --- Gold draws straight to the screen instead, which is why its GBC FX row used --- to change a number and nothing else: nothing ever presented a canvas for the --- shader to read. So compose into one here when a pass wants it, and skip the --- canvas entirely when none does -- the common case, and one less full-screen --- blit than the old path would have paid. +-- src/render/Renderer.lua, which owns a present canvas and calls ShaderFX +-- there. Gold draws straight to the screen instead, which is why its SHADER +-- FX row used to change a number and nothing else (back when it was GBC FX): +-- nothing ever presented a canvas for the shader to read. So compose into +-- one here when a pass wants it, and skip the canvas entirely when none does +-- -- the common case, and one less full-screen blit than the old path would +-- have paid. -- --- CLASSIC runs first and GBC FX second, matching the Gen 1 order: the palette --- IS the picture, and the screen effects are simulating the panel that picture --- is being shown on. Mod post-processes fold in between the two, where --- Renderer.lua:1058 folds them -- a blur or a colour grade is what the LCD grid --- is then drawn over, rather than something that smears the grid itself. +-- CLASSIC runs first and SHADER FX second, matching the Gen 1 order: the +-- palette IS the picture, and the screen effects are simulating the panel +-- that picture is being shown on. Mod post-processes fold in between the +-- two, where Renderer.lua:1058 folds them -- a blur or a colour grade is +-- what the LCD grid is then drawn over, rather than something that smears +-- the grid itself. function Game2:drawViewportFrame() local G = love.graphics local w, h = GameViewport.dimensions() - local GBCFX = require("src.render.GBCFX") + local ShaderFX = require("src.render.ShaderFX") local GbcPalette = require("src.render.GbcPalette") local Pipelines = require("src.render.Pipelines") - local fx = GBCFX.active() + -- Same dispatch src/render/Renderer.lua:1185 already uses for Gen 1 + -- (ShaderFX replaced GBCFX's slot; GBCFX.lua itself is removed). + local shaderfx = ShaderFX.active() -- render.zones, at the instant Gen 1 raises it: the palette list is settled -- and the blit has not happened yet. Gen 1's list is the SGB packet zones @@ -1397,14 +1420,14 @@ function Game2:drawViewportFrame() local zoned = type(zones) == "table" and zones[1] ~= nil -- A present canvas is paid for only when something reads it: the zone pass, - -- GBC FX, a mod post-process, render.compose, or an enabled render.output + -- SHADER FX, a mod post-process, render.compose, or an enabled render.output -- subscriber. With none of them the frame draws straight to the screen -- exactly as it always did. local composing = ModRuntime.wantsHook("render.compose") local hasOutputHook = ModRuntime.wantsHook("render.output") and ModRuntime.call("render.output_enabled", function() return false end) == true local scene = nil - if zoned or fx or composing or Pipelines.wantsPresent() or hasOutputHook then + if zoned or shaderfx or composing or Pipelines.wantsPresent() or hasOutputHook then scene = self:presentCanvas(1, w, h) end if not scene then @@ -1432,11 +1455,11 @@ function Game2:drawViewportFrame() end -- The zone pass has to land in a texture whenever anything still reads one - -- after it: GBC FX and a post-process both sample the tinted image, not the - -- untinted one. On its own the tint rides the final blit and no second + -- after it: SHADER FX and a post-process both sample the tinted image, not + -- the untinted one. On its own the tint rides the final blit and no second -- canvas is paid for. local source = scene - local reread = fx or Pipelines.wantsPresent() or hasOutputHook + local reread = shaderfx or Pipelines.wantsPresent() or hasOutputHook if zoned and reread then local tinted = self:presentCanvas(2, w, h) if tinted then @@ -1456,7 +1479,7 @@ function Game2:drawViewportFrame() -- Post-process pipelines run over the finished composite and before GBC -- FX. Each hands back a canvas; with none registered this returns `source` -- unchanged and the frame is byte-identical (Renderer.lua:1058). - local scale, ox, oy, dpi = self:frameFit(w, h) + local scale, ox, oy, dpi, pw, ph = self:frameFit(w, h) source = Pipelines.present(source, { width = w, height = h, scale = scale, dpi = dpi, dpiX = dpi, dpiY = dpi }) or source local outputHandled = hasOutputHook @@ -1470,8 +1493,30 @@ function Game2:drawViewportFrame() if not outputHandled then local cx, cy, cw, ch = Playfield.cutout(w, h) if cx then G.setScissor(cx, cy, cw, ch) end - if fx then - GBCFX.present(source, self:pixelScale(w, h)) + if shaderfx then + -- rect is physical framebuffer pixels and source is the un-scaled + -- size, matching Renderer.lua's fxRectPx / fxSrc contract. + -- A live overworld draws edge to edge at World:zoomScale, so the + -- faithful 160*scale box would leave the rest of the map unshaded. + local rect, srcW, srcH + if self.frameWorldActive and self.world then + local s = self.world:zoomScale() * dpi + srcW = self.world.viewW or 160 + srcH = self.world.viewH or 144 + local rw, rh = srcW * s, srcH * s + rect = { + x = math.floor((pw - rw) / 2), y = math.floor((ph - rh) / 2), + w = rw, h = rh, scale = s, + } + else + srcW, srcH = 160, 144 + rect = { + x = ox * dpi, y = oy * dpi, + w = 160 * scale * dpi, h = 144 * scale * dpi, + scale = scale * dpi, + } + end + ShaderFX.render(source, rect, { w = srcW, h = srcH }, dpi, dpi) else G.setColor(1, 1, 1, 1) G.draw(source, 0, 0) @@ -1515,6 +1560,28 @@ function Game2:drawContained(w, h) if not ok then error(err, 0) end end +-- BATTLE BG (#1709): the surround around the centred GB screen, taken from +-- whichever state in the stack owns a battle. +function Game2:paintBattleSurround(w, h) + local states = self.stack and self.stack.states or {} + local mode + for i = #states, 1, -1 do + local state = states[i] + if state and state.bgMode then mode = state:bgMode() break end + end + if mode ~= "black" then return end + local G = love.graphics + local scale = Chrome.fitScale(w, h) + local ox, oy = Chrome.fitOrigin(w, h, scale) + local pw, ph = 160 * scale, 144 * scale + G.setColor(0, 0, 0, 1) + if oy > 0 then G.rectangle("fill", 0, 0, w, oy) end + if oy + ph < h then G.rectangle("fill", 0, oy + ph, w, h - oy - ph) end + if ox > 0 then G.rectangle("fill", 0, oy, ox, ph) end + if ox + pw < w then G.rectangle("fill", ox + pw, oy, w - ox - pw, ph) end + G.setColor(1, 1, 1, 1) +end + function Game2:drawScene(w, h) local G = love.graphics -- render.compose reads this after the scene is drawn; the plain overworld @@ -1584,6 +1651,7 @@ function Game2:drawScene(w, h) and base.drawWidescreen and base) if wide then wide:drawWidescreen(w, h) + self:paintBattleSurround(w, h) self:letterbox(w, h, false) if wide ~= top then -- The pushed box blits at the same integer fit the widescreen layer @@ -1688,7 +1756,6 @@ end -- F1/F2 write / reload the save 1 GAME SPEED -- - = zoom one step out / in 2 COLOR -- 4 cycle ZOOM 3 TILT (mnemonic: 3D) --- 5 GBC FX -- -- `2` is COLOR here rather than Gen 1's COLORS. The Gen 1 row cycles SGB -- palette packs, which a CGB-native game has no use for; what it cycles here @@ -1723,13 +1790,6 @@ function Game2:hotkey(key) options.tilt = Tilt.cycle() persist() return true - elseif key == "5" then - local GBCFX = require("src.render.GBCFX") - if GBCFX.isSupported() then - options.gbcfx = GBCFX.cycle() - persist() - end - return true end if not (self.world and self.world.map) then return self:pipelineHotkey(key, options, persist) @@ -1964,7 +2024,8 @@ end -- Push the saved display options into the modules that own them. Called -- whenever the options table changes hands (boot, CONTINUE, the OPTION --- screen), so a reload comes back at the zoom, tilt and GBC FX the player left. +-- screen), so a reload comes back at the zoom, tilt and SHADER FX the player +-- left. function Game2:applyOptions() local options = self.options or {} Music.applyOptions(options) @@ -1991,9 +2052,26 @@ function Game2:applyOptions() require("src.core.ScreenPosition").applyOptions(options) require("src.core.FrameCap").applyOptions(options) require("src.world.gen2.BorderFill").applyOptions(options) - local GBCFX = require("src.render.GBCFX") - if GBCFX.applyOptions(options) and self.save then - -- applyOptions returns true when it had to clear an unsupported level. + -- returns true when a persisted preset name no longer resolves (deleted + -- from the drop-in folder, or failed to (re)translate) and had to be + -- cleared back to OFF -- src\core\Game.lua:1215 mirrors this call for + -- Gen 1 (SHADER FX reaches Gen 2 too) + local shaderfxCleared = require("src.render.ShaderFX").applyOptions(options) + -- Scale the optional presentation extras to the device's performance + -- tier, same clamp src/core/Game.lua:1222-1230 applies for Gen 1 -- see + -- that site's comment for the full rationale. + local caps = require("src.core.Performance").applyOptions(options) + if not caps.tilt then require("src.render.Tilt").setLevel(0) end + if not caps.shaderfx then require("src.render.ShaderFX").deactivate() end + local Zoom = require("src.render.Zoom") + Zoom.allowSurvey = caps.survey + if not caps.survey and Zoom.offset < 0 then Zoom.offset = 0 end + if caps.fpsMax then + local FrameCap = require("src.core.FrameCap") + if FrameCap.current > caps.fpsMax then FrameCap.apply(caps.fpsMax) end + end + if shaderfxCleared and self.save then + -- applyOptions returns true when it had to clear an unresolved preset. self.save.options = options end end diff --git a/src/core/GameSpeed.lua b/src/core/GameSpeed.lua index 96f08815..495bd27f 100644 --- a/src/core/GameSpeed.lua +++ b/src/core/GameSpeed.lua @@ -29,11 +29,38 @@ end -- nearest valid level for an arbitrary value (a hand-edited options.lua or -- a --speed argument), so a bad number degrades to something sane +-- A cart may narrow the levels a player can reach (CartManifest's `speeds`). +-- nil restores the full ladder; a one-entry list pins the speed outright. +local allowed + +function GameSpeed.setAllowed(levels) + if type(levels) ~= "table" or #levels == 0 then allowed = nil; return end + local valid, seen = {}, {} + for _, want in ipairs(GameSpeed.LEVELS) do + for _, have in ipairs(levels) do + if have == want and not seen[want] then + seen[want] = true + valid[#valid + 1] = want + end + end + end + allowed = (#valid > 0) and valid or nil +end + +function GameSpeed.allowed() + return allowed or GameSpeed.LEVELS +end + +function GameSpeed.isLocked() + return allowed ~= nil and #allowed <= 1 +end + function GameSpeed.clamp(v) v = tonumber(v) - if not v then return GameSpeed.DEFAULT end - local best, bestDiff = GameSpeed.DEFAULT, math.huge - for _, level in ipairs(GameSpeed.LEVELS) do + local levels = GameSpeed.allowed() + if not v then return levels[1] or GameSpeed.DEFAULT end + local best, bestDiff = levels[1] or GameSpeed.DEFAULT, math.huge + for _, level in ipairs(levels) do local diff = math.abs(level - v) if diff < bestDiff then best, bestDiff = level, diff end end @@ -42,7 +69,7 @@ end -- cycle to the next/previous level, wrapping (the options row idiom) function GameSpeed.cycle(v, dir) - local levels = GameSpeed.LEVELS + local levels = GameSpeed.allowed() local cur = 1 for i, level in ipairs(levels) do if level == GameSpeed.clamp(v) then cur = i break end diff --git a/src/core/GameVersion.lua b/src/core/GameVersion.lua index b6435b86..3e39ced9 100644 --- a/src/core/GameVersion.lua +++ b/src/core/GameVersion.lua @@ -1,6 +1,6 @@ -- Which game this process is running: Red (the historical default), Blue, --- Yellow, Gold, or Silver. One source of truth for everything that differs by --- version -- the accepted ROM hash, the import manifest, where the +-- Yellow, Gold, Silver, or Crystal. One source of truth for everything that +-- differs by version -- the accepted ROM hash, the import manifest, where the -- extracted cache lives, and the save-file suffix -- so the importer, -- cache mount, SaveData, title screen and palette all agree. -- @@ -8,8 +8,9 @@ -- saves are untouched, but its extracted cache lives under red/ like Blue, -- Yellow, and Gold (issue #899); a legacy root cache is moved into red/ once -- by CacheFs.migrateLegacyRedCache. All supported versions can be imported --- and selected side by side. Gold and Silver are Gen 2 (see --- docs/gold-phase1.md). +-- and selected side by side. Gold, Silver and Crystal are Gen 2 (see +-- docs/gold-phase1.md); `generation` splits Gen 1 from Gen 2 and `engine` +-- splits Gold/Silver from Crystal within Gen 2. -- -- Zero requires, so it loads during love.conf and under plain Lua for tools -- and tests. The active version is a process-global set once at boot from @@ -27,6 +28,8 @@ GameVersion.VERSIONS = { manifest = "tools/rom_manifest.json", cachePrefix = "red/", -- red/data/generated, red/assets/generated (#899) saveSuffix = "", -- save.lua / save.lua.bak / save.lua.tmp + -- Absent reads as "gen1" (GameVersion.engine) + engine = "gen1", }, blue = { id = "blue", @@ -37,6 +40,7 @@ GameVersion.VERSIONS = { manifest = "tools/rom_manifest_blue.json", cachePrefix = "blue/", -- blue/data/generated, blue/assets/generated saveSuffix = "_blue", -- save_blue.lua / .bak / .tmp + engine = "gen1", }, yellow = { id = "yellow", @@ -47,6 +51,7 @@ GameVersion.VERSIONS = { manifest = "tools/rom_manifest_yellow.json", cachePrefix = "yellow/", -- yellow/data/generated, yellow/assets/generated saveSuffix = "_yellow", -- save_yellow.lua / .bak / .tmp + engine = "gen1", }, -- Gen 2, Phase 1 (docs/gold-phase1.md): a 2 MiB cart, twice the size of -- the Gen 1 ROMs above, imported through RomExtractorGen2 instead of @@ -55,15 +60,14 @@ GameVersion.VERSIONS = { id = "gold", label = "Gold", displayName = "Pokemon Gold", - -- Still Gen 2 Phase work; the launcher panel / Play button say Beta so - -- players do not treat it like the shipped Gen 1 columns. - launcherName = "Gold (Beta)", + launcherName = "Gold", sha1 = "d8b8a3600a465308c9953dfa04f0081c05bdcb94", manifest = "tools/rom_manifest_gold.json", cachePrefix = "gold/", -- gold/data/generated, gold/assets/generated saveSuffix = "_gold", -- save_gold.lua / .bak / .tmp -- Absent reads as 1 (GameVersion.generation) generation = 2, + engine = "gs", }, -- Gold's engine with edition-selected data; the manifest is derived from -- Gold's by tools/make_silver_manifest.py. @@ -71,17 +75,42 @@ GameVersion.VERSIONS = { id = "silver", label = "Silver", displayName = "Pokemon Silver", - launcherName = "Silver (Beta)", + launcherName = "Silver", sha1 = "49b163f7e57702bc939d642a18f591de55d92dae", manifest = "tools/rom_manifest_silver.json", cachePrefix = "silver/", -- silver/data/generated, silver/assets/generated saveSuffix = "_silver", -- save_silver.lua / .bak / .tmp generation = 2, + engine = "gs", + }, + crystal = { + id = "crystal", + label = "Crystal", + displayName = "Pokemon Crystal", + -- Still Gen 2 Phase work; the launcher panel / Play button say Beta so + -- players do not treat it like the shipped Gold and Silver columns. + launcherName = "Crystal (Beta)", + sha1 = "f4cd194bdee0d04ca4eac29e09b8e4e9d818c133", + manifest = "tools/rom_manifest_crystal.json", + cachePrefix = "crystal/", -- crystal/data/generated, crystal/assets/generated + saveSuffix = "_crystal", -- save_crystal.lua / .bak / .tmp + generation = 2, + engine = "crystal", + fixes = { + -- pokegold/docs/bugs_and_glitches.md:61 + luckyNumberBoxes = true, + -- pokegold/docs/bugs_and_glitches.md:88 + surfOntoNpc = true, + -- pokecrystal/engine/battle/effect_commands.asm:2614 + reflectOverflow = true, + }, }, } --- Launcher column order. -GameVersion.ORDER = { "red", "blue", "yellow", "gold", "silver" } +local NO_FIXES = {} + +-- Launcher column order. Append only (src/mods/ModProfile.lua encodes by index). +GameVersion.ORDER = { "red", "blue", "yellow", "gold", "silver", "crystal" } GameVersion.current = "red" @@ -115,6 +144,17 @@ function GameVersion.generation(id) return GameVersion.info(id).generation or 1 end +-- "gen1" | "gs" | "crystal": lineage within a generation. +function GameVersion.engine(id) + return GameVersion.info(id).engine or "gen1" +end + +-- Cart bugs a version FIXED, by fix name; an absent row reads {} and stays bugged. +function GameVersion.fixes(id) + local info = GameVersion.info(id) + return (info and info.fixes) or NO_FIXES +end + -- Metadata for a version id, defaulting to the active one. function GameVersion.info(id) return GameVersion.VERSIONS[id or GameVersion.current] diff --git a/src/core/HostShell.lua b/src/core/HostShell.lua index 82a42bfb..cb6d1082 100644 --- a/src/core/HostShell.lua +++ b/src/core/HostShell.lua @@ -376,7 +376,19 @@ end -- notice a quit command until curl returns. With the launcher's default 300s -- ceiling, closing the window during a mod download hung the process for -- minutes. Callers on the interactive fetch pool pass something short. -function HostShell.httpDownload(url, absPath, userAgent, accept, maxTime) +-- +-- `etagPath` (optional, absolute host path) turns this into a conditional +-- GET: curl sends `If-None-Match` from whatever ETag is already saved there +-- (`--etag-compare`) and overwrites it with the response's own ETag on +-- success (`--etag-save`), same file for both so a first call with no prior +-- ETag degrades to a plain unconditional download. A server that replies 304 +-- Not Modified is reported as a third return value (`notModified`), and -- +-- confirmed empirically against the real buildbot.libretro.com endpoint +-- (TrueFX/etag-cache-repro/) before this was wired in here -- curl does NOT +-- write `absPath` at all in that case (not even an empty file), matching +-- HTTP: a 304 carries no body. A caller must not treat a missing file as an +-- error when `notModified` comes back true. +function HostShell.httpDownload(url, absPath, userAgent, accept, maxTime, etagPath) if type(url) ~= "string" or url == "" then return nil, "missing url" end if type(absPath) ~= "string" or absPath == "" then return nil, "missing path" end userAgent = userAgent or "gen1recomp" @@ -388,6 +400,10 @@ function HostShell.httpDownload(url, absPath, userAgent, accept, maxTime) if accept then cmd = cmd .. "-H " .. HostShell.quote("Accept: " .. accept) .. " " end + if type(etagPath) == "string" and etagPath ~= "" then + cmd = cmd .. "--etag-compare " .. HostShell.quote(etagPath) .. " " + .. "--etag-save " .. HostShell.quote(etagPath) .. " " + end cmd = cmd .. "-o " .. HostShell.quote(absPath) .. " " .. "-w " .. HostShell.quote(HTTP_MARK_FMT) .. " " .. HostShell.quote(url) .. " 2>&1" @@ -400,6 +416,9 @@ function HostShell.httpDownload(url, absPath, userAgent, accept, maxTime) -- status is here purely so the failure can NAME itself: "download failed" -- with no URL and no code is the report this whole change exists to fix. local body, status, noise = splitCurlOutput(readOk and out or "") + if status == 304 then + return true, nil, true + end if status and (status < 200 or status >= 300) then return nil, fetchError(url, status, body) end @@ -411,6 +430,10 @@ function HostShell.httpDownload(url, absPath, userAgent, accept, maxTime) if not haveBridge() then return nil, "no network transport on this platform" end + -- The Android/iOS bridge has no conditional-GET support -- etagPath is + -- silently ignored here, so a downloaded preset re-fetches in full every + -- time on those platforms. No transport-level ETag/If-None-Match hook + -- exists on the bridge to hang one off, and it's low-priority for now. local ok, done = pcall(love.system.httpDownload, url, absPath, userAgent, accept) if ok and done then return true end return nil, "download failed for " .. url diff --git a/src/core/LaunchOptions.lua b/src/core/LaunchOptions.lua index 7317ba8a..18a692b2 100644 --- a/src/core/LaunchOptions.lua +++ b/src/core/LaunchOptions.lua @@ -40,6 +40,7 @@ local function normalizeVersion(v) y = "yellow", yellow = "yellow", g = "gold", gold = "gold", s = "silver", silver = "silver", + c = "crystal", crystal = "crystal", } v = alias[v] or v if GameVersion.VERSIONS and not GameVersion.VERSIONS[v] then return nil end diff --git a/src/core/Performance.lua b/src/core/Performance.lua index b15b520e..4d61b65f 100644 --- a/src/core/Performance.lua +++ b/src/core/Performance.lua @@ -3,9 +3,12 @@ -- -- The heavy extras are all things the Game Boy never had and this port -- adds on top: the whole-screen 3D TILT (transforms the entire map as a --- ground plane), the GBC FX post-process shader (a fullscreen pass), and --- survey ZOOM (zooming out renders the connected neighbor maps -- a lot of --- extra overdraw). A hard FPS ceiling caps present cost on top of that. +-- ground plane), survey ZOOM (zooming out renders the connected +-- neighbor maps -- a lot of extra overdraw), and SHADER FX (a live- +-- translated libretro slang-shader chain, reasoned -- not yet measured -- +-- to be heavier per-frame than GBCFX.lua's old fixed shader it replaced). +-- A hard FPS ceiling caps present cost on +-- top of that. -- None of this touches game logic, which is fixed-step off dt -- (src/core/FixedStep.lua), so every tier plays identically; they differ -- only in how much optional eye-candy the renderer is allowed to do. @@ -13,14 +16,15 @@ -- The tier is persisted as save.options.performance: -- auto pick a default from the device (see detect()) -- high everything on -- the historical behavior --- balanced no TILT, no GBC FX (kept: survey zoom, colors, uncapped FPS) --- low no TILT, no GBC FX, no survey zoom, FPS capped +-- balanced no TILT, no SHADER FX (kept: survey zoom, colors, uncapped FPS) +-- low no TILT, no survey zoom, no SHADER FX, FPS capped -- -- AUTO only chooses the *default* for the current device; every tier is -- selectable in OPTIONS, so a heuristic that guesses wrong is one row away -- from being overridden. The clamps are applied live in Game:applyOptions --- against the stored options without rewriting them, so raising the tier --- restores exactly the TILT / GBC FX / ZOOM / FPS the player had. +-- (Gen 1) / Game2:applyOptions (Gen 2) against the stored options without +-- rewriting them, so raising the tier restores exactly the TILT / SHADER FX +-- / ZOOM / FPS the player had. Wired into both generations. -- -- Zero requires: loads during love.conf and under plain Lua for tools and -- tests, the same way src/core/GameVersion.lua does. @@ -40,10 +44,24 @@ Performance.LABELS = { -- What each concrete tier permits. `auto` is resolved to one of these -- before caps are read, so it has no row here. fpsMax = false means no -- extra ceiling (the player's own MAX FPS still applies). +-- +-- `shaderfx` is `false` (fully off) or a positive number: an internal +-- chain-resolution multiplier (1.0 = native, e.g. 0.5 = render the whole +-- SHADER FX pass chain at half resolution, then stretch up for display -- +-- src/render/ShaderFX.lua's ShaderFX.render already stretch-blits its +-- final output to the real display rect regardless of the chain's own +-- internal size, so shrinking the chain's working +-- resolution costs nothing extra at that final blit). Every existing +-- truthy/falsy call site (`if not caps.shaderfx then ... end`) still works +-- unchanged: `false` stays falsy, any positive number stays truthy -- only +-- `ShaderFX.render` itself reads the actual number. No tier is +-- set below 1.0 yet -- tuning the actual per-tier value for real low-end +-- hardware needs a device this project doesn't have to hand right now; this +-- is the mechanism, not the tuning. Performance.CAPS = { - high = { tilt = true, gbcfx = true, survey = true, fpsMax = false }, - balanced = { tilt = false, gbcfx = false, survey = true, fpsMax = false }, - low = { tilt = false, gbcfx = false, survey = false, fpsMax = 60 }, + high = { tilt = true, survey = true, shaderfx = 1.0, fpsMax = false }, + balanced = { tilt = false, survey = true, shaderfx = false, fpsMax = false }, + low = { tilt = false, survey = false, shaderfx = false, fpsMax = 60 }, } -- Live resolved tier (never "auto"); Game:applyOptions sets it and the @@ -93,8 +111,7 @@ function Performance.detect() if isArm and os == "Linux" then return "low" end - -- Phones and tablets: GBC FX is already force-disabled here (issue #136); - -- balanced additionally drops the 3D tilt, the heaviest remaining extra. + -- Phones and tablets: balanced drops the 3D tilt, the heaviest extra. if os == "Android" or os == "iOS" then return "balanced" end diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index 53e44f3c..7263d2b0 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -272,10 +272,9 @@ function SaveData.defaultOptions() speedOverworld = 1, speedBattle = 1, speedMenu = 1, - -- port display options (OptionsMenu / hotkeys 2/3/4/5) + -- port display options (OptionsMenu / hotkeys 2/3/4) colors = "gbc", tilt = 0, - gbcfx = 0, -- survey zoom offset from window fit scale (0 = FIT); see Zoom.lua zoom = 0, -- OVERWORLD beyond-edge fill: trees | water | black @@ -290,8 +289,8 @@ function SaveData.defaultOptions() fpsCap = 60, -- graphics performance tier: auto | high | balanced | low. "auto" -- picks a default from the device (ARM handhelds/phones drop the heavy - -- extras); scales TILT / GBC FX / survey ZOOM / FPS but never game - -- logic. See src/core/Performance.lua. + -- extras); scales TILT / survey ZOOM / FPS but never game logic. See + -- src/core/Performance.lua. performance = "auto", -- Per-pipeline display levels, keyed by render_pipelines id (see -- src/render/Pipelines.lua). A level for a mod that is not installed @@ -360,9 +359,24 @@ function SaveData.defaultOptions() timeFormat = "device", -- device | 24h | 12h saveSync = { enabled = false, lastSyncAt = 0, revs = {}, stamps = {}, pendingConflicts = {} }, + -- Cart-scoped settings, beside cartSlots: cartOptions[cartId][key] layers + -- over the global value for CART_OPTION_KEYS while that cart is playing. + cartOptions = {}, + -- The player's own answer for a mod a cart pins, kept out of the per-game + -- flags: cartMods[cartId][modId], absent while the cart's switch stands. + cartMods = {}, + -- The carts whose shipped defaults were already seeded, so a player's + -- later change is never overwritten (compare modProfilesSeeded). + cartOptionsSeeded = {}, } end +-- Settings that belong to a playthrough and follow the cart: pacing and the +-- battle rules. Everything else -- audio, video, bindings, touch, language, +-- launcher state -- belongs to the machine and stays global. +SaveData.CART_OPTION_KEYS = { "animations", "battleStyle", "ruleset", + "speedBattle", "speedMenu", "speedOverworld", "textSpeed" } + -- Merge loaded keys over defaults (shallow). Unknown keys are kept so -- future options aren't dropped by older builds writing the file back. function SaveData.mergeOptions(loaded) @@ -437,6 +451,60 @@ end -- ------- options +-- The cart being played, if any; SaveData.setCart owns it. Declared here +-- because the options overlay below reads it. +local activeCart, activeCartHash + +local function cartBucket(opts, cartId) + local root = type(opts) == "table" and opts.cartOptions or nil + local bucket = type(root) == "table" and root[cartId] or nil + return type(bucket) == "table" and bucket or nil +end + +-- Lay the active cart's values over the globals for the cart-scoped keys; a +-- key the cart never set falls through to the global value. +local function applyCartOverlay(opts) + if not (activeCart and type(opts) == "table") then return opts end + local bucket = cartBucket(opts, activeCart) + if not bucket then return opts end + for _, key in ipairs(SaveData.CART_OPTION_KEYS) do + if bucket[key] ~= nil then opts[key] = bucket[key] end + end + return opts +end + +local function cartGlobals(onDisk) + local out = {} + if type(onDisk) ~= "table" then return out end + for _, key in ipairs(SaveData.CART_OPTION_KEYS) do out[key] = onDisk[key] end + return out +end + +-- The inverse, run just before a write: the cart-scoped keys go into the +-- active cart's bucket and the globals keep whatever the file already held. +local function splitCartOverlay(opts, globals, onDisk) + if not (activeCart and type(opts) == "table") then return opts end + local root = {} + local prior = type(onDisk) == "table" and onDisk.cartOptions or nil + if type(prior) == "table" then + for id, bucket in pairs(prior) do root[id] = deepCopy(bucket) end + end + for id, bucket in pairs(type(opts.cartOptions) == "table" and opts.cartOptions or {}) do + root[id] = bucket + end + local bucket = type(root[activeCart]) == "table" and root[activeCart] or {} + local defaults = SaveData.defaultOptions() + for _, key in ipairs(SaveData.CART_OPTION_KEYS) do + if opts[key] ~= nil then bucket[key] = opts[key] end + local global = globals and globals[key] + if global == nil then global = defaults[key] end + opts[key] = global + end + root[activeCart] = bucket + opts.cartOptions = root + return opts +end + -- Both take an optional fs (write/getInfo/read) defaulting to -- love.filesystem, so the mod loader's injected filesystem can carry the -- options round-trip headless (no love global). @@ -458,6 +526,10 @@ function SaveData.saveOptions(opts, fs) -- activeProfile -- not defaultOptions members -- can be deleted by their -- sites precisely because those sites always write full tables. local onDisk = readTable(fs, OPTIONS_FILENAME) + -- Fold from the cart's view of the file, not the globals underneath it, or + -- a partial write would push the global value into the cart's bucket. + local cartGlobalValues = cartGlobals(onDisk) + applyCartOverlay(onDisk) local isFull = type(opts) == "table" if isFull then for k in pairs(SaveData.defaultOptions()) do @@ -497,6 +569,7 @@ function SaveData.saveOptions(opts, fs) end opts.modOptions = merged end + splitCartOverlay(opts, cartGlobalValues, onDisk) local encoded = SaveSerializer.encode(opts) -- Stage the new bytes and roll the last good file aside BEFORE the main -- write truncates it, the same tmp/bak dance SaveData.save uses for @@ -544,7 +617,8 @@ function SaveData.saveOptions(opts, fs) fs.write(OPTIONS_BACKUP_FILENAME, encoded) -- the staged witness has served its purpose; the main file is verified remove(fs, OPTIONS_TMP_FILENAME) - return opts + -- hand back the cart's view, matching what loadOptions would answer + return applyCartOverlay(opts) end function SaveData.loadOptions(fs) @@ -572,11 +646,11 @@ function SaveData.loadOptions(fs) if fs.write then fs.write(OPTIONS_FILENAME, SaveSerializer.encode(recovered)) end - return SaveData.mergeOptions(recovered) + return applyCartOverlay(SaveData.mergeOptions(recovered)) end return SaveData.defaultOptions() end - return SaveData.mergeOptions(data) + return applyCartOverlay(SaveData.mergeOptions(data)) end -- ------- per-game mod enablement @@ -759,25 +833,40 @@ end -- use" and the flat legacy path (save.lua / save_blue.lua / save_yellow.lua) -- is used, which keeps a brand-new install and every pre-slots caller -- working unchanged. -local activeSlotCache = {} -- version -> slotId in use, or false when none -local slotsChecked = {} -- version -> true once resolved this process +local activeSlotCache = {} -- scope key -> slotId in use, or false when none +local slotsChecked = {} -- scope key -> true once resolved this process -- At most one New Game can be the live candidate for a first public tool -- request. A single strong reference models that runtime fact without adding -- marker data to the save or retaining abandoned playthrough tables. local freshPlaythrough -local function slotDir(version) return "saves/" .. version end +local CART_PREFIX = "cart_" -local function slotNames(version, id) - local main = slotDir(version) .. "/" .. id .. ".lua" +local sealBroken = false + +local function cartKey(cartId) + if type(cartId) ~= "string" or #cartId > 64 then return nil end + if not cartId:match("^%w[%w%._%-]*$") then return nil end + return CART_PREFIX .. cartId +end + +local function isCartKey(key) + return key:sub(1, #CART_PREFIX) == CART_PREFIX +end + +local function slotDir(key) return "saves/" .. key end + +local function slotNames(key, id) + local main = slotDir(key) .. "/" .. id .. ".lua" return main, main .. ".bak", main .. ".tmp" end --- the pre-slots flat names a version always used (save.lua for Red, --- save_blue.lua / save_yellow.lua for the others); still the destination --- before any slot exists -local function legacyNames(version) - local main = "save" .. GameVersion.saveSuffix(version) .. ".lua" +-- the pre-slots flat names a scope uses before any slot exists (save.lua for +-- Red, save_blue.lua / save_yellow.lua for the other versions, and +-- save_cart_.lua for a cart) +local function legacyNames(key) + local suffix = isCartKey(key) and ("_" .. key) or GameVersion.saveSuffix(key) + local main = "save" .. suffix .. ".lua" return main, main .. ".bak", main .. ".tmp" end @@ -790,6 +879,37 @@ local function knownVersion(version) return GameVersion.info(version) ~= nil end +local function knownScope(key) + if type(key) ~= "string" or key == "" then return false end + if isCartKey(key) then return true end + return knownVersion(key) +end + +local function activeScopeKey(version) + if activeCart then return CART_PREFIX .. activeCart end + return version or GameVersion.get() +end + +local function registryOf(opts, key) + local root, id = opts.saveSlots, key + if isCartKey(key) then + root, id = opts.cartSlots, key:sub(#CART_PREFIX + 1) + end + if type(root) ~= "table" then return nil end + local reg = root[id] + return type(reg) == "table" and reg or nil +end + +local function putRegistry(opts, key, reg) + if isCartKey(key) then + opts.cartSlots = type(opts.cartSlots) == "table" and opts.cartSlots or {} + opts.cartSlots[key:sub(#CART_PREFIX + 1)] = reg + else + opts.saveSlots = type(opts.saveSlots) == "table" and opts.saveSlots or {} + opts.saveSlots[key] = reg + end +end + -- Create the parent directory of a slot path when the fs supports it. -- love.filesystem.createDirectory makes the whole tree; the injected memfs -- stub keys files by full path and exposes no such method, so this is a @@ -802,8 +922,8 @@ end -- Decode a slot's save using the same recovery order load() uses -- main, -- then the .tmp write-witness, then the .bak -- so a slot mid-crash still -- summarizes. nil when nothing readable is present. -local function decodeSlot(fs, version, id) - local main, bak, tmp = slotNames(version, id) +local function decodeSlot(fs, key, id) + local main, bak, tmp = slotNames(key, id) local data = fs.getInfo(main) and SaveSerializer.decode(fs.read(main) or "") if data then return data end data = fs.getInfo(tmp) and SaveSerializer.decode(fs.read(tmp) or "") @@ -818,30 +938,29 @@ end -- active slot. Returns the new slot id, or nil when there is nothing to -- migrate or the copy could not be verified (originals left in place so no -- data is ever lost to a failed move). -local function tryMigrateLegacy(version, fs) - local lmain, lbak, ltmp = legacyNames(version) +local function tryMigrateLegacy(key, fs) + local lmain, lbak, ltmp = legacyNames(key) local mainBody = fs.getInfo(lmain) and fs.read(lmain) local bakBody = fs.getInfo(lbak) and fs.read(lbak) if not (mainBody or bakBody) then return nil end local id = "slot1" - local dmain, dbak = slotNames(version, id) + local dmain, dbak = slotNames(key, id) ensureParentDir(fs, dmain) if mainBody then fs.write(dmain, mainBody) end if bakBody then fs.write(dbak, bakBody) end -- refuse to delete the originals unless the new slot is loadable (from -- the main copy or, failing that, the backup) - if not decodeSlot(fs, version, id) then return nil end + if not decodeSlot(fs, key, id) then return nil end remove(fs, lmain) remove(fs, lbak) remove(fs, ltmp) local opts = SaveData.loadOptions(fs) - opts.saveSlots = opts.saveSlots or {} - opts.saveSlots[version] = { list = { id }, active = id } + putRegistry(opts, key, { list = { id }, active = id }) -- A tool may have allocated the legacy scope before the player made their -- first ordinary SAVE. Promoting that flat save into slot1 must preserve the -- same opaque identity; otherwise title-selected mod storage becomes -- unreachable after the migration even though every durable record exists. - local ids = opts.playthroughIds and opts.playthroughIds[version] + local ids = opts.playthroughIds and opts.playthroughIds[key] if type(ids) == "table" and type(ids.legacy) == "string" and ids.legacy ~= "" then if type(ids[id]) ~= "string" or ids[id] == "" then ids[id] = ids.legacy end ids.legacy = nil @@ -852,9 +971,9 @@ end -- Scan the filesystem for orphaned slot files under saves// when options.lua -- has no registered slots for this version (e.g. options.lua was reset or lost). -local function scanDiskSlots(version, fs) +local function scanDiskSlots(key, fs) if not fs then return nil end - local dir = "saves/" .. version + local dir = slotDir(key) local slots = {} if fs.getDirectoryItems and fs.getInfo and fs.getInfo(dir) then local items = pcall(fs.getDirectoryItems, dir) and fs.getDirectoryItems(dir) or {} @@ -882,49 +1001,49 @@ local function scanDiskSlots(version, fs) return #slots > 0 and slots or nil end --- Resolve (once per version per process) which slot in-game saves use: an +-- Resolve (once per scope per process) which slot in-game saves use: an -- existing registry wins; otherwise a lazy legacy migration may create -- slot1; otherwise auto-recover disk slots; otherwise false (flat legacy path). -local function ensureVersionSlots(version, fs) - if slotsChecked[version] then return end - slotsChecked[version] = true - if not knownVersion(version) then - activeSlotCache[version] = false +local function ensureSlots(key, fs) + if slotsChecked[key] then return end + slotsChecked[key] = true + if not knownScope(key) then + activeSlotCache[key] = false return end local opts = SaveData.loadOptions(fs) - local reg = opts.saveSlots and opts.saveSlots[version] + local reg = registryOf(opts, key) if reg and type(reg.list) == "table" and #reg.list > 0 then - activeSlotCache[version] = reg.active or reg.list[1] + activeSlotCache[key] = reg.active or reg.list[1] return end - local migrated = tryMigrateLegacy(version, fs) + local migrated = tryMigrateLegacy(key, fs) if migrated then - activeSlotCache[version] = migrated + activeSlotCache[key] = migrated return end -- Auto-recovery: if options.lua lost its slot registry, scan disk for orphaned slot files - local recovered = scanDiskSlots(version, fs) + local recovered = scanDiskSlots(key, fs) if recovered and #recovered > 0 then - opts.saveSlots = opts.saveSlots or {} - opts.saveSlots[version] = { list = recovered, active = recovered[1] } + putRegistry(opts, key, { list = recovered, active = recovered[1] }) SaveData.saveOptions(opts, fs) - activeSlotCache[version] = recovered[1] - Logger.info("auto-recovered %d save slot(s) for %s from disk", #recovered, version) + activeSlotCache[key] = recovered[1] + Logger.info("auto-recovered %d save slot(s) for %s from disk", #recovered, key) return end - activeSlotCache[version] = false + activeSlotCache[key] = false end -- (body for the forward-declared saveNames.) Resolves the ACTIVE slot for --- the version, falling back to the flat legacy names when no slot is in use. +-- the scope -- the active cart when one is set, otherwise the version -- +-- falling back to the flat legacy names when no slot is in use. function saveNames(version, injectedFs) - version = version or GameVersion.get() + local key = activeScopeKey(version) local fs = persistFs(injectedFs) - ensureVersionSlots(version, fs) - local slot = activeSlotCache[version] - if slot then return slotNames(version, slot) end - return legacyNames(version) + ensureSlots(key, fs) + local slot = activeSlotCache[key] + if slot then return slotNames(key, slot) end + return legacyNames(key) end -- Pure extraction of the launcher's per-slot summary from a decoded save, @@ -1005,26 +1124,33 @@ function SaveData.slotDiskPath(version, slotId) return base .. sep .. rel:gsub("/", sep) end --- Slots visible to the launcher: every registered slot for a version, each +-- Slots visible to the launcher: every registered slot for a scope, each -- with whether it holds a save and the cheap summary above. A fresh -- install with nothing registered returns an empty array; a legacy install -- is migrated to slot1 first. +local function listSlotsIn(key) + local fs = persistFs(nil) + ensureSlots(key, fs) + local opts = SaveData.loadOptions(fs) + local reg = registryOf(opts, key) + local list = (reg and type(reg.list) == "table" and reg.list) or {} + local out = {} + for _, id in ipairs(list) do + local save = decodeSlot(fs, key, id) + local name, meta = SaveData.slotSummary(save) + out[#out + 1] = { id = id, exists = save ~= nil, name = name, meta = meta, + label = reg.names and reg.names[id] or nil, + cartHash = reg.hashes and reg.hashes[id] or nil, + sealBroken = (reg.broken and reg.broken[id] == true) + or false } + end + return out +end + function SaveData.listSlots(version) version = version or GameVersion.get() if not knownVersion(version) then return {} end - local fs = persistFs(nil) - ensureVersionSlots(version, fs) - local opts = SaveData.loadOptions(fs) - local reg = opts.saveSlots and opts.saveSlots[version] - local list = (reg and reg.list) or {} - local out = {} - for _, id in ipairs(list) do - local save = decodeSlot(fs, version, id) - local name, meta = SaveData.slotSummary(save) - out[#out + 1] = { id = id, exists = save ~= nil, name = name, meta = meta, - label = reg.names and reg.names[id] or nil } - end - return out + return listSlotsIn(version) end function SaveData.readSlotSource(version, slotId, injectedFs) @@ -1049,17 +1175,14 @@ end -- needs no save rewrite and an empty slot can be labeled too. The label is -- trimmed; an empty (or whitespace-only) one clears it. Returns true, or -- false + an error string when the id is not registered. -function SaveData.renameSlot(version, slotId, name) - version = version or GameVersion.get() - if not knownVersion(version) then return false, "unknown version" end +local function renameSlotIn(key, slotId, name) if type(slotId) ~= "string" or slotId == "" then return false, "missing slot id" end local fs = persistFs(nil) local opts = SaveData.loadOptions(fs) - opts.saveSlots = opts.saveSlots or {} - local reg = opts.saveSlots[version] - if not reg or not reg.list then return false, "slot not registered" end + local reg = registryOf(opts, key) + if not reg or type(reg.list) ~= "table" then return false, "slot not registered" end local found = false for _, id in ipairs(reg.list) do if id == slotId then found = true break end @@ -1070,47 +1193,55 @@ function SaveData.renameSlot(version, slotId, name) reg.names = reg.names or {} reg.names[slotId] = label if next(reg.names) == nil then reg.names = nil end - opts.saveSlots[version] = reg + putRegistry(opts, key, reg) SaveData.saveOptions(opts, fs) return true end +function SaveData.renameSlot(version, slotId, name) + version = version or GameVersion.get() + if not knownVersion(version) then return false, "unknown version" end + return renameSlotIn(version, slotId, name) +end + -- Point the active slot at slotId (registering it if new) and persist the -- choice to options.lua; also update the process-global cache so the very -- next save/load lands in the chosen slot. -function SaveData.setActiveSlot(version, slotId) - version = version or GameVersion.get() - if not knownVersion(version) then return nil end +local function setActiveSlotIn(key, slotId) local fs = persistFs(nil) local opts = SaveData.loadOptions(fs) - opts.saveSlots = opts.saveSlots or {} - local reg = opts.saveSlots[version] or { list = {}, active = nil } + local reg = registryOf(opts, key) or { list = {}, active = nil } + reg.list = type(reg.list) == "table" and reg.list or {} local found = false for _, id in ipairs(reg.list) do if id == slotId then found = true break end end if not found then reg.list[#reg.list + 1] = slotId end reg.active = slotId - opts.saveSlots[version] = reg + putRegistry(opts, key, reg) SaveData.saveOptions(opts, fs) - slotsChecked[version] = true - activeSlotCache[version] = slotId + slotsChecked[key] = true + activeSlotCache[key] = slotId return slotId end --- Register a new empty slot for the version and return its id. Does NOT +function SaveData.setActiveSlot(version, slotId) + version = version or GameVersion.get() + if not knownVersion(version) then return nil end + return setActiveSlotIn(version, slotId) +end + +-- Register a new empty slot for the scope and return its id. Does NOT -- write a save file and does NOT change the active slot: an empty slot -- means the title screen offers NEW GAME only. Ids are "slot%d+", -- allocated one past the highest existing number so a reused id can never -- collide with a lingering file. -function SaveData.createSlot(version) - version = version or GameVersion.get() - if not knownVersion(version) then return nil end +local function createSlotIn(key) local fs = persistFs(nil) - ensureVersionSlots(version, fs) + ensureSlots(key, fs) local opts = SaveData.loadOptions(fs) - opts.saveSlots = opts.saveSlots or {} - local reg = opts.saveSlots[version] or { list = {}, active = nil } + local reg = registryOf(opts, key) or { list = {}, active = nil } + reg.list = type(reg.list) == "table" and reg.list or {} local maxN = 0 for _, id in ipairs(reg.list) do local n = tonumber(tostring(id):match("^slot(%d+)$")) @@ -1118,21 +1249,31 @@ function SaveData.createSlot(version) end local id = "slot" .. (maxN + 1) reg.list[#reg.list + 1] = id - opts.saveSlots[version] = reg + putRegistry(opts, key, reg) SaveData.saveOptions(opts, fs) return id end --- The active slot id in use for a version (resolved once per process like +function SaveData.createSlot(version) + version = version or GameVersion.get() + if not knownVersion(version) then return nil end + return createSlotIn(version) +end + +-- The active slot id in use for a scope (resolved once per process like -- saveNames does), or nil when none is registered and the flat legacy path is -- in use. Public so the launcher's save Import/Export glue can name an export -- after the slot it came from without reaching into the private cache. +local function activeSlotIn(key) + local fs = persistFs(nil) + ensureSlots(key, fs) + return activeSlotCache[key] or nil +end + function SaveData.activeSlot(version) version = version or GameVersion.get() if not knownVersion(version) then return nil end - local fs = persistFs(nil) - ensureVersionSlots(version, fs) - return activeSlotCache[version] or nil + return activeSlotIn(version) end -- Write saveTable into an existing slot's file (SaveSerializer.encode), through @@ -1142,12 +1283,10 @@ end -- already registered the slot via createSlot but written no bytes yet; unlike -- SaveData.save this targets a specific slot and never rebuilds meta or touches -- options. Returns true, or false + an error string on a failed write. -function SaveData.writeSlot(version, slotId, saveTable) - version = version or GameVersion.get() - if not knownVersion(version) then return false, "unknown version" end +local function writeSlotIn(key, slotId, saveTable) if type(slotId) ~= "string" then return false, "missing slot id" end if type(saveTable) ~= "table" then return false, "missing save table" end - local main, bak, tmp = slotNames(version, slotId) + local main, bak, tmp = slotNames(key, slotId) local encoded = SaveSerializer.encode(saveTable) local fs = persistFs(nil) ensureParentDir(fs, main) @@ -1164,52 +1303,300 @@ function SaveData.writeSlot(version, slotId, saveTable) return true end +function SaveData.writeSlot(version, slotId, saveTable) + version = version or GameVersion.get() + if not knownVersion(version) then return false, "unknown version" end + return writeSlotIn(version, slotId, saveTable) +end + -- Delete a registered slot: remove its main/.bak/.tmp files, drop it from the -- options registry, and if it was active point active at another remaining -- slot (or clear active when the list is empty). Returns true, or false + -- an error string when the id is unknown / not registered. -function SaveData.deleteSlot(version, slotId) - version = version or GameVersion.get() - if not knownVersion(version) then return false, "unknown version" end +local function deleteSlotIn(key, slotId) if type(slotId) ~= "string" or slotId == "" then return false, "missing slot id" end local fs = persistFs(nil) - ensureVersionSlots(version, fs) + ensureSlots(key, fs) local opts = SaveData.loadOptions(fs) - opts.saveSlots = opts.saveSlots or {} - local reg = opts.saveSlots[version] - if not reg or not reg.list then return false, "slot not registered" end + local reg = registryOf(opts, key) + if not reg or type(reg.list) ~= "table" then return false, "slot not registered" end local found, idx = false, nil for i, id in ipairs(reg.list) do if id == slotId then found = true; idx = i; break end end if not found then return false, "slot not registered" end - local main, bak, tmp = slotNames(version, slotId) + local main, bak, tmp = slotNames(key, slotId) remove(fs, main) remove(fs, bak) remove(fs, tmp) table.remove(reg.list, idx) if reg.names then reg.names[slotId] = nil end + if reg.hashes then + reg.hashes[slotId] = nil + if next(reg.hashes) == nil then reg.hashes = nil end + end + if reg.broken then + reg.broken[slotId] = nil + if next(reg.broken) == nil then reg.broken = nil end + end if reg.active == slotId then reg.active = reg.list[1] -- may be nil when the list is now empty end - opts.saveSlots[version] = reg + putRegistry(opts, key, reg) SaveData.saveOptions(opts, fs) - slotsChecked[version] = true - activeSlotCache[version] = reg.active or false + slotsChecked[key] = true + activeSlotCache[key] = reg.active or false return true end --- Test seam: drop the process-global slot cache so a suite can exercise --- migration/resolution against a freshly injected filesystem. Unused by --- the game, which resolves each version exactly once per boot. +function SaveData.deleteSlot(version, slotId) + version = version or GameVersion.get() + if not knownVersion(version) then return false, "unknown version" end + return deleteSlotIn(version, slotId) +end + +-- Test seam: drop the process-global slot cache (and the active cart) so a +-- suite can exercise migration/resolution against a freshly injected +-- filesystem. Unused by the game, which resolves each scope exactly once per +-- boot. function SaveData.resetSlotState() for k in pairs(activeSlotCache) do activeSlotCache[k] = nil end for k in pairs(slotsChecked) do slotsChecked[k] = nil end freshPlaythrough = nil + activeCart, activeCartHash = nil, nil + sealBroken = false +end + +-- ------- custom carts + +function SaveData.setCart(cartId, cartHash) + if cartId ~= nil and cartKey(cartId) then + activeCart = cartId + activeCartHash = (type(cartHash) == "string" and cartHash ~= "") and cartHash or nil + else + activeCart, activeCartHash = nil, nil + end + return activeCart +end + +function SaveData.getCart() + return activeCart +end + +-- The player's answer for one mod a cart pins, or nil while they have not +-- given one and the cart's own switch stands. +function SaveData.cartModEnabled(options, cartId, modId) + if not cartKey(cartId) or type(modId) ~= "string" then return nil end + local root = type(options) == "table" and options.cartMods or nil + local bucket = type(root) == "table" and root[cartId] or nil + if type(bucket) == "table" and type(bucket[modId]) == "boolean" then + return bucket[modId] + end + return nil +end + +-- Kept apart from options.mods so switching a cart's mod never changes what +-- the base game runs, and vice versa. +function SaveData.setCartModEnabled(cartId, modId, enabled, fs) + if not cartKey(cartId) or type(modId) ~= "string" or modId == "" then + return false + end + local opts = SaveData.loadOptions(fs) + local root = type(opts.cartMods) == "table" and opts.cartMods or {} + local bucket = type(root[cartId]) == "table" and root[cartId] or {} + bucket[modId] = enabled and true or false + root[cartId], opts.cartMods = bucket, root + return SaveData.saveOptions(opts, fs) ~= nil +end + +-- Seed the active cart's shipped settings into its overlay, once ever. Keys +-- outside CART_OPTION_KEYS are ignored; a later player change is never redone. +function SaveData.seedCartOptions(defaults, fs) + if not (activeCart and type(defaults) == "table") then return false end + local opts = SaveData.loadOptions(fs) + local seeded = type(opts.cartOptionsSeeded) == "table" + and opts.cartOptionsSeeded or {} + if seeded[activeCart] then return false end + local root = type(opts.cartOptions) == "table" and opts.cartOptions or {} + local bucket = type(root[activeCart]) == "table" and root[activeCart] or {} + for _, key in ipairs(SaveData.CART_OPTION_KEYS) do + if defaults[key] ~= nil and bucket[key] == nil then + bucket[key] = defaults[key] + opts[key] = defaults[key] + end + end + root[activeCart], opts.cartOptions = bucket, root + seeded[activeCart], opts.cartOptionsSeeded = true, seeded + return SaveData.saveOptions(opts, fs) ~= nil +end + +function SaveData.setCartHash(cartHash) + activeCartHash = (type(cartHash) == "string" and cartHash ~= "") and cartHash or nil + return activeCartHash +end + +function SaveData.getCartHash() + return activeCartHash +end + +function SaveData.breakSeal(save) + sealBroken = true + if type(save) == "table" then + save.meta = type(save.meta) == "table" and save.meta or {} + save.meta.sealBroken = true + end + return true +end + +function SaveData.isSealBroken(save) + if type(save) == "table" then + return type(save.meta) == "table" and save.meta.sealBroken == true + end + return sealBroken +end + +function SaveData.listCartSlots(cartId) + local key = cartKey(cartId or activeCart) + if not key then return {} end + return listSlotsIn(key) +end + +function SaveData.createCartSlot(cartId) + local key = cartKey(cartId or activeCart) + if not key then return nil end + return createSlotIn(key) +end + +function SaveData.setActiveCartSlot(cartId, slotId) + local key = cartKey(cartId or activeCart) + if not key then return nil end + if type(slotId) ~= "string" or slotId == "" then return nil end + return setActiveSlotIn(key, slotId) +end + +function SaveData.activeCartSlot(cartId) + local key = cartKey(cartId or activeCart) + if not key then return nil end + return activeSlotIn(key) +end + +function SaveData.renameCartSlot(cartId, slotId, name) + local key = cartKey(cartId or activeCart) + if not key then return false, "unknown cart" end + return renameSlotIn(key, slotId, name) +end + +function SaveData.deleteCartSlot(cartId, slotId) + local key = cartKey(cartId or activeCart) + if not key then return false, "unknown cart" end + return deleteSlotIn(key, slotId) +end + +function SaveData.writeCartSlot(cartId, slotId, saveTable) + local key = cartKey(cartId or activeCart) + if not key then return false, "unknown cart" end + local ok, err = writeSlotIn(key, slotId, saveTable) + if not ok then return ok, err end + local hash = type(saveTable.meta) == "table" and saveTable.meta.cartHash or nil + if type(hash) == "string" and hash ~= "" then + SaveData.setSlotCartHash(cartId or activeCart, slotId, hash) + end + return true +end + +function SaveData.slotCartHash(cartId, slotId) + local key = cartKey(cartId or activeCart) + if not key or type(slotId) ~= "string" then return nil end + local reg = registryOf(SaveData.loadOptions(persistFs(nil)), key) + local hash = reg and type(reg.hashes) == "table" and reg.hashes[slotId] or nil + if type(hash) ~= "string" or hash == "" then return nil end + return hash +end + +function SaveData.setSlotCartHash(cartId, slotId, cartHash) + local key = cartKey(cartId or activeCart) + if not key then return false, "unknown cart" end + if type(slotId) ~= "string" or slotId == "" then + return false, "missing slot id" + end + local fs = persistFs(nil) + local opts = SaveData.loadOptions(fs) + local reg = registryOf(opts, key) + if not reg or type(reg.list) ~= "table" then return false, "slot not registered" end + local found = false + for _, id in ipairs(reg.list) do + if id == slotId then found = true break end + end + if not found then return false, "slot not registered" end + local hash = (type(cartHash) == "string" and cartHash ~= "") and cartHash or nil + reg.hashes = type(reg.hashes) == "table" and reg.hashes or {} + if reg.hashes[slotId] == hash then return true end + reg.hashes[slotId] = hash + if next(reg.hashes) == nil then reg.hashes = nil end + putRegistry(opts, key, reg) + SaveData.saveOptions(opts, fs) + return true +end + +function SaveData.slotSealBroken(cartId, slotId) + local key = cartKey(cartId or activeCart) + if not key or type(slotId) ~= "string" then return false end + local reg = registryOf(SaveData.loadOptions(persistFs(nil)), key) + local broken = reg and type(reg.broken) == "table" and reg.broken[slotId] + return broken == true +end + +function SaveData.markSlotSealBroken(cartId, slotId) + local key = cartKey(cartId or activeCart) + if not key then return false, "unknown cart" end + if type(slotId) ~= "string" or slotId == "" then + return false, "missing slot id" + end + local fs = persistFs(nil) + local opts = SaveData.loadOptions(fs) + local reg = registryOf(opts, key) + if not reg or type(reg.list) ~= "table" then return false, "slot not registered" end + local found = false + for _, id in ipairs(reg.list) do + if id == slotId then found = true break end + end + if not found then return false, "slot not registered" end + reg.broken = type(reg.broken) == "table" and reg.broken or {} + if reg.broken[slotId] == true then return true end + reg.broken[slotId] = true + putRegistry(opts, key, reg) + SaveData.saveOptions(opts, fs) + return true +end + +function SaveData.adoptCartSeal(cartId) + local id = cartId or activeCart + if not cartKey(id) then return false end + local slot = SaveData.activeCartSlot(id) + if type(slot) ~= "string" or not SaveData.slotSealBroken(id, slot) then + return false + end + SaveData.breakSeal() + return true +end + +local function stampActiveCartHash(fs) + if not (activeCart and activeCartHash) then return end + local key = CART_PREFIX .. activeCart + local slot = activeSlotCache[key] + if type(slot) ~= "string" then return end + local opts = SaveData.loadOptions(fs) + local reg = registryOf(opts, key) + if not reg or type(reg.list) ~= "table" then return end + reg.hashes = type(reg.hashes) == "table" and reg.hashes or {} + if reg.hashes[slot] == activeCartHash then return end + reg.hashes[slot] = activeCartHash + putRegistry(opts, key, reg) + SaveData.saveOptions(opts, fs) end -- ------- opaque playthrough identity @@ -1235,10 +1622,10 @@ function SaveData.newPlaythroughId() end local function playthroughScope(version, injectedFs) - version = version or GameVersion.get() + local key = activeScopeKey(version) local fs = persistFs(injectedFs) - ensureVersionSlots(version, fs) - return activeSlotCache[version] or "legacy" + ensureSlots(key, fs) + return activeSlotCache[key] or "legacy", key end local function rememberPlaythroughId(save, opts, injectedFs) @@ -1246,7 +1633,7 @@ local function rememberPlaythroughId(save, opts, injectedFs) local id = type(meta) == "table" and meta.playthroughId if type(id) ~= "string" or id == "" then return opts, false end local version = save.version or GameVersion.get() - local scope = playthroughScope(version, injectedFs) + local scope, key = playthroughScope(version, injectedFs) local persisted = SaveData.loadOptions(injectedFs) if opts then -- Slot selection and opaque playthrough routing are engine-owned launcher @@ -1254,14 +1641,15 @@ local function rememberPlaythroughId(save, opts, injectedFs) -- save was promoted to slot1; writing that stale snapshot must not erase -- the freshly persisted routing and strand tool storage on next boot. opts.saveSlots = deepCopy(persisted.saveSlots) + opts.cartSlots = deepCopy(persisted.cartSlots) opts.playthroughIds = deepCopy(persisted.playthroughIds) else opts = persisted end opts.playthroughIds = opts.playthroughIds or {} - opts.playthroughIds[version] = opts.playthroughIds[version] or {} - local changed = opts.playthroughIds[version][scope] ~= id - opts.playthroughIds[version][scope] = id + opts.playthroughIds[key] = opts.playthroughIds[key] or {} + local changed = opts.playthroughIds[key][scope] ~= id + opts.playthroughIds[key][scope] = id return opts, changed end @@ -1275,11 +1663,11 @@ function SaveData.ensurePlaythroughId(save, injectedFs) if type(id) == "string" and id ~= "" then return id end local version = save.version or GameVersion.get() - local scope = playthroughScope(version, injectedFs) + local scope, key = playthroughScope(version, injectedFs) local opts = SaveData.loadOptions(injectedFs) local isFresh = save == freshPlaythrough if isFresh then freshPlaythrough = nil end - local byVersion = opts.playthroughIds and opts.playthroughIds[version] + local byVersion = opts.playthroughIds and opts.playthroughIds[key] local existing = byVersion and byVersion[scope] id = not isFresh and existing or nil if type(id) ~= "string" or id == "" then @@ -1296,8 +1684,8 @@ function SaveData.ensurePlaythroughId(save, injectedFs) -- storage and repeating on every launch. if not (isFresh and type(existing) == "string" and existing ~= "") then opts.playthroughIds = opts.playthroughIds or {} - opts.playthroughIds[version] = opts.playthroughIds[version] or {} - opts.playthroughIds[version][scope] = id + opts.playthroughIds[key] = opts.playthroughIds[key] or {} + opts.playthroughIds[key][scope] = id SaveData.saveOptions(opts, injectedFs) end end @@ -1325,9 +1713,9 @@ function SaveData.selectedPlaythroughId(save, injectedFs) -- Resolve the selected scope first. That may perform the one-time legacy -- save-to-slot migration, which also moves the opaque identity mapping; only -- then read options so this lookup never observes the pre-migration table. - local scope = playthroughScope(version, injectedFs) + local scope, key = playthroughScope(version, injectedFs) local opts = SaveData.loadOptions(injectedFs) - local byVersion = opts.playthroughIds and opts.playthroughIds[version] + local byVersion = opts.playthroughIds and opts.playthroughIds[key] id = byVersion and byVersion[scope] or nil if type(id) ~= "string" or id == "" then return nil, "no_selected_playthrough", @@ -1394,6 +1782,8 @@ function SaveData.buildMeta(mods, previous, sessionStart) savedAt = savedAt, sessionStart = started, playthroughId = type(previous) == "table" and previous.playthroughId or nil, + cartHash = type(previous) == "table" and previous.cartHash or nil, + sealBroken = (type(previous) == "table" and previous.sealBroken == true) or nil, mods = list, } end @@ -1486,8 +1876,10 @@ function SaveData.runMigrations(save, modChains, activeMods) -- every step whose from-format the save has not passed yet runs, in -- (from, registration) order; a save at the current format runs none local fmt = (save.meta and save.meta.format) or 1 - for _, m in ipairs(coreMigrations) do - if m.from >= fmt then m.fn(save) end + if GameVersion.generation() == 1 then + for _, m in ipairs(coreMigrations) do + if m.from >= fmt then m.fn(save) end + end end -- a save that predates meta records an empty mod set: an old vanilla -- save becomes a v2 vanilla save @@ -1617,6 +2009,12 @@ function SaveData.save(data, mods) if mods ~= nil or data.meta == nil then data.meta = SaveData.buildMeta(mods, data.meta) end + if activeCart and activeCartHash and type(data.meta) == "table" then + data.meta.cartHash = activeCartHash + end + if sealBroken and type(data.meta) == "table" then + data.meta.sealBroken = true + end local gameOnly = {} for k, v in pairs(data) do if k ~= "options" then gameOnly[k] = v end @@ -1644,6 +2042,7 @@ function SaveData.save(data, mods) return false end remove(fs, TMP_FILENAME) + stampActiveCartHash(fs) Logger.info("saved game") return true end diff --git a/src/core/Sensors.lua b/src/core/Sensors.lua new file mode 100644 index 00000000..446eb283 --- /dev/null +++ b/src/core/Sensors.lua @@ -0,0 +1,178 @@ +-- Accelerometer and gyroscope reads for the tilt-driven shader presets, plus +-- screen-rotation compensation. love.sensor does not exist in LOVE 11.5 on any +-- platform, so the working path is raw FFI into the SDL2 LOVE already links, +-- the same technique src/core/Orientation.lua uses. What is and is not verified +-- on real hardware is written down in docs/shaderfx.md. + +local Sensors = {} + +-- Test seams: a no-device harness injects synthetic readings. +local overrides = {} +local orientationOverride = nil + +function Sensors.setOverride(kind, x, y, z) + overrides[kind] = { x, y, z } +end + +function Sensors.clearOverride(kind) + overrides[kind] = nil +end + +function Sensors.clearAllOverrides() + overrides = {} + orientationOverride = nil +end + +-- "landscape" | "landscapeFlipped" | "portrait" | "portraitFlipped" | nil +-- (nil = ask SDL for real). Test seam for rotateForScreen below. +function Sensors.setOrientationOverride(o) + orientationOverride = o +end + +-- ---- love.sensor path (kept for a future LOVE 12 upgrade; always nil today). +local function safeHasSensor(kind) + if not love or not love.sensor then return false end + local ok, has = pcall(love.sensor.hasSensor, kind) + return ok and has == true +end + +local function loveSensorRead(kind) + if not safeHasSensor(kind) then return nil end + local ok, x, y, z = pcall(love.sensor.getData, kind) + if not ok then return nil end + return x or 0, y or 0, z or 0 +end + +-- ---- raw SDL2 FFI path (the one that actually works on LÖVE 11.5). +local SDL_SENSOR_ACCEL, SDL_SENSOR_GYRO = 1, 2 +local SDL_INIT_SENSOR = 0x00008000 +local KIND_TO_SDL_TYPE = { accelerometer = SDL_SENSOR_ACCEL, gyroscope = SDL_SENSOR_GYRO } + +local sdlCdefOk = nil +local sdlSensorHandles = {} -- kind -> SDL_Sensor* (nil once confirmed absent) +local sdlSensorTried = {} + +-- ffi.load("SDL2") first, needed on desktop where SDL2 is a separate library, +-- then bare ffi.C, needed on Android where love-android links SDL2 statically +-- into libmain.so and there is no standalone libSDL2.so to attach to by name +-- (Orientation.lua's own sdlFfi resolves the same way on real hardware, #592/#716). +local sdlLib = nil +local function sdlFfi() + local okFfi, ffi = pcall(require, "ffi") + if not okFfi then return nil end + if sdlCdefOk == nil then + sdlCdefOk = pcall(ffi.cdef, [[ + typedef struct SDL_Window SDL_Window; + typedef struct SDL_Sensor SDL_Sensor; + int SDL_InitSubSystem(unsigned int flags); + int SDL_NumSensors(void); + int SDL_SensorGetDeviceType(int device_index); + SDL_Sensor *SDL_SensorOpen(int device_index); + void SDL_SensorUpdate(void); + int SDL_SensorGetData(SDL_Sensor *sensor, float *data, int num_values); + SDL_Window *SDL_GL_GetCurrentWindow(void); + int SDL_GetWindowDisplayIndex(SDL_Window *window); + int SDL_GetDisplayOrientation(int displayIndex); + ]]) + if sdlCdefOk then + local okLoad, lib = pcall(ffi.load, "SDL2") + sdlLib = okLoad and lib or ffi.C + end + end + if not sdlCdefOk or not sdlLib then return nil end + return sdlLib +end + +-- Opens and caches once, and returns nil permanently once a real attempt finds +-- none, so a no-hardware desktop run pays this cost once rather than per frame. +local function sdlSensorHandle(lib, kind) + if sdlSensorTried[kind] then return sdlSensorHandles[kind] end + sdlSensorTried[kind] = true + local wantType = KIND_TO_SDL_TYPE[kind] + if not wantType then return nil end + local okInit = pcall(lib.SDL_InitSubSystem, SDL_INIT_SENSOR) + if not okInit then return nil end + local okCount, count = pcall(lib.SDL_NumSensors) + if not okCount or count <= 0 then return nil end + for i = 0, count - 1 do + local okType, t = pcall(lib.SDL_SensorGetDeviceType, i) + if okType and t == wantType then + local okOpen, handle = pcall(lib.SDL_SensorOpen, i) + if okOpen and handle ~= nil then + sdlSensorHandles[kind] = handle + return handle + end + end + end + return nil +end + +local function sdlSensorRead(kind) + local lib = sdlFfi() + if not lib then return nil end + local handle = sdlSensorHandle(lib, kind) + if not handle then return nil end + pcall(lib.SDL_SensorUpdate) + local data = require("ffi").new("float[3]") + local okGet, n = pcall(lib.SDL_SensorGetData, handle, data, 3) + if not okGet or n ~= 0 then return nil end + return data[0], data[1], data[2] +end + +-- ---- screen-rotation compensation (x/y only; z is perpendicular to the screen +-- and unaffected by an in-plane rotation). Mobile-only: a desktop monitor is +-- legitimately, permanently "landscape" to SDL_GetDisplayOrientation. +local function isMobile() + if not love or not love.system or not love.system.getOS then return false end + local os = love.system.getOS() + return os == "Android" or os == "iOS" +end + +local function sdlOrientation() + if not isMobile() then return nil end + local lib = sdlFfi() + if not lib then return nil end + local okWin, win = pcall(lib.SDL_GL_GetCurrentWindow) + if not okWin or win == nil then return nil end + local okIdx, idx = pcall(lib.SDL_GetWindowDisplayIndex, win) + if not okIdx or idx < 0 then return nil end + local okOr, o = pcall(lib.SDL_GetDisplayOrientation, idx) + if not okOr then return nil end + -- SDL_DisplayOrientation: 0=UNKNOWN, 1=LANDSCAPE, 2=LANDSCAPE_FLIPPED, + -- 3=PORTRAIT, 4=PORTRAIT_FLIPPED. + if o == 1 then return "landscape" + elseif o == 2 then return "landscapeFlipped" + elseif o == 3 then return "portrait" + elseif o == 4 then return "portraitFlipped" + else return nil end +end + +-- Remaps raw device-frame (x,y) into "as currently displayed" (x,y). portrait, +-- and unknown/undetectable (desktop always lands here), is a no-op. +local function rotateForScreen(x, y) + local o = orientationOverride or sdlOrientation() + if o == "landscape" then return -y, x + elseif o == "landscapeFlipped" then return y, -x + elseif o == "portraitFlipped" then return -x, -y + else return x, y end +end + +-- Returns x, y, z for "accelerometer" | "gyroscope". {0,0,0} when overridden +-- that way, no hardware/module is available, or an underlying call errors. +-- x/y are rotated to be screen-relative; z passes through unchanged. +function Sensors.read(kind) + local override = overrides[kind] + if override then + local x, y = rotateForScreen(override[1], override[2]) + return x, y, override[3] + end + + local x, y, z = loveSensorRead(kind) + if not x then x, y, z = sdlSensorRead(kind) end + if not x then return 0, 0, 0 end + + x, y = rotateForScreen(x, y) + return x, y, z +end + +return Sensors diff --git a/src/core/Sound.lua b/src/core/Sound.lua index f2982353..01a82299 100644 --- a/src/core/Sound.lua +++ b/src/core/Sound.lua @@ -363,6 +363,14 @@ function Sound.sfxBusy() return true end +-- WaitSFX (home/audio.asm), the drain above GiveItemScript's `specialsound` +-- (engine/overworld/scripting.asm:445), so ch5-ch8 are free for it (#1483). +function Sound.waitSfxDone() + if not curSfx then return end + pcall(curSfx.src.stop, curSfx.src) + curSfx = nil +end + local function startSfx(data, name, def) local src = playPath(data, name, def) if not src then return end diff --git a/src/core/gen2/BattleTower.lua b/src/core/gen2/BattleTower.lua new file mode 100644 index 00000000..9a18cda9 --- /dev/null +++ b/src/core/gen2/BattleTower.lua @@ -0,0 +1,503 @@ +-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1 and +-- ../pokecrystal/engine/events/battle_tower/rules.asm:27. + +local Breeding = require("src.core.gen2.Breeding") +local HallOfFame = require("src.core.gen2.HallOfFame") +local Mon = require("src.battle.gen2.Mon") +local Save = require("src.core.gen2.Save") + +local BattleTower = {} + +-- ../pokecrystal/constants/battle_tower_constants.asm:1-7 +BattleTower.PARTY_LENGTH = 3 +BattleTower.STREAK_LENGTH = 7 +BattleTower.NUM_UNIQUE_MON = 21 +BattleTower.NUM_UNIQUE_TRAINERS = 70 +BattleTower.TRAINERDATALENGTH = 36 + +-- ../pokecrystal/constants/battle_tower_constants.asm:47 GS_BALL_AVAILABLE +BattleTower.GS_BALL_AVAILABLE = 0x0b + +-- ../pokecrystal/constants/battle_tower_constants.asm:55-61 +BattleTower.NO_CHALLENGE = 0 +BattleTower.SAVED_AND_LEFT = 1 +BattleTower.CHALLENGE_IN_PROGRESS = 2 +BattleTower.WON_CHALLENGE = 3 +BattleTower.RECEIVED_REWARD = 4 + +-- ../pokecrystal/constants/battle_tower_constants.asm:63-67 +BattleTower.REWARD_QUANTITY = 5 +BattleTower.MIN_REWARD = "HP_UP" +BattleTower.MAX_REWARD = "CALCIUM" +BattleTower.SKIPPED_REWARD = "LUCKY_PUNCH" +BattleTower.FALLBACK_REWARD = "POTION" + +-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1471-1492 bit 0, +-- :978-1008 bit 1 of sBattleTowerSaveFileFlags. +BattleTower.SAVEFILE_REGISTERED = 1 +BattleTower.SAVEFILE_EXPLANATION = 2 + +-- ../pokecrystal/constants/battle_tower_constants.asm:11-43 +BattleTower.ACTIONS = { + CHECK_EXPLANATION_READ = 0, + SET_EXPLANATION_READ = 1, + GET_CHALLENGE_STATE = 2, + SAVE_AND_QUIT = 3, + CHALLENGECANCELED = 4, + ACTION_05 = 5, + ACTION_06 = 6, + SAVELEVELGROUP = 7, + LOADLEVELGROUP = 8, + CHECKSAVEFILEISYOURS = 9, + ACTION_0A = 10, + GSBALL = 11, + ACTION_0C = 12, + ACTION_0D = 13, + EGGTICKET = 14, + ACTION_0F = 15, + ACTION_10 = 16, + ACTION_11 = 17, + ACTION_12 = 18, + ACTION_13 = 19, + ACTION_14 = 20, + ACTION_15 = 21, + ACTION_16 = 22, + ACTION_17 = 23, + LEVEL_CHECK = 24, + UBERS_CHECK = 25, + RESETDATA = 26, + GIVEREWARD = 27, + ACTION_1C = 28, + ACTION_1D = 29, + CHOOSEREWARD = 30, + SAVEOPTIONS = 31, +} +BattleTower.NUM_ACTIONS = 32 + +-- ../pokecrystal/ram/wram.asm:1703 wNrOfBeatenBattleTowerTrainers, which +-- ../pokecrystal/maps/BattleTowerBattleRoom.asm:38 reads back with `readmem`. +BattleTower.WRAM_NR_BEATEN = 0xcf64 + +-- ../pokecrystal/constants/battle_tower_constants.asm:49-53 `battletowertext`. +BattleTower.TEXT_INTRO = 1 +BattleTower.TEXT_WIN = 2 +BattleTower.TEXT_LOSS = 3 + +-- ../pokecrystal/engine/events/battle_tower/rules.asm:39-55, in the order +-- BattleTower_ExecuteJumptable prints them. +BattleTower.RULE_HEADER_TEXT = "_ExcuseMeYoureNotReadyText" +BattleTower.RULE_FAIL_TEXTS = { + "_OnlyThreeMonMayBeEnteredText", + "_TheMonMustAllBeDifferentKindsText", + "_TheMonMustNotHoldTheSameItemsText", + "_YouCantTakeAnEggText", +} +-- ../pokecrystal/engine/events/battle_tower/rules.asm:61-68 +BattleTower.RULE_TAIL_TEXT = "_BattleTowerReturnWhenReadyText" +-- ../pokecrystal/engine/events/battle_tower/rules.asm:28-31 wStringBuffer2. +BattleTower.RULE_PARTY_COUNT_TEXT = "3" + +-- ../pokecrystal/mobile/mobile_46.asm:3936-3958 BattleTower_UbersCheck. +BattleTower.UBERS = { + MEWTWO = true, MEW = true, LUGIA = true, HO_OH = true, CELEBI = true, +} +BattleTower.UBER_MIN_LEVEL = 70 + +-- ../pokecrystal/mobile/mobile_46.asm:3869-3887 Strings_L10ToL100 and +-- Strings_Ll0ToL40. +BattleTower.MAX_LEVEL_GROUP = 10 +BattleTower.PRE_HOF_LEVEL_GROUPS = 4 + +local function counter(value) + return math.max(0, math.floor(tonumber(value) or 0)) +end + +-- ../pokecrystal/ram/sram.asm:147-172, on top of Save.battleTowerState. +function BattleTower.state(save) + local tower = Save.battleTowerState(save) + -- ../pokecrystal/ram/sram.asm:156 sBTChoiceOfLevelGroup + tower.levelGroup = counter(tower.levelGroup) + -- ../pokecrystal/ram/sram.asm:159 sBattleTowerSaveFileFlags + tower.saveFileFlags = counter(tower.saveFileFlags) % 256 + -- ../pokecrystal/ram/sram.asm:158 sBTTrainers, $ff for an unused slot + if type(tower.trainers) ~= "table" then tower.trainers = {} end + -- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1449-1469 s5_aa8d + if tower.reentry == nil then tower.reentry = false end + return tower +end + +-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1471-1483 and +-- :984-989: `and 1` / `and 2`, so the answer is the MASKED byte, not a boolean. +function BattleTower.saveFileFlag(save, mask) + local tower = BattleTower.state(save) + return (math.floor(tower.saveFileFlags / mask) % 2 == 1) and mask or 0 +end + +function BattleTower.setSaveFileFlag(save, mask) + local tower = BattleTower.state(save) + if math.floor(tower.saveFileFlags / mask) % 2 == 0 then + tower.saveFileFlags = tower.saveFileFlags + mask + end + return tower.saveFileFlags +end + +-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:890-904 +function BattleTower.resetTrainers(save) + local tower = BattleTower.state(save) + tower.trainers = {} + tower.streak = 0 + return tower +end + +-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1016-1022 +function BattleTower.setChallengeState(save, state) + local tower = BattleTower.state(save) + tower.challenge = counter(state) + return tower.challenge +end + +-- ../pokecrystal/engine/events/battle_tower/rules.asm:206-211 +function BattleTower.partyCountOk(party) + return #party == BattleTower.PARTY_LENGTH +end + +-- ../pokecrystal/engine/events/battle_tower/rules.asm:218-277 +-- CheckPartyValueIsUnique: eggs are skipped on both sides and a zero value +-- never collides, because `ld a, [hl] / and a / jr z, .next` drops it first. +local function valuesUnique(party, get) + local count = #party + for i = 1, count - 1 do + local mon = party[i] + if not Breeding.isEgg(mon) then + local value = get(mon) + if value ~= nil and value ~= 0 then + for j = i + 1, count do + local other = party[j] + if not Breeding.isEgg(other) and get(other) == value then + return false + end + end + end + end + end + return true +end + +local function speciesOf(mon) return mon and mon.species or nil end +local function itemOf(mon) return mon and mon.item or nil end + +-- ../pokecrystal/engine/events/battle_tower/rules.asm:213-216 +function BattleTower.speciesUnique(party) + return valuesUnique(party, speciesOf) +end + +-- ../pokecrystal/engine/events/battle_tower/rules.asm:279-282 +function BattleTower.itemsUnique(party) + return valuesUnique(party, itemOf) +end + +-- ../pokecrystal/engine/events/battle_tower/rules.asm:284-299 +function BattleTower.partyHasEgg(party) + for _, mon in ipairs(party) do + if Breeding.isEgg(mon) then return true end + end + return false +end + +-- ../pokecrystal/engine/events/battle_tower/rules.asm:27-37 and :94-103: every +-- check runs, the first failure prints the header first, and a tail line +-- follows any failure at all. Returns the text labels in printing order. +function BattleTower.checkRules(party) + party = party or {} + local failed = { + not BattleTower.partyCountOk(party), + not BattleTower.speciesUnique(party), + not BattleTower.itemsUnique(party), + BattleTower.partyHasEgg(party), + } + local lines, any = {}, false + for index = 1, #BattleTower.RULE_FAIL_TEXTS do + if failed[index] then + if not any then + any = true + lines[#lines + 1] = BattleTower.RULE_HEADER_TEXT + end + lines[#lines + 1] = BattleTower.RULE_FAIL_TEXTS[index] + end + end + if any then lines[#lines + 1] = BattleTower.RULE_TAIL_TEXT end + return lines, any +end + +-- ../pokecrystal/mobile/mobile_46.asm:1156-1166: the Hall of Fame flag is what +-- opens rooms above L40, and the last row is always CANCEL. +function BattleTower.levelGroupCount(save) + if HallOfFame.hasEntered(save) then return BattleTower.MAX_LEVEL_GROUP end + return BattleTower.PRE_HOF_LEVEL_GROUPS +end + +function BattleTower.levelGroupRows(save) + local rows = {} + for group = 1, BattleTower.levelGroupCount(save) do + rows[group] = { group = group, level = group * 10 } + end + return rows +end + +-- ../pokecrystal/mobile/mobile_46.asm:1264-1267 `dec a / and $fe / srl a`, the +-- BATTLE ROOM pair the hallway walks to (../pokecrystal/maps/BattleTowerHallway.asm:40-47). +function BattleTower.roomOf(group) + return math.floor((math.max(1, counter(group)) - 1) / 2) +end + +-- ../pokecrystal/mobile/mobile_46.asm:3892-3934 BattleTower_LevelCheck +function BattleTower.levelCheck(party, group) + local cap = counter(group) * 10 + for _, mon in ipairs(party or {}) do + if (tonumber(mon.level) or 0) > cap then return true end + end + return false +end + +-- ../pokecrystal/mobile/mobile_46.asm:3936-3989 BattleTower_UbersCheck: below +-- the L70 rooms an uber under L70 is refused, and its name goes in wcd49. +function BattleTower.ubersCheck(party, group) + if counter(group) >= BattleTower.UBER_MIN_LEVEL / 10 then return nil end + for _, mon in ipairs(party or {}) do + if BattleTower.UBERS[mon.species] + and (tonumber(mon.level) or 0) < BattleTower.UBER_MIN_LEVEL then + return mon.species + end + end + return nil +end + +-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:955-976: a +-- `maskbits` roll over HP_UP..CALCIUM that folds the overshoot back to the +-- bottom of the range and rerolls LUCKY_PUNCH. +function BattleTower.rollReward(order, random) + if type(order) ~= "table" then return nil end + local low, high + for index, name in pairs(order) do + if name == BattleTower.MIN_REWARD then low = index end + if name == BattleTower.MAX_REWARD then high = index end + end + if not (low and high) then return nil end + local span = high - low + 1 + local mask = 1 + while mask < span do mask = mask * 2 end + for _ = 1, 64 do + local roll = random(mask) % mask + if roll >= span then roll = roll - span end + local item = order[low + roll] + if item ~= BattleTower.SKIPPED_REWARD then return item end + end + return order[low] +end + +-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:906-933: five of +-- the reward only fit when the ITEM pocket has a free slot, or already holds +-- the reward with room for five more. Otherwise the desk hands over a POTION, +-- which the script reads as "your PACK is stuffed full". +function BattleTower.rewardFits(slots, capacity, held) + if counter(slots) < counter(capacity) then return true end + if held == nil then return false end + return counter(held) < 99 - BattleTower.REWARD_QUANTITY + 1 +end + +-- The opponent draw, all of it ../pokecrystal/engine/events/battle_tower/ +-- load_trainer.asm, over RomExtractorGen2's Crystal-only `battleTower` block. + +-- data/generated/trainers.lua `battleTower`, absent on Gold and Silver. +function BattleTower.roster(data) + local trainers = data and (data.gen2Trainers or data.trainers) + local roster = trainers and trainers.battleTower + if type(roster) ~= "table" then return nil end + if type(roster.trainers) ~= "table" or type(roster.groups) ~= "table" then + return nil + end + return roster +end + +-- ../pokecrystal/ram/sram.asm:162-173 sBTMonOfTrainers: the last team's three +-- species and the one before that's, which the draw refuses to repeat. +function BattleTower.prevTeams(save) + local tower = BattleTower.state(save) + local teams = tower.prevTeams + if type(teams) ~= "table" then + teams = {} + tower.prevTeams = teams + end + if type(teams.prev) ~= "table" then teams.prev = {} end + if type(teams.prevPrev) ~= "table" then teams.prevPrev = {} end + return teams +end + +-- ../pokecrystal/engine/events/battle_tower/load_trainer.asm:104-105 reads the +-- room back as `ld a, [wBTChoiceOfLvlGroup] / dec a`, so group 0 indexes +-- BEFORE the table; battle_tower.asm:1129-1141 is what can only save 1..10. +function BattleTower.opponentGroup(save, roster) + local groups = (roster and roster.levelGroups) or BattleTower.MAX_LEVEL_GROUP + local group = counter(BattleTower.state(save).levelGroup) + if group < 1 then return 1 end + if group > groups then return groups end + return group +end + +-- load_trainer.asm:24-38 and :101-166 reroll until the roll passes; one draw +-- from the survivors is the same distribution and cannot spin. An empty +-- survivor set is where the cart's own loop would hang, so it draws unfiltered. +local function drawFiltered(count, random, accept) + local pool = {} + for index = 1, count do + if accept(index) then pool[#pool + 1] = index end + end + if #pool == 0 then return random(count) end + return pool[random(#pool)] +end + +-- ../pokecrystal/engine/events/battle_tower/load_trainer.asm:22-60. The roll +-- is refused while it names anybody already in sBTTrainers, and the winner is +-- written into the slot sNrOfBeatenBattleTowerTrainers points at. +function BattleTower.chooseTrainer(save, roster, random) + local tower = BattleTower.state(save) + -- :29-37, the ceiling read out of the cart: Crystal 1.0 masks with + -- BATTLETOWER_NUM_UNIQUE_MON and can only ever draw the first 21 rows. + local ceiling = counter(roster.sampleTrainers) + if ceiling < 1 or ceiling > #roster.trainers then ceiling = #roster.trainers end + local seen = {} + for slot = 1, BattleTower.STREAK_LENGTH do + local held = tonumber(tower.trainers[slot]) + if held then seen[held] = true end + end + local index = drawFiltered(ceiling, random, function(row) + return not seen[row - 1] + end) + tower.trainers[math.min(counter(tower.streak), BattleTower.STREAK_LENGTH - 1) + + 1] = index - 1 + return roster.trainers[index] +end + +-- ../pokecrystal/engine/events/battle_tower/load_trainer.asm:94-208. Three +-- draws out of the chosen level group, each refusing a species this team +-- already holds, an ITEM this team already holds, and any species from the +-- last two teams. wBT_OTTrainer was zero-filled and its three item slots set +-- to $ff first (:7-17), which is why an unfilled slot collides with nothing. +function BattleTower.chooseTeam(save, roster, group, random) + local rows = roster.groups[group] or {} + local teams = BattleTower.prevTeams(save) + local picked, species, items = {}, {}, {} + for _ = 1, BattleTower.PARTY_LENGTH do + local index = drawFiltered(#rows, random, function(row) + local mon = rows[row] + if not mon then return false end + if species[mon.species] then return false end + if mon.item ~= nil and items[mon.item] then return false end + for _, seen in ipairs(teams.prev) do + if seen == mon.species then return false end + end + for _, seen in ipairs(teams.prevPrev) do + if seen == mon.species then return false end + end + return true + end) + local mon = rows[index] + if not mon then break end + picked[#picked + 1] = mon + species[mon.species] = true + if mon.item ~= nil then items[mon.item] = true end + end + -- :195-206, after all three: this team becomes sBTMonPrevTrainer and the + -- one it displaces becomes sBTMonPrevPrevTrainer. + local prev = {} + for slot, mon in ipairs(picked) do prev[slot] = mon.species end + teams.prevPrev = teams.prev + teams.prev = prev + return picked +end + +-- The whole of `special LoadOpponentTrainerAndPokemon`, as one record. The +-- cart keeps it in wBT_OTTrainer, which is WRAM bank 3 and is NOT saved -- +-- only the sBTTrainers slot and the two previous teams this walk writes are. +function BattleTower.drawOpponent(data, save, random) + local roster = BattleTower.roster(data) + if not roster then return nil end + local group = BattleTower.opponentGroup(save, roster) + local trainer = BattleTower.chooseTrainer(save, roster, random) + if not trainer then return nil end + local rows = BattleTower.chooseTeam(save, roster, group, random) + return { + index = trainer.index, + name = trainer.name, + class = trainer.class, + classId = trainer.classId, + sprite = roster.classSprites and roster.classSprites[trainer.classId], + group = group, + rows = rows, + } +end + +-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:549-570 +-- CopyBTTrainer_FromBT_OT_TowBT_OTTemp, which is the first thing +-- ReadBTTrainerParty does: the challenge is now in progress and the streak +-- counter steps BEFORE the battle, which is why beating the seventh opponent +-- leaves the counter on BATTLETOWER_STREAK_LENGTH. +function BattleTower.beginBattle(save) + local tower = BattleTower.state(save) + tower.challenge = BattleTower.CHALLENGE_IN_PROGRESS + tower.streak = counter(tower.streak) + 1 + return tower.streak +end + +-- ReadBTTrainerParty's .otpartymon_loop (battle_tower.asm:349-376) copies the +-- whole party_struct into wOTPartyMon, so nothing here is rolled: the stored +-- stats go on top of Mon.new's, which agree with them anyway. +-- The ROM nicknames never show: load_trainer.asm:171-190 overwrites each one +-- with GetPokemonName, so the rows carry none. +function BattleTower.battleParty(data, rows) + local party = {} + for _, row in ipairs(rows or {}) do + local moves = nil + if row.moves and #row.moves > 0 then + moves = {} + for slot, id in ipairs(row.moves) do + local def = data and data.moves and data.moves[id] + local max = (def and def.pp) or 0 + moves[slot] = { + id = id, + pp = (row.pp and row.pp[slot]) or max, + maxPp = max, + } + end + end + local dvs = row.dvs or {} + local statExp = row.statExp or {} + local mon = Mon.new(data, row.species, row.level, { + moves = moves, + item = row.item, + dvs = { attack = dvs.attack, defense = dvs.defense, + speed = dvs.speed, special = dvs.special }, + statExp = { hp = statExp.hp, attack = statExp.attack, + defense = statExp.defense, speed = statExp.speed, + special = statExp.special }, + happiness = row.happiness, + }) + if mon then + if row.stats then + mon.stats = { + hp = row.stats.hp, attack = row.stats.attack, + defense = row.stats.defense, speed = row.stats.speed, + specialAttack = row.stats.specialAttack, + specialDefense = row.stats.specialDefense, + } + end + mon.maxHp = row.maxHp or mon.maxHp + mon.hp = row.hp or mon.maxHp + mon.experience = row.experience or mon.experience + party[#party + 1] = mon + end + end + return party +end + +return BattleTower diff --git a/src/core/gen2/Boxes.lua b/src/core/gen2/Boxes.lua index 7a87bd93..fa5a702d 100644 --- a/src/core/gen2/Boxes.lua +++ b/src/core/gen2/Boxes.lua @@ -109,6 +109,17 @@ function Boxes.restorePP(mon) end end +-- box_struct has no MON_STATUS and no MON_HP (macros/ram.asm:7-26), so +-- CalcTempmonStats refills a BOXMON from MAXHP (engine/pokemon/tempmon.asm:56-83). +function Boxes.enterBox(mon) + if not mon then return mon end + Boxes.restorePP(mon) + mon.status = nil + mon.statusTurns = nil + mon.hp = mon.isEgg and 0 or (mon.maxHp or mon.hp) + return mon +end + function Boxes.deposit(save, partyIndex, boxIndex) local ok, reason = Boxes.canDeposit(save, partyIndex, boxIndex) if not ok then return false, reason end @@ -122,7 +133,7 @@ function Boxes.deposit(save, partyIndex, boxIndex) box[#box + 1] = mon -- SendGetMonIntoFromBox's PC_DEPOSIT arm ends in RestorePPOfDepositedPokemon -- (engine/pokemon/move_mon.asm:633-635, :696-700). - Boxes.restorePP(mon) + Boxes.enterBox(mon) return true, mon end diff --git a/src/core/gen2/Breeding.lua b/src/core/gen2/Breeding.lua index 25a82de5..57db6832 100644 --- a/src/core/gen2/Breeding.lua +++ b/src/core/gen2/Breeding.lua @@ -883,6 +883,11 @@ function Breeding.withdraw(data, save, which) rebuilt.status = nil for _, move in ipairs(rebuilt.moves) do move.pp = move.maxPp end rebuilt.caughtLevel = mon.caughtLevel or rebuilt.caughtLevel + -- RetrieveBreedmon copies the stored struct back whole, MON_CAUGHTDATA + -- included -- engine/pokemon/move_mon.asm:805. + rebuilt.caughtTime = mon.caughtTime + rebuilt.caughtLocation = mon.caughtLocation + rebuilt.caughtByGender = mon.caughtByGender rebuilt.ot, rebuilt.otId = mon.ot, mon.otId -- CalcExpAtLevel, which is the experience loss. rebuilt.experience = Mon.experienceForLevel(growthOf(data, def), newLevel) @@ -1077,7 +1082,9 @@ end -- Returns the new record plus a table of side effects the caller owes: -- { species = , togepi = bool } -- SetSeenAndCaughtMon, and the -- EVENT_TOGEPI_HATCHED flag the ASM sets by hand for exactly one species. -function Breeding.hatch(data, save, index, nickname) +-- `where` is the hatch site for SetEggMonCaughtData: landmark, timeOfDay and +-- playerGender -- engine/pokemon/breeding.asm:228. +function Breeding.hatch(data, save, index, nickname, where) local party = (save and save.party) or {} local egg = party[index] if not Breeding.isEgg(egg) then return nil end @@ -1097,6 +1104,18 @@ function Breeding.hatch(data, save, index, nickname) hatched.ot = egg.ot or (save.player and save.player.name) hatched.otId = egg.otId or (save.player and save.player.id) hatched.caughtLevel = egg.level or Breeding.EGG_LEVEL + -- SetEggMonCaughtData swaps wCurPartyLevel for CAUGHT_EGG_LEVEL around the + -- shared setter -- engine/pokemon/caught_data.asm:235-246. + if Mon.hasCaughtData(save and save.version) then + where = where or {} + Mon.setCaughtData(hatched, { + level = Mon.CAUGHT_EGG_LEVEL, + timeOfDay = where.timeOfDay, + landmark = where.landmark, + playerGender = where.playerGender + or (save.player and save.player.gender), + }) + end party[index] = hatched Breeding.markPokedex(save, egg.species) diff --git a/src/core/gen2/BugContest.lua b/src/core/gen2/BugContest.lua index ffd332d5..6dc1521b 100644 --- a/src/core/gen2/BugContest.lua +++ b/src/core/gen2/BugContest.lua @@ -803,7 +803,9 @@ function BugContest.collectCaughtMon(save, partySize, boxes) end boxes = boxes or require("src.core.gen2.Boxes") local box = boxes.box(save, save.currentBox or 1) - if box then box[#box + 1] = mon end + -- .TryAddToBox copies BOXMON_STRUCT_LENGTH and tails into + -- RestorePPOfDepositedPokemon (engine/pokemon/caught_nickname.asm:72-90). + if box then box[#box + 1] = boxes.enterBox(mon) end return BugContest.BOXED_MON, mon end diff --git a/src/core/gen2/Evolution.lua b/src/core/gen2/Evolution.lua index d295debb..fe18a59d 100644 --- a/src/core/gen2/Evolution.lua +++ b/src/core/gen2/Evolution.lua @@ -357,6 +357,7 @@ Evolution.MON_FIELDS = { experience = true, dvs = true, stats = true, hp = true, maxHp = true, types = true, moves = true, item = true, status = true, happiness = true, caughtLevel = true, shiny = true, gender = true, + caughtTime = true, caughtLocation = true, caughtByGender = true, } -- Turn `mon` into `entry.into`. Returns the NEW record; the caller writes it @@ -410,6 +411,11 @@ function Evolution.apply(data, mon, entry) evolved.experience = mon.experience evolved.status = mon.status evolved.caughtLevel = mon.caughtLevel + -- Nothing between GetBaseData and the PARTYMON_STRUCT_LENGTH copy back + -- touches MON_CAUGHTDATA -- engine/pokemon/evolve.asm:291-293. + evolved.caughtTime = mon.caughtTime + evolved.caughtLocation = mon.caughtLocation + evolved.caughtByGender = mon.caughtByGender -- Anything a future field adds to a party record (mail, pokerus) rides along -- rather than being silently dropped. Only fields Mon.new does NOT own may -- be carried: copying `nickname` back would undo diff --git a/src/core/gen2/Happiness.lua b/src/core/gen2/Happiness.lua index c31daedf..400a058f 100644 --- a/src/core/gen2/Happiness.lua +++ b/src/core/gen2/Happiness.lua @@ -86,8 +86,11 @@ Happiness.EVENT = { ENERGYROOT = 16, -- 10 REVIVALHERB = 17, -- 11 GROOMING = 18, -- 12 + -- Crystal only; Gold's enum stops at GROOMING + -- (pokegold constants/pokemon_data_constants.asm:205). + GAINLEVELATHOME = 19, -- 13 } -Happiness.NUM_EVENTS = 18 +Happiness.NUM_EVENTS = 19 -- data/events/happiness_changes.asm, transcribed row for row. The three -- columns are the three tiers below, in order. @@ -110,6 +113,7 @@ Happiness.CHANGES = { { -10, -10, -15 }, -- 10 Used Energy Root (bitter) { -15, -15, -20 }, -- 11 Used Revival Herb (bitter) { 3, 3, 1 }, -- 12 Grooming + { 10, 6, 4 }, -- 13 Gained a level where it was caught (Crystal) } -- Which of HappinessChanges' three columns a CURRENT value reads. The cart @@ -174,6 +178,23 @@ function Happiness.change(mon, event) return value end +-- LevelUpHappinessMod: the caught location masked with CAUGHT_LOCATION_MASK +-- against the current landmark -- engine/pokemon/level_up_happiness.asm:1-20. +function Happiness.levelUpEvent(mon, landmark) + local caught = type(mon) == "table" and tonumber(mon.caughtLocation) or nil + landmark = tonumber(landmark) + if not (caught and landmark) then return "GAINLEVEL" end + if math.floor(caught) % 0x80 ~= math.floor(landmark) % 0x80 then + return "GAINLEVEL" + end + return "GAINLEVELATHOME" +end + +-- The `callfar ChangeHappiness` that follows -- level_up_happiness.asm:19. +function Happiness.levelUp(mon, landmark) + return Happiness.change(mon, Happiness.levelUpEvent(mon, landmark)) +end + -- The same event across a party, which is how the Gym Leader award is written -- out longhand in engine/battle/core.asm InitEnemyTrainer: -- diff --git a/src/core/gen2/Roamers.lua b/src/core/gen2/Roamers.lua index 9526f5f6..f60ce4e5 100644 --- a/src/core/gen2/Roamers.lua +++ b/src/core/gen2/Roamers.lua @@ -1,5 +1,5 @@ -- The two pieces of Gen 2 world state that override where a wild mon comes --- from: the three roaming legendaries, and swarms. +-- from: the roaming legendaries, and swarms. -- -- Both live in one module because they are the same KIND of thing -- a -- persistent record that sits in front of a map's own encounter table -- and @@ -26,6 +26,7 @@ -- pair InitRoamMons never writes and BattleEnd_HandleRoamMons writes when a -- beast is caught or beaten -- is nil. +local GameVersion = require("src.core.GameVersion") local Mon = require("src.battle.gen2.Mon") local Runtime = require("src.mods.Runtime") @@ -37,7 +38,7 @@ local Roamers = {} -- map connection or a door triggers) and JumpRoamMons (the scatter a fly or a -- teleport triggers) -- so a tracker mod sees every hop. -- --- index the roamer slot, 1 Raikou / 2 Entei / 3 Suicune +-- index the roamer slot, 1 Raikou / 2 Entei / 3 Suicune (Gold only) -- slot that roamer record, already carrying the new map -- species the beast's species id -- from the map id it left @@ -68,9 +69,31 @@ Roamers.SPECIES = { { species = "ENTEI", level = 40, map = "ROUTE_37" }, { species = "SUICUNE", level = 40, map = "ROUTE_38" }, } -Roamers.COUNT = 3 Roamers.LEVEL = 40 +-- pokecrystal/engine/overworld/wildmons.asm:493 seeds Raikou and Entei only, +-- so the roster is read out of the cache when the cache carries one. +local function startMapFor(species) + for _, row in ipairs(Roamers.SPECIES) do + if row.species == species then return row.map end + end + return nil +end + +function Roamers.roster(encounters) + local extracted = encounters and encounters.roamMons + if type(extracted) ~= "table" or #extracted == 0 then return Roamers.SPECIES end + local rows = {} + for _, row in ipairs(extracted) do + rows[#rows + 1] = { + species = row.species, + level = row.level or Roamers.LEVEL, + map = row.map or startMapFor(row.species), + } + end + return rows +end + -- data/wild/roammon_maps.asm, entry for entry and in order. The order matters -- twice over: `.Update` picks a connection by a two-bit index into the list, so -- shuffling one row changes which route a beast walks to, and JumpRoamMon picks @@ -185,7 +208,7 @@ end -- The save record -------------------------------------------------------------------------- -- --- save.roamers is a three-slot array in the order above, written first by the +-- save.roamers is a slot array in the roster order above, written first by the -- InitRoamMons special (src/script/gen2/Specials.lua) when the Burned Tower -- basement script fires. Each slot: -- @@ -215,13 +238,15 @@ function Roamers.slot(save, index) end -- InitRoamMons. Safe to call twice: the Burned Tower script is behind a scene --- flag, but a re-init would hand the player three fresh beasts, so this only --- writes when there is nothing there. +-- flag, but a re-init would hand the player fresh beasts, so this only writes +-- when there is nothing there. function Roamers.init(save, opts) if type(save) ~= "table" then return nil end if save.roamers and not (opts and opts.force) then return save.roamers end + local encounters = opts and (opts.encounters + or (opts.data and opts.data.gen2Encounters)) local list = {} - for _, row in ipairs(Roamers.SPECIES) do + for _, row in ipairs(Roamers.roster(encounters)) do list[#list + 1] = { species = row.species, level = row.level, @@ -413,7 +438,23 @@ end -- -- the roaming battle is one attack long unless it is trapped. The other -- two lists are the same routine's 50% and 10% gates and live here so the -- battle engine has one place to read them from. -Roamers.ALWAYS_FLEE = { RAIKOU = true, ENTEI = true, SUICUNE = true } +-- pokegold/data/wild/flee_mons.asm:34 lists Suicune; pokecrystal's:34 ends the +-- list at Entei, which is what makes maps/TinTower1F.asm:119 catchable. +Roamers.ALWAYS_FLEE_BY_ENGINE = { + gs = { RAIKOU = true, ENTEI = true, SUICUNE = true }, + crystal = { RAIKOU = true, ENTEI = true }, +} + +function Roamers.alwaysFleeMons(versionId) + return Roamers.ALWAYS_FLEE_BY_ENGINE[GameVersion.engine(versionId)] + or Roamers.ALWAYS_FLEE_BY_ENGINE.gs +end + +-- src/battle/gen2/Battle.lua:4070 reads AlwaysFleeMons by species name. +Roamers.ALWAYS_FLEE = setmetatable({}, { + __index = function(_, species) return Roamers.alwaysFleeMons()[species] end, +}) + Roamers.OFTEN_FLEE = { CUBONE = true, ARTICUNO = true, ZAPDOS = true, MOLTRES = true, QUAGSIRE = true, DELIBIRD = true, PHANPY = true, TEDDIURSA = true, @@ -433,29 +474,79 @@ local Swarm = {} Roamers.Swarm = Swarm -- The state, on the save: --- save.swarmMap wSwarmMapGroup / wSwarmMapNumber, as a map id +-- save.swarmMaps the stored map pairs, keyed by swarm kind +-- save.swarmMap the Gold single pair, kept as the legacy alias -- save.dailyFlags.swarm DAILYFLAGS1_SWARM_F -- save.dailyFlags.fishingSwarm wFishingSwarmFlag (FISHSWARM_* 0/1/2) -- save.dailyResetDay the day wDailyResetTimer was last restarted -- -- src/world/gen2/World.lua:setSwarm and the ActivateFishingSwarm special --- already write the first three; this module is where they are READ and where --- they expire. +-- already write the flags and the alias; this module is where they are READ +-- and where they expire. -- constants/script_constants.asm, ActivateFishingSwarm setval arguments. Swarm.FISH_NONE = 0 Swarm.FISH_QWILFISH = 1 Swarm.FISH_REMORAID = 2 +-- pokecrystal/constants/script_constants.asm:256-257 -- the kind byte Crystal +-- puts in front of a `swarm` map, choosing between its two stored pairs. +Swarm.KIND_ORDER = { "DUNSPARCE", "YANMA" } +Swarm.KINDS = { [0] = "DUNSPARCE", [1] = "YANMA" } +Swarm.DEFAULT_KIND = "DUNSPARCE" + +local KIND_NAMES = { DUNSPARCE = true, YANMA = true } + +local function kindName(kind) + if type(kind) == "number" then return Swarm.KINDS[kind] or Swarm.DEFAULT_KIND end + if KIND_NAMES[kind] then return kind end + return Swarm.DEFAULT_KIND +end + +-- pokegold/engine/events/specials.asm:288-293 has the one pair, so a Gold save +-- (and every save written before this record was keyed) folds onto that key. +local function goldShape(maps) + return maps.YANMA == nil +end + +function Swarm.maps(save) + if type(save) ~= "table" then return nil end + local maps = save.swarmMaps + if type(maps) ~= "table" then + maps = {} + save.swarmMaps = maps + end + if save.swarmMap ~= nil and goldShape(maps) then + maps[Swarm.DEFAULT_KIND] = save.swarmMap + end + return maps +end + +local function anyMap(save) + if type(save) ~= "table" then return false end + if save.swarmMap ~= nil then return true end + local maps = save.swarmMaps + if type(maps) ~= "table" then return false end + for _, name in ipairs(Swarm.KIND_ORDER) do + if maps[name] ~= nil then return true end + end + return false +end + -- StoreSwarmMapIndices, which FALLS THROUGH into SetSwarmFlag: one command -- writes the map pair AND the daily flag. A port that stored only the map -- would leave the Dunsparce call live for the rest of the game, because -- CheckSwarmFlag answers off the flag and clears the pair itself. -function Swarm.set(save, mapId) +-- +-- pokecrystal/engine/events/specials.asm:290-306 picks the pair off c instead. +function Swarm.set(save, mapId, kind) if type(save) ~= "table" then return false end save.dailyFlags = save.dailyFlags or {} save.dailyFlags.swarm = true - save.swarmMap = mapId + local name = kindName(kind) + local maps = Swarm.maps(save) + maps[name] = mapId + if name == Swarm.DEFAULT_KIND then save.swarmMap = mapId end return true end @@ -475,9 +566,22 @@ function Swarm.active(save) and save.dailyFlags.swarm == true end -function Swarm.mapId(save) +function Swarm.mapId(save, kind) if not Swarm.active(save) then return nil end - return save.swarmMap + local maps = Swarm.maps(save) + return maps and maps[kindName(kind)] or nil +end + +-- pokecrystal/engine/overworld/wildmons.asm:414-451 tests Dunsparce first and +-- falls through to Yanma, so a map both are on answers Dunsparce. +function Swarm.onMap(save, mapId) + if mapId == nil or not Swarm.active(save) then return nil end + local maps = Swarm.maps(save) + if not maps then return nil end + for _, name in ipairs(Swarm.KIND_ORDER) do + if maps[name] == mapId then return name end + end + return nil end function Swarm.fishing(save) @@ -493,6 +597,12 @@ function Swarm.check(save) if type(save) ~= "table" then return 1 end if Swarm.active(save) then return 0 end if save.dailyFlags then save.dailyFlags.fishingSwarm = nil end + -- pokecrystal/engine/overworld/time.asm:103-112 zeroes wSwarmFlags whole, + -- which strands both of Crystal's pairs on the one daily tick. + local maps = save.swarmMaps + if type(maps) == "table" then + for _, name in ipairs(Swarm.KIND_ORDER) do maps[name] = nil end + end save.swarmMap = nil return 1 end @@ -518,10 +628,10 @@ end -- rather than needing its own timer. Returns true when the swarm ended on -- this call. function Swarm.timeEvents(save, day) + local hadMap = anyMap(save) local reset = Swarm.checkDailyReset(save, day) - local hadMap = save and save.swarmMap ~= nil Swarm.check(save) - return reset and hadMap and (save.swarmMap == nil) + return reset and hadMap and not anyMap(save) end -- _SwarmWildmonCheck: the swarm table is searched BEFORE the Johto/Kanto one, @@ -538,7 +648,7 @@ end -- lookup here falls through to the map's own list. function Swarm.entry(save, encounters, mapId, kind) if not encounters then return nil end - if Swarm.mapId(save) ~= mapId then return nil end + if not Swarm.onMap(save, mapId) then return nil end local table_ = (kind == "water") and encounters.swarmWater or encounters.swarmGrass return table_ and table_[mapId] or nil diff --git a/src/core/gen2/Save.lua b/src/core/gen2/Save.lua index edc7b862..dd10851c 100644 --- a/src/core/gen2/Save.lua +++ b/src/core/gen2/Save.lua @@ -133,10 +133,33 @@ local function fs() return love.filesystem end --- The blank-name fallback is the first PlayerNameArray row, which differs --- per edition -- data/player_names.asm:12-23. -function Save.defaultPlayerName(version) - return (version or GameVersion.get()) == "silver" and "SILVER" or "GOLD" +-- The first PlayerNameArray row -- pokegold data/player_names.asm:12-22, and +-- Crystal's gender-split arrays at data/player_names.asm:12-16, :31-35. +Save.DEFAULT_PLAYER_NAMES = { + gold = "GOLD", + silver = "SILVER", + crystal = "CHRIS", +} + +-- FemalePlayerNameArray's first row, the `.Kris` dname NamePlayer falls back to +-- (data/player_names.asm:31-32, engine/menus/intro_menu.asm:768-781). +Save.DEFAULT_PLAYER_NAMES_FEMALE = { + crystal = "KRIS", +} + +-- wPlayerGender as this save spells it (constants/ram_constants.asm:176-177). +function Save.isFemale(save) + local player = type(save) == "table" and save.player + return (player and player.gender) == "female" +end + +function Save.defaultPlayerName(version, gender) + version = version or GameVersion.get() + if gender == "female" then + local female = Save.DEFAULT_PLAYER_NAMES_FEMALE[version] + if female then return female end + end + return Save.DEFAULT_PLAYER_NAMES[version] or "GOLD" end -- A fresh Gen 2 save. `opts` carries what the intro collected: player name, @@ -148,10 +171,13 @@ function Save.newGame(opts) version = GameVersion.get(), generation = 2, player = { - name = opts.playerName or Save.defaultPlayerName(), + name = opts.playerName + or Save.defaultPlayerName(nil, opts.gender or "male"), -- _ResetWRAM rolls wPlayerID out of hRandomSub/hRandomAdd -- (engine/menus/intro_menu.asm:41-49). id = opts.trainerId or rand(0, 65535), + -- InitCrystalData zeroes wPlayerGender before InitGender is even offered + -- (engine/menus/init_gender.asm:1-6). gender = opts.gender or "male", money = 3000, coins = 0, @@ -272,9 +298,11 @@ Save.DEFAULT_OPTIONS = { -- uses (src/core/SaveData.lua) and they drive the same shared modules, so -- a player's display and speed choices mean the same thing in both games. speed = 1, -- GameSpeed.LEVELS multiplier, logic only + -- graphics performance tier: auto | high | balanced | low. "auto" is the + -- Gen 1 save's own default (src/core/SaveData.lua); same key, same module. + performance = "auto", zoom = 0, -- Zoom offset from the window's fit scale tilt = 0, -- Tilt.LEVELS degrees, 0 = off - gbcfx = 0, -- GBCFX ladder, 0 = off -- COLOR: GbcPalette.MODES. "gbc" is the cart's own palettes and the -- default -- this is a Game Boy Color game, so colour is ON out of the box -- and the other two rungs are the deliberate step DOWN to a grey or green @@ -283,6 +311,8 @@ Save.DEFAULT_OPTIONS = { color = "gbc", videoMode = "windowed", fpsCap = 60, + -- BATTLE BG (#1709): white | black, the surround around the battle screen. + battleBg = "white", -- VOID FILL: fade | water | trees | black. fade is each map header's own -- border block with the dissolve across a boundary (#1418). voidFill = "fade", @@ -379,6 +409,49 @@ local function normalizePokerus(mons) end end +-- ../pokecrystal/ram/sram.asm:140 sGSBallFlag. nil is the cleared byte. +Save.GS_BALL_STATES = { have = true, given = true, used = true } + +local function counter(value) + return math.max(0, math.floor(tonumber(value) or 0)) +end + +-- ../pokecrystal/ram/sram.asm:138 "SRAM Crystal Data", created on demand the +-- way Mail.state creates sPartyMail, so a Crystal handler can index freely. +function Save.crystalState(save) + local crystal = save.crystal or {} + save.crystal = crystal + if crystal.celebiCaught == nil then crystal.celebiCaught = false end + crystal.beasts = crystal.beasts or {} + -- ../pokecrystal/ram/wram.asm:3342 wBuenasPassword, :3343 wBlueCardBalance. + local buena = crystal.buenaPassword or {} + crystal.buenaPassword = buena + buena.prizesToday = counter(buena.prizesToday) + buena.streak = counter(buena.streak) + -- ../pokecrystal/engine/events/move_tutor.asm:1 MoveTutor. + crystal.moveTutor = crystal.moveTutor or {} + if crystal.moveTutor.used == nil then crystal.moveTutor.used = false end + -- ../pokecrystal/ram/wram.asm:3445 wUnlockedUnowns. + crystal.unownWords = crystal.unownWords or {} + return crystal +end + +-- ../pokecrystal/ram/sram.asm:147 "SRAM Battle Tower". `reward` stays nil +-- until one is won, which is sBattleTowerReward's zero byte (:160). +function Save.battleTowerState(save) + local tower = save.battleTower or {} + save.battleTower = tower + -- ../pokecrystal/ram/sram.asm:155 sNrOfBeatenBattleTowerTrainers. + tower.streak = counter(tower.streak) + tower.best = counter(tower.best) + -- ../pokecrystal/ram/sram.asm:150 sBattleTowerChallengeState: 0 normal, 2 tower. + tower.challenge = counter(tower.challenge) + -- ../pokecrystal/ram/sram.asm:162 sBTMonOfTrainers. + tower.prevTeams = tower.prevTeams or {} + if tower.inChallenge == nil then tower.inChallenge = false end + return tower +end + -- Fill in anything a save (or an older save) is missing, so callers can index -- freely. Runs on both newGame and load. function Save.normalize(save) @@ -390,7 +463,11 @@ function Save.normalize(save) end save.generation = 2 save.player = save.player or {} - save.player.name = save.player.name or Save.defaultPlayerName(save.version) + -- _ResetWRAM leaves wPlayerGender at 0, PLAYERGENDER_MALE, and Gold never + -- writes it -- constants/ram_constants.asm:177. + save.player.gender = save.player.gender or "male" + save.player.name = save.player.name + or Save.defaultPlayerName(save.version, save.player.gender) save.player.id = save.player.id or rand(0, 65535) save.player.money = math.max(0, math.min(save.player.money or 0, Save.MAX_MONEY)) save.player.coins = math.max(0, math.min(save.player.coins or 0, Save.MAX_COINS)) @@ -462,6 +539,12 @@ function Save.normalize(save) save.playTime = save.playTime or { hours = 0, minutes = 0, seconds = 0, frames = 0 } save.rtc = save.rtc or {} + -- ../pokecrystal/ram/sram.asm:138,147: both regions are Crystal's own, so a + -- Gold or Silver file never grows either key. + if GameVersion.engine(save.version) == "crystal" then + Save.crystalState(save) + Save.battleTowerState(save) + end -- HallOfFame.record fills in the count and the roster list, and trims a -- roster that a corrupt file grew past NUM_HOF_TEAMS -- the same guard the -- party gets below, for the same reason. diff --git a/src/debug/SwitchDiagnostics.lua b/src/debug/SwitchDiagnostics.lua index 04ed4403..07e2b849 100644 --- a/src/debug/SwitchDiagnostics.lua +++ b/src/debug/SwitchDiagnostics.lua @@ -249,12 +249,13 @@ function SwitchDiagnostics.probeAssets(version) end -- Shallow listing so we can see if the extract tree exists at all. - local roots = { "yellow", "blue", "gold", "silver", "assets", + local roots = { "yellow", "blue", "gold", "silver", "crystal", "assets", "yellow/assets/generated", "yellow/assets/generated/sprites", "blue/assets/generated/sprites", "gold/assets/generated", "gold/assets/generated/sprites", "gold/data/generated", "silver/assets/generated", - "silver/data/generated" } + "silver/data/generated", "crystal/assets/generated", + "crystal/data/generated" } for _, dir in ipairs(roots) do local info = filesystem.getInfo(dir) if info and info.type == "directory" and filesystem.getDirectoryItems then diff --git a/src/import/CacheContract.lua b/src/import/CacheContract.lua index 9aef281e..dd4e8503 100644 --- a/src/import/CacheContract.lua +++ b/src/import/CacheContract.lua @@ -73,6 +73,58 @@ CacheContract.VERSION_REQUIRED_FILES_OVERRIDE = { "assets/generated/slots/gold_slots_1.png", "assets/generated/card_flip/card_flip_1.png", "assets/generated/pc/mail_item.png", + -- engine/events/fishing_gfx.asm:23 + "assets/generated/emotes/fishing.png", + }, + crystal = { + "data/generated/constants.lua", + "data/generated/maps.lua", + "data/generated/roofs.lua", + "data/generated/sprites.lua", + "data/generated/scripts.lua", + "data/generated/text.lua", + "data/generated/rom_text.lua", + "data/generated/pokemon.lua", + "data/generated/encounters.lua", + "data/generated/tilesets.lua", + "data/generated/landmarks.lua", + "data/generated/audio.lua", + "data/generated/marts.lua", + "data/generated/oak_speech.lua", + "data/generated/title.lua", + "data/generated/intro.lua", + "assets/generated/fonts/font.png", + "assets/generated/fonts/frames.png", + "assets/generated/title/crystal_logo.png", + "assets/generated/title/crystal_wordmark.png", + "assets/generated/title/crystal_suicune.png", + "assets/generated/title/copyright_splash.png", + "assets/generated/splash/ditto.png", + "assets/generated/intro/chris.png", + "assets/generated/intro/kris.png", + "assets/generated/intro/suicune_run_sprites.png", + "assets/generated/intro/unowns_tiles.png", + "assets/generated/intro/oak.png", + "assets/generated/tilesets/johto.png", + "assets/generated/tilesets/roofs/new_bark.png", + "assets/generated/sprites/chris.png", + "assets/generated/sprites/kris.png", + "assets/generated/battle/front/chikorita.png", + "assets/generated/battle/front/wooper.png", + "assets/generated/battle/front/pikachu.png", + "assets/generated/battle/trainers/falkner.png", + "assets/generated/battle/hud/balls.png", + "assets/generated/audio/programs.bin", + "assets/generated/slots/gold_slots_1.png", + "assets/generated/card_flip/card_flip_1.png", + "assets/generated/pc/mail_item.png", + "assets/generated/trainer_card/card_f.png", + "data/generated/mobile_gfx.lua", + "assets/generated/battle/player_back_female.png", + "assets/generated/battle/trainers/kris.png", + "assets/generated/battle/trainers/chris.png", + -- ../pokecrystal/engine/events/fishing_gfx.asm:38-42 + "assets/generated/emotes/fishing.png", }, } CacheContract.VERSION_REQUIRED_FILES_OVERRIDE.silver = diff --git a/src/import/CrystalMovie.lua b/src/import/CrystalMovie.lua new file mode 100644 index 00000000..fb9892b3 --- /dev/null +++ b/src/import/CrystalMovie.lua @@ -0,0 +1,439 @@ +-- Crystal's intro movie and title screen extraction (pokecrystal +-- engine/movie/intro.asm:1678-1777, engine/movie/title.asm:364-374). +-- Called from RomExtractorGen2's extractIntro/extractTitle stages when +-- edition == "crystal"; reads through the passed-in extractor and never +-- mutates it. + +local ImageWriter = require("src.import.ImageWriter") + +local CrystalMovie = {} + +local TILE_BYTES = 16 +local SHEET_TILES = 16 + +-- vBGMap tilemaps and attrmaps are one full 32x32 map +-- (engine/movie/intro.asm:1611-1628 decompresses $40 tiles = 1024 bytes). +local MAP_BYTES = 1024 + +local function padTiles(raw, count) + local length = count * TILE_BYTES + while #raw > length do table.remove(raw) end + while #raw < length do raw[#raw + 1] = 0 end + return raw +end + +-- Lay `count` tiles of `source` (from tile `from`, 0-based) over `target` +-- starting at tile `at` (0-based). +local function overlayTiles(target, source, at, from, count) + for offset = 1, count * TILE_BYTES do + target[at * TILE_BYTES + offset] = source[from * TILE_BYTES + offset] or 0 + end + return target +end + +local function blankTiles(count) + local out = {} + for index = 1, count * TILE_BYTES do out[index] = 0 end + return out +end + +-- Sheets are 16 tiles per row so tile id N resolves to (N % 16, N / 16), +-- the same layout data/sprite_anims/oam.asm:120-136 assumes. +local function writeSheet(self, raw, count, relative) + padTiles(raw, count) + local rows = math.ceil(count / SHEET_TILES) + padTiles(raw, rows * SHEET_TILES) + self:write2bpp(raw, SHEET_TILES * 8, rows * 8, relative, true) + return "assets/generated/" .. relative +end + +local function readMap(self, label) + local bytes = self:decompressLz3Symbol(label) + while #bytes > MAP_BYTES do table.remove(bytes) end + while #bytes < MAP_BYTES do bytes[#bytes + 1] = 0 end + return bytes +end + +-- 16 palettes: 8 BG then 8 OBJ (engine/movie/intro.asm:123-130 copies +-- `16 palettes` over wBGPals1, which wOBPals1 follows). +local function readPalettes(self, label) + local symbol = self:symbol(label) + local flat = self:colors(symbol.bank, symbol.address, 64) + local bg, obj = {}, {} + for pal = 0, 7 do + local a, b = {}, {} + for slot = 1, 4 do + a[slot] = flat[pal * 4 + slot] + b[slot] = flat[(pal + 8) * 4 + slot] + end + bg[pal + 1], obj[pal + 1] = a, b + end + return { bg = bg, obj = obj } +end + +local function readColors(self, label, count) + local symbol = self:symbol(label) + return self:colors(symbol.bank, symbol.address, count) +end + +function CrystalMovie.extractIntro(self) + self:beginStage("Intro movie") + local out = { + generation = 2, + layout = "crystal", + source = "ROM:CrystalIntro (engine/movie/intro.asm)", + } + + local unowns = padTiles(self:decompressLz3Symbol("IntroUnownsGFX"), 128) + local grassSym = self:symbol("IntroGrass4GFX") + local grass4 = self.rom:bytes(grassSym.bank, grassSym.address, TILE_BYTES) + + local unownsPath = writeSheet(self, unowns, 128, "intro/unowns_tiles.png") + local pulsePath = writeSheet(self, + self:decompressLz3Symbol("IntroPulseGFX"), 16, "intro/pulse_sprites.png") + local backgroundPath = writeSheet(self, + self:decompressLz3Symbol("IntroBackgroundGFX"), 128, + "intro/background_tiles.png") + local runPath = writeSheet(self, + self:decompressLz3Symbol("IntroSuicuneRunGFX"), 192, + "intro/suicune_run_sprites.png") + local pichuPath = writeSheet(self, + self:decompressLz3Symbol("IntroPichuWooperGFX"), 128, + "intro/pichu_wooper_sprites.png") + self:tick("Intro movie", 1, 4) + + -- IntroScene15: jump BG at vTiles2 plus IntroGrass4GFX at vTiles1 tile 0, + -- i.e. BG id $80 (engine/movie/intro.asm:744-753). + local jump = blankTiles(256) + overlayTiles(jump, self:decompressLz3Symbol("IntroSuicuneJumpGFX"), 0, 0, 128) + overlayTiles(jump, grass4, 0x80, 0, 1) + local jumpPath = writeSheet(self, jump, 256, "intro/suicune_jump_tiles.png") + + -- The same grass tile is the whole of the SUICUNE_AWAY object's sheet + -- (data/sprite_anims/oam.asm:136 base $80; engine/movie/intro.asm:750-753). + local unownBack = blankTiles(144) + overlayTiles(unownBack, self:decompressLz3Symbol("IntroUnownBackGFX"), 0, 0, 48) + overlayTiles(unownBack, grass4, 0x80, 0, 1) + local unownBackPath = writeSheet(self, unownBack, 144, + "intro/unown_back_sprites.png") + + -- IntroScene17 loads 255 tiles from vTiles1, so BG id $80 is close tile 0 + -- and ids wrap through $00-$7e (engine/movie/intro.asm:825-828). + local closeRaw = padTiles(self:decompressLz3Symbol("IntroSuicuneCloseGFX"), 255) + local close = blankTiles(256) + for id = 0, 255 do + local from = (id + 128) % 256 + if from < 255 then overlayTiles(close, closeRaw, id, from, 1) end + end + local closePath = writeSheet(self, close, 256, "intro/suicune_close_tiles.png") + + -- IntroScene19: suicune_back at vTiles2, the Unown ring at vTiles1, and + -- grass over vTiles1 tile $7f = BG id $ff (engine/movie/intro.asm:890-901). + local back = blankTiles(256) + overlayTiles(back, self:decompressLz3Symbol("IntroSuicuneBackGFX"), 0, 0, 128) + overlayTiles(back, unowns, 128, 0, 128) + overlayTiles(back, grass4, 0xff, 0, 1) + local backPath = writeSheet(self, back, 256, "intro/suicune_back_tiles.png") + + local crystalUnownsPath = writeSheet(self, + self:decompressLz3Symbol("IntroCrystalUnownsGFX"), 32, + "intro/crystal_unowns_tiles.png") + + -- Intro_RustleGrass swaps 4 tiles at vTiles2 tile $09 through grass1/2/3 + -- (engine/movie/intro.asm:1521-1547); one 16-tile row, frame f at f*4. + local grassStrip = blankTiles(16) + for frame, label in ipairs({ "IntroGrass1GFX", "IntroGrass2GFX", + "IntroGrass3GFX" }) do + local sym = self:symbol(label) + overlayTiles(grassStrip, + self.rom:bytes(sym.bank, sym.address, 4 * TILE_BYTES), (frame - 1) * 4, + 0, 4) + end + out.grassFrames = writeSheet(self, grassStrip, 16, "intro/grass_anim.png") + self:tick("Intro movie", 2, 4) + + local unownPals = readPalettes(self, "IntroUnownsPalette") + local backgroundPals = readPalettes(self, "IntroBackgroundPalette") + local suicunePals = readPalettes(self, "IntroSuicunePalette") + local closePals = readPalettes(self, "IntroSuicuneClosePalette") + local crystalUnownsPals = readPalettes(self, "IntroCrystalUnownsPalette") + + local function act(tiles, sprites, tilemapLabel, attrmapLabel, palettes) + return { + tiles = tiles, + sprites = sprites, + tilemap = readMap(self, tilemapLabel), + attrmap = readMap(self, attrmapLabel), + palettes = palettes, + } + end + + out.acts = { + unownA = act(unownsPath, pulsePath, + "IntroUnownATilemap", "IntroUnownAAttrmap", unownPals), + unownHI = act(unownsPath, pulsePath, + "IntroUnownHITilemap", "IntroUnownHIAttrmap", unownPals), + unowns = act(unownsPath, pulsePath, + "IntroUnownsTilemap", "IntroUnownsAttrmap", unownPals), + background = act(backgroundPath, runPath, + "IntroBackgroundTilemap", "IntroBackgroundAttrmap", backgroundPals), + suicuneJump = act(jumpPath, unownBackPath, + "IntroSuicuneJumpTilemap", "IntroSuicuneJumpAttrmap", suicunePals), + suicuneClose = act(closePath, nil, + "IntroSuicuneCloseTilemap", "IntroSuicuneCloseAttrmap", closePals), + suicuneBack = act(backPath, unownBackPath, + "IntroSuicuneBackTilemap", "IntroSuicuneBackAttrmap", suicunePals), + crystalUnowns = act(crystalUnownsPath, nil, + "IntroCrystalUnownsTilemap", "IntroCrystalUnownsAttrmap", + crystalUnownsPals), + } + -- IntroScene7/10 load pichu_wooper into VRAM bank 1 + -- (engine/movie/intro.asm:340-348); OAM_BANK1 picks this sheet. + out.acts.background.sprites1 = pichuPath + self:tick("Intro movie", 3, 4) + + out.fades = { + toWhite = {}, + unownAppear = readColors(self, "Intro_Scene20_AppearUnown.pal1", 4), + unownAppear2 = readColors(self, "Intro_Scene20_AppearUnown.pal2", 4), + wordFast = readColors(self, "Intro_FadeUnownWordPals.FastFadePalettes", 16), + wordSlow = readColors(self, "Intro_FadeUnownWordPals.SlowFadePalettes", 16), + } + -- Intro_Scene24_ApplyPaletteFade steps 8 rows of one palette each + -- (engine/movie/intro.asm:1155-1189). + local fade = readColors(self, "Intro_Scene24_ApplyPaletteFade.FadePals", 32) + for row = 0, 7 do + local pal = {} + for slot = 1, 4 do pal[slot] = fade[row * 4 + slot] end + out.fades.toWhite[row + 1] = pal + end + + self:write("intro", out) + self:tick("Intro movie", 4, 4) + return out +end + +-------------------------------------------------------------------------- + +local function shadeOf(r) + if r > 0.9 then return 0 end + if r > 0.5 then return 1 end + if r > 0.2 then return 2 end + return 3 +end + +local function tilesFrom2bpp(raw) + local tiles = {} + for offset = 1, #raw - (#raw % TILE_BYTES), TILE_BYTES do + local one = {} + for index = offset, offset + TILE_BYTES - 1 do one[#one + 1] = raw[index] end + tiles[#tiles + 1] = ImageWriter.decode2bpp(one, 8, 8, true) + end + return tiles +end + +local function blitTile(target, tile, tx, ty) + if not tile then return end + for y = 0, 7 do + for x = 0, 7 do + local r, g, b, a = tile:getPixel(x, y) + if a ~= 0 then target:setPixel(tx + x, ty + y, r, g, b, a) end + end + end +end + +local function colorize(image, palFor) + local w, h = image:getDimensions() + local out = ImageWriter.blank(w, h, 0, 0, 0, 0) + for y = 0, h - 1 do + for x = 0, w - 1 do + local r, _, _, a = image:getPixel(x, y) + if a ~= 0 then + local pal = palFor(x, y) + local c = pal[shadeOf(r) + 1] or pal[4] or { 0, 0, 0 } + out:setPixel(x, y, c[1] / 255, c[2] / 255, c[3] / 255, 1) + end + end + end + return out +end + +-- engine/movie/splash.asm:20-30 sets SCGB_GAMEFREAK_LOGO before Copyright, so +-- the card runs on gfx/sgb/predef.pal:79 PREDEFPAL_GAMEFREAK_LOGO_BG. +local COPYRIGHT_TILES = 29 +local COPYRIGHT_BG = { + { 0, 0, 0 }, { 66, 90, 90 }, { 173, 173, 173 }, { 255, 255, 255 }, +} + +-- data/copyright.asm at hlcoord 2, 7 (engine/menus/intro_menu.asm:1315-1326). +local COPYRIGHT_LINES = { + { 0x60, 0x61, 0x62, 0x63, 0x64, 0x65, 0x66, + 0x67, 0x68, 0x69, 0x6a, 0x6b, 0x6c }, + { 0x60, 0x61, 0x62, 0x63, 0x64, 0x65, 0x66, + 0x6d, 0x6e, 0x6f, 0x70, 0x71, 0x72, 0x7a, 0x7b, 0x7c }, + { 0x60, 0x61, 0x62, 0x63, 0x64, 0x65, 0x66, + 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7a, 0x7b, 0x7c }, +} + +local function extractCopyright(self) + local symbol = self:symbol("CopyrightGFX") + local raw = self.rom:bytes(symbol.bank, symbol.address, + COPYRIGHT_TILES * TILE_BYTES) + self:write2bpp(raw, COPYRIGHT_TILES * 8, 8, "title/copyright.png") + + local painted = {} + for index, tile in ipairs(tilesFrom2bpp(raw)) do + painted[index] = colorize(tile, function() return COPYRIGHT_BG end) + end + local backdrop = COPYRIGHT_BG[1] + local splash = ImageWriter.blank(160, 144, + backdrop[1] / 255, backdrop[2] / 255, backdrop[3] / 255, 1) + for row, line in ipairs(COPYRIGHT_LINES) do + for column, id in ipairs(line) do + blitTile(splash, painted[id - 0x60 + 1], (1 + column) * 8, (6 + row) * 8) + end + end + self:save(splash, "title/copyright_splash.png") +end + +function CrystalMovie.extractTitle(self) + self:beginStage("Title screen") + + -- TitleLogoGFX decompresses to vTiles1, so BG id $80 is logo tile 0 and + -- ids $00-$1b wrap on past it (engine/movie/title.asm:91-94). + local logoTiles = tilesFrom2bpp(self:decompressLz3Symbol("TitleLogoGFX")) + -- TitleSuicuneGFX fills vTiles4-vTiles5: bank-1 ids $80-$ff then $00-$7f + -- (engine/movie/title.asm:24-27). + local suicuneTiles = tilesFrom2bpp(self:decompressLz3Symbol("TitleSuicuneGFX")) + local gemTiles = tilesFrom2bpp(self:decompressLz3Symbol("TitleCrystalGFX")) + local pals = readPalettes(self, "TitleScreenPalettes") + self:tick("Title screen", 1, 5) + + -- Attribute regions from _TitleScreen's ByteFills + -- (engine/movie/title.asm:39-85): logo gradient rows, the version strip, + -- pal 7 on the window copyright line, pal 0 everywhere else. + local function bgPalAt(col, row) + if row == 9 and col >= 5 and col <= 15 then return 1 end + if row >= 3 and row <= 4 then return 2 end + if row == 5 then return 3 end + if row == 6 then return 4 end + if row == 7 then return 5 end + if row >= 8 and row <= 9 then return 6 end + if row == 17 then return 7 end + return 0 + end + + local shade = ImageWriter.blank(160, 144, 0, 0, 0, 0) + -- DrawTitleGraphic lays 7 rows of 20 running tiles from d=$80 + -- (engine/movie/title.asm:107-112,275-302). + for row = 0, 6 do + for col = 0, 19 do + blitTile(shade, logoTiles[row * 20 + col + 1], col * 8, (row + 3) * 8) + end + end + -- Copyright line: 13 tiles from d=$c on the window's row 0, shown at + -- hWY = $88 (engine/movie/title.asm:114-119; engine/menus/intro_menu.asm:1121-1122). + for index = 0, 12 do + blitTile(shade, logoTiles[128 + 12 + index + 1], (3 + index) * 8, 17 * 8) + end + + local colored = colorize(shade, function(x, y) + return pals.bg[bgPalAt(math.floor(x / 8), math.floor(y / 8)) + 1] + end) + self:save(colored, "title/crystal_screen.png") + self:save(shade, "title/crystal_screen_gray.png") + + local logo = ImageWriter.blank(160, 56, 0, 0, 0, 0) + ImageWriter.blit(logo, colored, 0, 0, 0, 24, 160, 56) + self:save(logo, "title/crystal_logo.png") + local wordmark = ImageWriter.blank(88, 8, 0, 0, 0, 0) + ImageWriter.blit(wordmark, colored, 0, 0, 40, 72, 88, 8) + self:save(wordmark, "title/crystal_wordmark.png") + self:tick("Title screen", 2, 5) + + -- LoadSuicuneFrame: 6 rows of 8 tiles at hlcoord 6,12, row stride 16; + -- frame bases $80/$88/$00/$08 (engine/movie/title.asm:245-273). + local suicunePaths, suicuneGrayPaths = {}, {} + for index, base in ipairs({ 0x00, 0x08, 0x80, 0x88 }) do + local frame = ImageWriter.blank(64, 48, 0, 0, 0, 0) + for row = 0, 5 do + for col = 0, 7 do + blitTile(frame, suicuneTiles[base + row * 16 + col + 1], + col * 8, row * 8) + end + end + local tinted = colorize(frame, function() return pals.bg[1] end) + local rel = ("title/crystal_suicune_%d.png"):format(index) + self:save(tinted, rel) + suicunePaths[index] = "assets/generated/" .. rel + local grayRel = ("title/crystal_suicune_%d_gray.png"):format(index) + self:save(frame, grayRel) + suicuneGrayPaths[index] = "assets/generated/" .. grayRel + if index == 1 then self:save(tinted, "title/crystal_suicune.png") end + end + self:tick("Title screen", 3, 5) + + -- InitializeBackground: five 48x16 strips of six 8x16 OBJs, consecutive + -- tile pairs, OBJ pal 0, OAM_PRIO (engine/movie/title.asm:304-338). + local gem = ImageWriter.blank(48, 80, 0, 0, 0, 0) + for strip = 0, 4 do + for slot = 0, 5 do + local tile = strip * 12 + slot * 2 + blitTile(gem, gemTiles[tile + 1], slot * 8, strip * 16) + blitTile(gem, gemTiles[tile + 2], slot * 8, strip * 16 + 8) + end + end + local gemTinted = colorize(gem, function() return pals.obj[1] end) + self:save(gemTinted, "title/crystal_gem.png") + self:save(gem, "title/crystal_gem_gray.png") + extractCopyright(self) + self:tick("Title screen", 4, 5) + + local function unit(color) return { color[1] / 255, color[2] / 255, color[3] / 255 } end + + local data = { + generation = 2, + layout = "crystal_title", + source = "ROM:TitleSuicuneGFX + TitleLogoGFX + TitleCrystalGFX" + .. " + TitleScreenPalettes + CopyrightGFX", + screen = "assets/generated/title/crystal_screen.png", + screenGray = "assets/generated/title/crystal_screen_gray.png", + image = "assets/generated/title/crystal_logo.png", + wordmark = "assets/generated/title/crystal_wordmark.png", + suicune = "assets/generated/title/crystal_suicune.png", + suicuneFrames = suicunePaths, + suicuneFramesGray = suicuneGrayPaths, + -- hlcoord 6, 12 (engine/movie/title.asm:252) in screen pixels. + suicuneX = 48, + suicuneY = 96, + -- SuicuneFrameIterator advances every 8 frames (engine/movie/title.asm:217-243). + suicuneEvery = 8, + gem = "assets/generated/title/crystal_gem.png", + gemGray = "assets/generated/title/crystal_gem_gray.png", + copyright = "assets/generated/title/copyright.png", + copyrightSplash = "assets/generated/title/copyright_splash.png", + copyrightBackdrop = unit(COPYRIGHT_BG[1]), + copyrightInk = unit(COPYRIGHT_BG[4]), + -- Strip 0 starts at OAM (64, -$22) and stops at OAM y 22, i.e. screen + -- (56, 6) (engine/movie/title.asm:306-311,340-362). + gemX = 56, + gemY = 6, + gemFromY = -50, + gemStep = 2, + -- TitleScreenEntrance: hSCX from +112 to 0 by 4 with alternating-line + -- signage over the logo's 80 lines; the copyright window sits at + -- hWY = $88 only after it lands (engine/menus/intro_menu.asm:1078-1111). + entrance = { scx = 112, step = 4, lines = 80, hideBelow = 136 }, + entranceSfx = "Sfx_TitleScreenEntrance", + -- TitleScreenTimer (engine/menus/intro_menu.asm:1125-1136). + timeoutFrames = 73 * 60 + 36, + sky = unit(pals.bg[1][1]), + below = unit(pals.bg[1][1]), + palettes = { bg = pals.bg, obj = pals.obj }, + } + self:write("title", data) + self:tick("Title screen", 5, 5) + return data +end + +return CrystalMovie diff --git a/src/import/LauncherSettings.lua b/src/import/LauncherSettings.lua index 7d828cab..3739dd8a 100644 --- a/src/import/LauncherSettings.lua +++ b/src/import/LauncherSettings.lua @@ -266,18 +266,6 @@ local function coreRows(opts, hooks) end) end - -- issue #136: GBC FX soft-bricks the mobile present shader; same gate as - -- the in-game row. - local okFx, GBCFX = pcall(require, "src.render.GBCFX") - if okFx and GBCFX.isSupported() then - add(Strings("GBC FX"), - function() return GBCFX.levelLabel(opts.gbcfx or 0) end, - function(dir) - opts.gbcfx = wrapIndex((opts.gbcfx or 0) + dir, 5) - return true - end) - end - local okZ, Zoom = pcall(require, "src.render.Zoom") if okZ then add(Strings("ZOOM"), @@ -678,17 +666,6 @@ local function gen2Rows(opts, hooks) end) end - -- Same #136 gate as the Gen 1 row and the in-game one. - local okFx, GBCFX = pcall(require, "src.render.GBCFX") - if okFx and GBCFX.isSupported() then - add(Strings("GBC FX"), - function() return GBCFX.levelLabel(opts.gbcfx or 0) end, - function(dir) - opts.gbcfx = wrapIndex((opts.gbcfx or 0) + dir, 5) - return true - end) - end - local okVm, VideoMode = pcall(require, "src.core.VideoMode") if okVm then add(Strings("VIDEO MODE"), @@ -709,6 +686,10 @@ local function gen2Rows(opts, hooks) end) end + -- BATTLE BG (#1709): the WHITE/BLACK pair Gold's battle screen honours. + add(Strings("BATTLE BG"), ladder(opts, "battleBg", + { { "white", "WHITE" }, { "black", "BLACK" } }, "white")) + addTouchRows(rows, add, opts, hooks) return rows diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index 2f20a15d..fe8b77e9 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -405,37 +405,88 @@ end local CART_COLOR = { red = PAL.railRed, blue = PAL.railBlue, yellow = PAL.railGold, gold = PAL.railAmber, silver = PAL.railSilver, + crystal = PAL.railCrystal, } local function cartColor(version) return CART_COLOR[version] or PAL.green end +local function shellColor(hex) + local r, g, b = tostring(hex):match("^#(%x%x)(%x%x)(%x%x)$") + if not r then return nil end + return { tonumber(r, 16), tonumber(g, 16), tonumber(b, 16) } +end + +-- The real Crystal shell is glitter-flecked translucent plastic over a foil +-- label, so it is the one stock cart that ships with a finish. +local STOCK_FINISH = { crystal = "sparkle+holo" } + +local function finishFlags(name) + name = tostring(name or "") + return name:find("sparkle", 1, true) ~= nil, name:find("holo", 1, true) ~= nil +end + +local function cartSkin(imp, version) + local row = imp.activeCartRow and imp:activeCartRow(version) or nil + if not row then + local sparkle, holo = finishFlags(STOCK_FINISH[version]) + return { cacheKey = version, color = cartColor(version), + sparkle = sparkle, holo = holo, + labelPath = "assets/labels/" .. tostring(version) .. ".png" } + end + local sparkle, holo = finishFlags(row.finish) + return { cacheKey = "cart:" .. tostring(row.id), + color = shellColor(row.shell) or cartColor(version), + sparkle = sparkle, holo = holo, + name = row.title, cart = row, cartId = row.id } +end + local CART_DRAG_SLOP = 8 local TAU = math.pi * 2 -local function cartridgeState(imp, version) +local function cartridgeState(imp, key) imp._cartridge = imp._cartridge or {} - local state = imp._cartridge[version] + local state = imp._cartridge[key] if not state then state = { spin = 0, lastTime = Kit.time } - imp._cartridge[version] = state + imp._cartridge[key] = state end return state end -local function cartridgeLabel(imp, version) +local function cartLabelImage(cartId) + local CartStore = require("src.carts.CartStore") + local got, bytes = pcall(CartStore.labelArt, cartId) + if not got or type(bytes) ~= "string" or bytes == "" then return nil end + if not (love.filesystem and love.filesystem.newFileData) then return nil end + local made, image = pcall(function() + return love.graphics.newImage( + love.filesystem.newFileData(bytes, tostring(cartId) .. ".png")) + end) + if not made then return nil end + return image +end + +local function cartridgeLabel(imp, key, path, cartId) imp._cartridgeLabels = imp._cartridgeLabels or {} - local label = imp._cartridgeLabels[version] + local label = imp._cartridgeLabels[key] if label ~= nil then return label or nil end - local ok, image = pcall(love.graphics.newImage, - "assets/labels/" .. tostring(version) .. ".png") - if not ok then - imp._cartridgeLabels[version] = false + local image + if cartId then + image = cartLabelImage(cartId) + elseif path then + local ok, art = pcall(love.graphics.newImage, path) + if ok then image = art end + end + local sized, iw, ih = false, nil, nil + if image then sized, iw, ih = pcall(image.getDimensions, image) end + if not (sized and type(iw) == "number" and type(ih) == "number" + and iw > 0 and ih > 0) then + imp._cartridgeLabels[key] = false return nil end - local iw, ih = image:getDimensions() label = { image = image, width = iw, height = ih } - imp._cartridgeLabels[version] = label + imp._cartridgeLabels[key] = label return label end @@ -491,10 +542,10 @@ local function cartPill(project, x, y, w, h, z, color, alpha) cartPolygon(points, color, alpha) end -local function cartLabelMesh(imp, version, label, points) +local function cartLabelMesh(imp, key, label, points) if not love.graphics.newMesh then return nil end imp._cartridgeLabelMeshes = imp._cartridgeLabelMeshes or {} - local mesh = imp._cartridgeLabelMeshes[version] + local mesh = imp._cartridgeLabelMeshes[key] if not mesh then mesh = love.graphics.newMesh({ { 0, 0, 0, 0, 255, 255, 255, 255 }, @@ -503,7 +554,7 @@ local function cartLabelMesh(imp, version, label, points) { 0, 0, 0, 1, 255, 255, 255, 255 }, }, "fan", "dynamic") mesh:setTexture(label.image) - imp._cartridgeLabelMeshes[version] = mesh + imp._cartridgeLabelMeshes[key] = mesh end mesh:setVertices({ { points[1][1], points[1][2], 0, 0, 255, 255, 255, 255 }, @@ -534,8 +585,56 @@ vec4 position(mat4 transform_projection, vec4 vertex_position) { #endif #ifdef PIXEL +// finish: 0 plain, 1 sparkle (glitter suspended in the shell), 2 holographic +// label sweep. Both are ours, not ported from anywhere. +extern float finish; +extern float finish_time; +extern float finish_spin; + +float sparkHash(vec2 p) { + return fract(sin(dot(p, vec2(41.7321, 289.113))) * 43758.5453); +} + +// Flecks live on a jittered lattice so they read as suspended grains rather +// than a regular grid, and each one twinkles on its own phase. +vec3 sparkle(vec2 screen_coords) { + vec2 cell = screen_coords / 19.0; + vec2 id = floor(cell); + vec2 f = fract(cell); + float peak = 0.0; + for (int oy = -1; oy <= 1; oy++) { + for (int ox = -1; ox <= 1; ox++) { + vec2 n = vec2(float(ox), float(oy)); + vec2 h = vec2(sparkHash(id + n), sparkHash(id + n + 17.0)); + float phase = sparkHash(id + n + 71.0) * 6.2831; + float tw = sin(finish_time * 2.6 + phase + finish_spin * 3.0) * 0.5 + 0.5; + float d = length(f - (n + h)); + peak = max(peak, smoothstep(0.13, 0.0, d) * pow(tw, 16.0)); + } + } + return vec3(peak); +} + +// Angle-dependent spectral sweep: a diagonal band whose hue walks with view +// angle, so tilting the cart moves the rainbow the way real foil does. +vec3 holo(vec2 uv) { + float band = uv.x * 0.9 + uv.y * 0.6 + finish_spin * 0.55 + finish_time * 0.06; + vec3 hue = 0.5 + 0.5 * cos(6.2831 * (band + vec3(0.0, 0.33, 0.67))); + float interference = 0.5 + 0.5 * sin((uv.x - uv.y) * 62.0 + finish_time * 0.9); + return hue * (0.55 + 0.45 * interference); +} + vec4 effect(vec4 color, Image tex, vec2 texture_coords, vec2 screen_coords) { - return Texel(tex, texture_coords) * color; + vec4 px = Texel(tex, texture_coords) * color; + if (finish > 1.5) { + vec3 sheen = holo(texture_coords); + float lum = dot(px.rgb, vec3(0.299, 0.587, 0.114)); + px.rgb = mix(px.rgb, px.rgb * (0.65 + sheen), 0.30 * (0.35 + 0.65 * lum)); + px.rgb += sheen * 0.05 * px.a; + } else if (finish > 0.5) { + px.rgb += sparkle(screen_coords) * 0.70 * px.a; + } + return px; } #endif ]] @@ -563,8 +662,20 @@ local function cartSendHover(shader, mx, my, hovering, screenScale) return ok end -local function cartridgeButton(imp, x, y, w, h, key, version, gameName, action) - local state = cartridgeState(imp, version) +-- FINISH_* match the shader's `finish` uniform. +local FINISH_NONE, FINISH_SPARKLE, FINISH_HOLO = 0, 1, 2 + +local function cartSendFinish(shader, mode, spin) + if not shader or not shader.send then return end + pcall(function() + shader:send("finish", mode) + shader:send("finish_time", Kit.time or 0) + shader:send("finish_spin", spin or 0) + end) +end + +local function cartridgeButton(imp, x, y, w, h, key, skin, gameName, action) + local state = cartridgeState(imp, skin.cacheKey) markNoDrag(imp, x, y, w, h) local focused = Kit.focusable(key, x, y, w, h) local hot = Kit.hover(x, y, w, h) @@ -639,7 +750,7 @@ local function cartridgeButton(imp, x, y, w, h, key, version, gameName, action) local ease = math.exp(-60 * dt) state.visScale = ease * state.visScale + (1 - ease) * desScale if not state.animId then - local n, s = 0, tostring(version) + local n, s = 0, tostring(skin.cacheKey) for i = 1, #s do n = n + s:byte(i) * i end state.animId = n end @@ -685,6 +796,10 @@ local function cartridgeButton(imp, x, y, w, h, key, version, gameName, action) love.graphics.rotate(juiceR * 2) love.graphics.translate(-cx, -cy) if useHover then love.graphics.setShader(shader) end + if useHover then + cartSendFinish(shader, skin.sparkle and FINISH_SPARKLE or FINISH_NONE, + state.spin) + end local capH = h * 3 / 65 local mainTop = -halfH + capH @@ -695,7 +810,7 @@ local function cartridgeButton(imp, x, y, w, h, key, version, gameName, action) capRight + halfW, capH, depth) local capBack = cartQuad(project, -halfW, -halfH, capRight + halfW, capH, -depth) - local shell = cartColor(version) + local shell = skin.color local side = { math.floor(shell[1] * 0.54), math.floor(shell[2] * 0.54), math.floor(shell[3] * 0.54) } @@ -797,8 +912,11 @@ local function cartridgeButton(imp, x, y, w, h, key, version, gameName, action) local plate = cartQuad(project, labelX - 2, labelY - 2, labelW + 4, labelH + 4, faceZ + 0.8) cartPolygon(plate, side, 0.95) local labelPoints = cartQuad(project, labelX, labelY, labelW, labelH, faceZ + 1.2) - local label = cartridgeLabel(imp, version) - local mesh = label and cartLabelMesh(imp, version, label, labelPoints) + local label = cartridgeLabel(imp, skin.cacheKey, skin.labelPath, skin.cartId) + local mesh = label and cartLabelMesh(imp, skin.cacheKey, label, labelPoints) + if useHover and skin.holo then + cartSendFinish(shader, FINISH_HOLO, state.spin) + end if mesh then love.graphics.setColor(1, 1, 1, 1) love.graphics.draw(mesh) @@ -807,6 +925,10 @@ local function cartridgeButton(imp, x, y, w, h, key, version, gameName, action) love.graphics.draw(label.image, labelPoints[1][1], labelPoints[1][2], 0, artScale, artScale) end + if useHover and skin.holo then + cartSendFinish(shader, skin.sparkle and FINISH_SPARKLE or FINISH_NONE, + state.spin) + end cartPolygon({ { project(-w * 0.07, h * 0.37, faceZ + 1) }, { project(w * 0.07, h * 0.37, faceZ + 1) }, @@ -825,6 +947,8 @@ local function modStatusColor(status) if status == "safe_mode" then return Strings("Safe mode"), PAL.yellow end if status == "needs_import" then return Strings("Import required"), PAL.yellow end if status == "conflict" then return Strings("Conflict"), PAL.red end + -- a cart pins it, but nothing on this install provides it + if status == "missing" then return Strings("Not installed"), PAL.red end -- not a fault: the mod is intact, this is simply not a game it is for -- (src/mods/ModTargets.lua) if status == "other_game" then return Strings("Not for this game"), PAL.muted end @@ -933,6 +1057,63 @@ local function buildModScopeRow(imp, x, y, w, m) return h + math.floor(8 * m.s) end +-- Says whose mod list is on screen when a cart owns it, and what its seal +-- lets the player do with it. +local function buildModCartRow(imp, x, y, w, m, cartId, report) + if not cartId then return 0 end + local title = (report and report.title) or cartId + local seal = (report and report.seal) or "sealed" + local line + if seal == "sealed+" then + line = Strings("%s pins these mods. You may switch any of them on or off, but not add or remove any.", + title) + elseif seal == "open" then + line = Strings("%s pins these mods. Anything it ships switched off is yours to switch on.", + title) + else + line = Strings("%s is sealed: these are its mods, and they run exactly as pinned. Break the seal on the cart's own page to change that.", + title) + end + local h = Kit.textWrapped("small", line, x, y, w, + seal == "sealed" and PAL.yellow or PAL.blue, 3) + return h + math.floor(8 * m.s) +end + +local function buildSaveCartRow(imp, x, y, w, m) + local version = imp.modScope + local h = m.btnH + local gap = math.floor(8 * m.s) + local label = Strings("Save as cart") + local bw = math.min(w, Kit.textWidth("small", label) + math.floor(28 * m.s)) + local enabled = version ~= nil and imp:_cartCaptureCount(version) > 0 + btn(imp, x, y, bw, h, "mods-save-cart", label, { + kind = "accent", font = "small", enabled = enabled, + action = enabled and function() imp:_beginCartSave(version) end or nil }) + local hint + if version == nil then + hint = Strings("Pick one game above to save its enabled mods as a cart.") + elseif not enabled then + hint = Strings("Enable a mod for this game first.") + else + local info = GameVersion.info(version) + hint = Strings("Freeze the mods enabled for %s into a cart.", + (info and (info.launcherName or info.displayName)) or tostring(version)) + end + local hx = x + bw + gap + local hw = math.max(0, x + w - hx) + if hw > 0 then + Kit.text("small", Kit.ellipsize("small", hint, hw), hx, + y + (h - Kit.textHeight("small")) / 2, PAL.muted) + end + return h + gap +end + +-- The launcher's name for a game id, where all a row has is the id. +local function gameLabel(version) + local info = GameVersion.info(version) + return (info and (info.launcherName or info.displayName)) or tostring(version) +end + local function findActionFor(entry, installedVersion) local ModIndex = require("src.mods.ModIndex") if not ModIndex.canInstall(entry) then @@ -1062,6 +1243,8 @@ local GAME_TABS = { label = "Gold" }, { id = "silver", key = "tab-silver", letter = "S", color = PAL.railSilver, label = "Silver" }, + { id = "crystal", key = "tab-crystal", letter = "C", + color = PAL.railCrystal, label = "Crystal" }, } local HEADER_TABS = { @@ -1509,9 +1692,11 @@ local function chipWidth(label, m) end local function buildSlotCard(imp, x, y, w, availH, m, version, ready) - imp:_ensureSlots(version) - local slots = imp.slots[version] or {} - local active = imp.activeSlot[version] + local scope = imp.slotScope and imp:slotScope(version) or version + local onCart = scope ~= version + imp:_ensureSlots(scope) + local slots = imp.slots[scope] or {} + local active = imp.activeSlot[scope] local n = #slots local pad = math.floor(14 * m.s) local iw = w - 2 * pad @@ -1567,7 +1752,7 @@ local function buildSlotCard(imp, x, y, w, availH, m, version, ready) local headH = math.max(Kit.textHeight("caption"), m.btnH) + math.floor(8 * m.s) local pagerH = math.max(Kit.tapMin(), math.floor(30 * m.s)) local newBtnH = m.btnH - local sfNotice = imp.saveNotice[version] + local sfNotice = imp.saveNotice[scope] local hintText, hintCol if sfNotice then hintText, hintCol = sfNotice.text, (sfNotice.ok and PAL.green or PAL.red) @@ -1583,7 +1768,7 @@ local function buildSlotCard(imp, x, y, w, availH, m, version, ready) local listH = availH - (pad * 2 + headH + hintH + pagerH + gap + newBtnH + gap) local perPage = Kit.rowsThatFit(listH, rowH, gap, 1, 12) - local pageKey = "slots-" .. version + local pageKey = "slots-" .. scope local first, last, cur, pages = Kit.pageBounds(page(imp, pageKey), n, perPage) setPage(imp, pageKey, cur) @@ -1600,10 +1785,12 @@ local function buildSlotCard(imp, x, y, w, availH, m, version, ready) local savImportLabel = imp.isNX and Strings("Scan again") or Strings("Import save") local impW = chipWidth(savImportLabel, m) + math.floor(8 * m.s) - btn(imp, x + w - pad - impW, cy, impW, m.btnH, "sav-import-" .. version, + btn(imp, x + w - pad - impW, cy, impW, m.btnH, "sav-import-" .. scope, savImportLabel, { - kind = "accent", font = "small", enabled = ready and true or false, - action = ready and function() imp:chooseSaveImport(version) end or nil, + kind = "accent", font = "small", + enabled = (ready and not onCart) and true or false, + action = (ready and not onCart) + and function() imp:chooseSaveImport(version) end or nil, }) local countW = (x + w - pad - impW - math.floor(8 * m.s)) - (x + pad + Kit.captionWidth(Strings("SAVE SLOT")) + math.floor(8 * m.s)) @@ -1626,10 +1813,10 @@ local function buildSlotCard(imp, x, y, w, availH, m, version, ready) for i = first, last do local slot = slots[i] local selected = slot.id == active - local rowKey = "slot-" .. version .. "-" .. slot.id + local rowKey = "slot-" .. scope .. "-" .. slot.id local ry = cy + (i - first) * (rowH + gap) local ink = rowHit(imp, x + pad, ry, iw, rowH, selected, rowKey, - function() imp:_selectSlot(version, slot.id) end) + function() imp:_selectSlot(scope, slot.id) end) local px = x + pad + math.floor(10 * m.s) local inner = iw - math.floor(20 * m.s) @@ -1658,6 +1845,9 @@ local function buildSlotCard(imp, x, y, w, availH, m, version, ready) else metaTxt = Strings("empty slot") end + if slot.sealBroken then + metaTxt = Strings("%s - seal broken", metaTxt) + end Kit.text("small", Kit.ellipsize("small", metaTxt, textW), px, ly, selected and PAL.inverse or PAL.muted) -- Where the chip block starts: centred on the row beside the text, or @@ -1670,22 +1860,22 @@ local function buildSlotCard(imp, x, y, w, availH, m, version, ready) -- an export is a property of a slot, so the control belongs on the slot -- it exports (it selects the row first, since the exporter writes -- whichever slot is active). - local armed = deleteArmed(imp, "slot", slot.id, version) + local armed = deleteArmed(imp, "slot", slot.id, scope) local chips = {} - if slot.exists then + if slot.exists and not onCart then chips[#chips + 1] = { label = Strings("Export"), kind = "accent", key = rowKey .. "-export", action = function() - imp:_selectSlot(version, slot.id) + imp:_selectSlot(scope, slot.id) imp:exportSave(version) end } end if not imp.android then chips[#chips + 1] = { label = Strings("Rename"), kind = "accent", key = rowKey .. "-rename", - action = function() imp:_beginRename(version, slot.id) end } + action = function() imp:_beginRename(scope, slot.id) end } end - if imp.onEditSave and slot.exists then + if imp.onEditSave and slot.exists and not onCart then chips[#chips + 1] = { label = Strings("Edit"), kind = "accent", key = rowKey .. "-edit", action = function() imp.onEditSave(version, slot.id) end } @@ -1696,8 +1886,8 @@ local function buildSlotCard(imp, x, y, w, availH, m, version, ready) w = math.max(chipWidth(DELETE_LABEL(false), m), chipWidth(DELETE_LABEL(true), m)), action = function() - imp:pressDelete("slot", slot.id, version, function() - imp:_deleteSlot(version, slot.id) + imp:pressDelete("slot", slot.id, scope, function() + imp:_deleteSlot(scope, slot.id) end) end } for _, c in ipairs(chips) do c.w = c.w or chipWidth(c.label, m) end @@ -1726,7 +1916,7 @@ local function buildSlotCard(imp, x, y, w, availH, m, version, ready) cy = cy + Kit.textWrapped("small", hintText, x + pad, cy, iw, hintCol, 2) if folderRow then cy = cy + math.floor(4 * m.s) - local key = "sav-folder-" .. version + local key = "sav-folder-" .. scope local label = Strings("Open folder") local lw = Kit.textWidth("small", label) local lh = Kit.textHeight("small") @@ -1749,19 +1939,150 @@ local function buildSlotCard(imp, x, y, w, availH, m, version, ready) setPage(imp, pageKey, newPage) cy = cy + pagerH + gap end - btn(imp, x + pad, cy, iw, newBtnH, "slot-new-" .. version, + btn(imp, x + pad, cy, iw, newBtnH, "slot-new-" .. scope, Strings("+ New save slot"), { kind = "good", - action = function() imp:_newSlot(version) end, + action = function() imp:_newSlot(scope) end, }) return h end +local function SEAL_LABEL(armed) + return armed and Strings("Break it") or Strings("Break the seal") +end + +local function FILL_LABEL(count) + if count > 1 then return Strings("Install %d required mods", count) end + return Strings("Install required mods") +end + +local function sealSlotName(slot) + if not slot then return nil end + if type(slot.label) == "string" and slot.label ~= "" then return slot.label end + return tostring(slot.id):match("^slot(%d+)$") or tostring(slot.id) +end + +local function buildCartCard(imp, x, y, w, m, version) + if not imp.cartPlan then return 0 end + local report, slot = imp:cartPlan(version) + if not report then return 0 end + local title = tostring(report.title or report.id or "") + local broken = (slot and slot.sealBroken == true) or false + local fillCount = imp.cartFillRows and #imp:cartFillRows(version) or 0 + local state, stateCol, body = nil, PAL.green, {} + if report.refused then + state, stateCol = Strings("This cart will not start"), PAL.red + body[#body + 1] = { report.message, PAL.detail } + if fillCount > 0 then + body[#body + 1] = { Strings("Install the mods it pins to play it the way its author built it."), + PAL.detail } + end + body[#body + 1] = { Strings("Break the seal to play it with the mods you have."), + PAL.detail } + elseif broken then + state, stateCol = Strings("Seal broken"), PAL.yellow + body[#body + 1] = { Strings("This save loads the cart's pinned mods first, then your other enabled mods. It is marked modified."), + PAL.detail } + elseif report.seal == "sealed+" then + state = Strings("Sealed - ready to play") + body[#body + 1] = { Strings("This cart loads only the mods it pins. You can switch any of them on or off."), + PAL.detail } + elseif report.sealed then + state = Strings("Sealed - ready to play") + body[#body + 1] = { Strings("This cart loads only the mods it pins."), + PAL.detail } + else + state = Strings("Open cart - ready to play") + body[#body + 1] = { Strings("This cart's pinned mods load first, then your other enabled mods."), + PAL.detail } + end + local scope = imp:slotScope(version) + local offer = report.sealed and not broken + local armed = offer and deleteArmed(imp, "seal", slot and slot.id or nil, scope) + if armed then + local name = sealSlotName(slot) + body[#body + 1] = { name + and Strings("Break the seal on %s, save slot %s?", title, name) + or Strings("Break the seal on %s, on a new save slot?", title), + PAL.yellow } + body[#body + 1] = { Strings("This is permanent and cannot be undone. That save is marked modified from then on."), + PAL.yellow } + body[#body + 1] = { Strings("%s still loads its pinned mods first, with your other enabled mods on top.", title), + PAL.yellow } + body[#body + 1] = { Strings("Press Break it again to do it."), PAL.yellow } + end + -- What the last install run managed, and per mod what it could not. + local fillNotice = imp.cartFillNotice + if fillNotice then + body[#body + 1] = { fillNotice.text, + fillNotice.ok and PAL.green or PAL.red } + for _, line in ipairs(fillNotice.failures or {}) do + body[#body + 1] = { line, PAL.red } + end + end + + local pad = math.floor(14 * m.s) + local iw = w - 2 * pad + local fillLabel = FILL_LABEL(fillCount) + local chipGap = math.floor(8 * m.s) + local chipH = math.max(Kit.tapMin(), math.floor(30 * m.s)) + local fillW = fillCount > 0 + and math.min(iw, chipWidth(fillLabel, m)) or 0 + local sealW = offer and math.min(iw, math.max(chipWidth(SEAL_LABEL(false), m), + chipWidth(SEAL_LABEL(true), m))) or 0 + -- Both chips share a row when they fit; a narrow card stacks them instead. + local sideBySide = fillW > 0 and sealW > 0 + and (fillW + chipGap + sealW) <= iw + local chipRows = 0 + if fillW > 0 then chipRows = chipRows + 1 end + if sealW > 0 then chipRows = chipRows + 1 end + if sideBySide then chipRows = 1 end + if chipRows == 0 then chipH = 0 end + + local h = pad + Kit.textHeight("button") + math.floor(4 * m.s) + for _, line in ipairs(body) do + h = h + Kit.wrapHeight("small", line[1] or "", iw, 3) + end + if chipRows > 0 then + h = h + math.floor(8 * m.s) + chipRows * chipH + (chipRows - 1) * chipGap + end + h = h + pad + + Kit.card(x, y, w, h) + local cy = y + pad + Kit.text("button", Kit.ellipsize("button", state, iw), x + pad, cy, stateCol) + cy = cy + Kit.textHeight("button") + math.floor(4 * m.s) + for _, line in ipairs(body) do + cy = cy + Kit.textWrapped("small", line[1] or "", x + pad, cy, iw, + line[2], 3) + end + if chipRows > 0 then + cy = cy + math.floor(8 * m.s) + local bx = x + pad + if fillW > 0 then + btn(imp, bx, cy, fillW, chipH, "cart-fill-" .. scope, fillLabel, { + kind = "primary", font = "small", + action = function() imp:pressInstallCartMods(version) end, + }) + if sideBySide then bx = bx + fillW + chipGap + else cy = cy + chipH + chipGap end + end + if sealW > 0 then + btn(imp, bx, cy, sealW, chipH, "seal-" .. scope, SEAL_LABEL(armed), { + kind = "danger", font = "small", keepArm = true, + action = function() imp:pressBreakSeal(version) end, + }) + end + end + return h +end + local function buildGamePanel(imp, x, y, w, availH, m, version, budgetH) imp.panelVersion = version local info = GameVersion.info(version) local locked = info == nil - local gameName = info and (info.launcherName or info.displayName) + local skin = cartSkin(imp, version) + local gameName = skin.name or (info and (info.launcherName or info.displayName)) or tostring(version) local ready = (not locked) and imp.ready[version] or false @@ -1839,7 +2160,7 @@ local function buildGamePanel(imp, x, y, w, availH, m, version, budgetH) local cartW = math.min(cartAreaW, math.floor(playH * 0.88)) local cartX = lx + math.floor((cartAreaW - cartW) / 2) cartridgeButton(imp, cartX, ly, cartW, playH, "play-" .. version, - version, gameName, function() imp:play(version, true) end) + skin, gameName, function() imp:play(version, true) end) imp._gearIcon = imp._gearIcon or love.graphics.newImage("assets/launcher/gear.png") btn(imp, lx + lw - mgW, ly, mgW, mgW, "manage-" .. version, "", { @@ -1847,6 +2168,17 @@ local function buildGamePanel(imp, x, y, w, availH, m, version, budgetH) action = function() imp._gameManage = version end, }) ly = ly + playH + gap + btn(imp, lx, ly, lw, m.btnH, "carts-" .. version, + Strings("Custom Carts"), { + kind = "accent", font = "small", + action = function() + imp._cartPopup = version + imp._cartNotice = nil + end, + }) + ly = ly + m.btnH + gap + local sealH = buildCartCard(imp, lx, ly, lw, m, version) + if sealH > 0 then ly = ly + sealH + gap end end -- The ROM card, which now only exists while there is something to report: @@ -2013,6 +2345,10 @@ local function buildModsPanel(imp, x, y, w, availH, m) local mods = imp.mods or {} local gap = m.gap local cy = y + local cartId, cartReport + if imp.modCartPlan then cartId, cartReport = imp:modCartPlan() end + -- a cart owns its mod set: only the pins it already ships may be switched + local bulkOk = not safeMode and cartId == nil -- header: progressive action cluster. Surfaces primary/frequent actions -- (Import, Updates, Sort) directly on the bar across screen sizes, placing @@ -2041,11 +2377,11 @@ local function buildModsPanel(imp, x, y, w, availH, m) action = function() imp:chooseMod() end }) btn(imp, place(disableW), cy, disableW, bh, "mods-disable-all", Strings("Disable all"), { kind = "warn", font = "small", - enabled = not safeMode, + enabled = bulkOk, action = function() imp:_setAllMods(false) end }) btn(imp, place(enableW), cy, enableW, bh, "mods-enable-all", Strings("Enable all"), { kind = "good", font = "small", - enabled = not safeMode, + enabled = bulkOk, action = function() imp:_setAllMods(true) end }) btn(imp, place(checkFullW), cy, checkFullW, bh, "mods-check-updates", Strings("Check for updates"), { font = "small", @@ -2106,6 +2442,8 @@ local function buildModsPanel(imp, x, y, w, availH, m) + math.floor(8 * m.s) cy = cy + buildModScopeRow(imp, x, cy, w, m) + cy = cy + buildModCartRow(imp, x, cy, w, m, cartId, cartReport) + cy = cy + buildSaveCartRow(imp, x, cy, w, m) if #mods == 0 then Kit.emptyBox(x, cy, w, math.floor(110 * m.s), imp:_modsEmptyHint()) @@ -2198,22 +2536,40 @@ local function buildModsPanel(imp, x, y, w, availH, m) -- These answer separate games, not a single shared install flag. The -- importer receives the game id so an experimental confirmation also - -- applies only to the checkbox the player pressed. + -- applies only to the checkbox the player pressed. A cart's pin answers + -- one game -- the cart's -- so it gets one switch instead of the row. local flipped = false local gamesY = ry + math.floor(8 * m.s) + textH + math.floor(8 * m.s) - Kit.text("micro", gamesLabel, px, - gamesY + (togH - Kit.textHeight("micro")) / 2, PAL.muted) - local tx = px + Kit.textWidth("micro", gamesLabel) + math.floor(10 * m.s) - for _, game in ipairs(GameVersion.ORDER) do - local togKey = "mod-toggle-" .. mod.id .. "-" .. game - if modGameCheckbox(tx, gamesY, togH, - mod.enabledByVersion and mod.enabledByVersion[game] == true, - game, togKey, not safeMode) then - local version = game - queueAction(imp, togKey, function() imp:_toggleMod(mod.id, nil, version) end) + local rowLabel = gamesLabel + if mod.cartPin then + rowLabel = mod.cartTogglable and Strings("In this cart:") + or Strings("Pinned, sealed:") + end + Kit.text("micro", rowLabel, px, + gamesY + (togH - Kit.textHeight("micro")) / 2, + mod.cartPin and not mod.cartTogglable and PAL.yellow or PAL.muted) + local tx = px + Kit.textWidth("micro", rowLabel) + math.floor(10 * m.s) + if mod.cartPin then + local togKey = "mod-toggle-" .. mod.id .. "-cart" + -- pressable even when the seal refuses it, so the panel can say why + if modGameCheckbox(tx, gamesY, togH, mod.enabled == true, + mod.cartBase or "red", togKey, not safeMode) then + queueAction(imp, togKey, function() imp:_toggleMod(mod.id, nil, nil) end) flipped = true end tx = tx + togH + togGap + else + for _, game in ipairs(GameVersion.ORDER) do + local togKey = "mod-toggle-" .. mod.id .. "-" .. game + if modGameCheckbox(tx, gamesY, togH, + mod.enabledByVersion and mod.enabledByVersion[game] == true, + game, togKey, not safeMode) then + local version = game + queueAction(imp, togKey, function() imp:_toggleMod(mod.id, nil, version) end) + flipped = true + end + tx = tx + togH + togGap + end end -- The checkboxes sit inside the row's rect, so their press also passes the -- row hit test; `flipped` gates the row action to everywhere else. @@ -2229,17 +2585,27 @@ local function buildModsPanel(imp, x, y, w, availH, m) -- in-game manager shows (src/mods/ModTargets.lua) local gamesW = mod.targets and Kit.textWidth("micro", mod.targets) + math.floor(12 * m.s) or 0 + -- the cart's own list, not the player's: every row says so + local pinLabel = mod.cartPin and Strings("PINNED") or nil + local pinW = pinLabel + and Kit.textWidth("micro", pinLabel) + math.floor(12 * m.s) or 0 local nameShown = Kit.ellipsize("button", mod.name, - textW - badgeW - gamesW - math.floor(12 * m.s)) + textW - badgeW - gamesW - pinW - math.floor(12 * m.s)) local headingCol = isFullyDisabled and PAL.muted or PAL.heading Kit.text("button", nameShown, px, ly, headingCol) local tagX = px + Kit.textWidth("button", nameShown) + math.floor(8 * m.s) Kit.tag(tagX, ly, badgeW, Kit.textHeight("button"), mod.badge, mod.experimental and PAL.yellow or PAL.muted) + tagX = tagX + badgeW + math.floor(4 * m.s) if mod.targets then - Kit.tag(tagX + badgeW + math.floor(4 * m.s), ly, gamesW, + Kit.tag(tagX, ly, gamesW, Kit.textHeight("button"), mod.targets, mod.targetsHere == false and PAL.steel or PAL.blue) + tagX = tagX + gamesW + math.floor(4 * m.s) + end + if pinLabel then + Kit.tag(tagX, ly, pinW, Kit.textHeight("button"), pinLabel, + mod.cartTogglable and PAL.blue or PAL.yellow) end ly = ly + Kit.textHeight("button") + math.floor(4 * m.s) @@ -2660,14 +3026,45 @@ local function buildBugPanel(imp, x, y, w, availH, m) end }) end +-- FIND tab: which half of the feed is on screen, in the same chip idiom the +-- MODS tab's scope row uses. The counts say which half is worth pressing. +local function buildFindKindRow(imp, x, y, w, m) + local h = math.max(Kit.tapMin(), math.floor(26 * m.s)) + local gap = math.floor(6 * m.s) + local label = Strings("Browse:") + Kit.text("small", label, x, y + (h - Kit.textHeight("small")) / 2, PAL.muted) + local cx = x + Kit.textWidth("small", label) + math.floor(10 * m.s) + local options = { + { id = "mods", label = Strings("Mods"), + n = #((imp.findIndex and imp.findIndex.mods) or {}) }, + { id = "carts", label = Strings("Carts"), + n = #((imp.findIndex and imp.findIndex.carts) or {}) }, + } + for _, opt in ipairs(options) do + local text = ("%s (%d)"):format(opt.label, opt.n) + local cw = Kit.textWidth("micro", text) + math.floor(18 * m.s) + if cx + cw > x + w then break end + if Kit.chip(cx, y, cw, h, text, imp.findKind == opt.id, PAL.lineStrong, + "find-kind-" .. opt.id) then + local want = opt.id + queueAction(imp, "find-kind-" .. want, + function() imp:_setFindKind(want) end) + end + cx = cx + cw + gap + end + return h + math.floor(8 * m.s) +end + local function buildFindPanel(imp, x, y, w, availH, m) imp:_ensureFind() imp:_ensureMods() local ModIndex = require("src.mods.ModIndex") local ModUpdate = require("src.mods.ModUpdate") local sources = imp.findSources or {} + local carts = imp.findKind == "carts" local rows = imp:_findRows() - local total = #((imp.findIndex and imp.findIndex.mods) or {}) + local total = #((imp.findIndex + and (carts and imp.findIndex.carts or imp.findIndex.mods)) or {}) local gap = m.gap local cy = y @@ -2698,6 +3095,8 @@ local function buildFindPanel(imp, x, y, w, availH, m) return (cy - y) + h end + cy = cy + buildFindKindRow(imp, x, cy, w, m) + -- One row: the search field, then Filter / Sort / Indexes popup buttons. local fieldH = math.max(Kit.tapMin(), math.floor(36 * m.s)) local bgap = math.floor(6 * m.s) @@ -2710,22 +3109,31 @@ local function buildFindPanel(imp, x, y, w, availH, m) btn(imp, place(sw), cy, sw, fieldH, "find-sort", Strings("Sort"), { font = "small", action = function() imp._sortPopup = "find" end }) - -- The Filter button carries its state: blue while a category is active, - -- so a filtered-down list never reads as "the index shrank". + -- The Filter button carries its state: blue while a category (mods) or a + -- base game (carts) is active, so a filtered-down list never reads as "the + -- index shrank". + local activeFilter = carts and imp.findBase or imp.findCategory local fw = Kit.textWidth("small", Strings("Filter")) + math.floor(20 * m.s) btn(imp, place(fw), cy, fw, fieldH, "find-filter", Strings("Filter"), { - kind = imp.findCategory and "accent" or "ghost", font = "small", + kind = activeFilter and "accent" or "ghost", font = "small", action = function() imp._filterPopup = true end }) local searchW = place(0) - x - bgap textField(imp, x, cy, searchW, fieldH, "find-search", imp.findQuery or "", - Strings("Search mods"), imp._findSearchFocus == true, + carts and Strings("Search carts") or Strings("Search mods"), + imp._findSearchFocus == true, function() imp:_toggleFindSearchFocus() end) cy = cy + fieldH + math.floor(8 * m.s) if #rows == 0 then - Kit.emptyBox(x, cy, w, math.floor(110 * m.s), - (total == 0) and Strings("This index lists no mods yet.") - or Strings("No mods match that search.")) + local empty + if carts then + empty = (total == 0) and Strings("This index lists no carts yet.") + or Strings("No carts match that search.") + else + empty = (total == 0) and Strings("This index lists no mods yet.") + or Strings("No mods match that search.") + end + Kit.emptyBox(x, cy, w, math.floor(110 * m.s), empty) return (cy - y) + math.floor(110 * m.s) end @@ -2825,7 +3233,7 @@ local function buildFindPanel(imp, x, y, w, availH, m) if imp:_findThumbPending(entry.id) then Kit.spinner(px + thumb / 2, ly + thumb / 2, thumb * 0.28) else - Kit.textCenter("micro", "MOD", px, + Kit.textCenter("micro", carts and "CART" or "MOD", px, ly + (thumb - Kit.textHeight("micro")) / 2, thumb, PAL.faint) end end @@ -2848,7 +3256,12 @@ local function buildFindPanel(imp, x, y, w, availH, m) or nil local rest = {} if entry.author then rest[#rest + 1] = entry.author end - if entry.categories and entry.categories[1] then + if carts then + -- A cart has no categories; the game it plays as and its seal are what + -- a reader is actually choosing between. + rest[#rest + 1] = gameLabel(entry.base) + if entry.seal then rest[#rest + 1] = entry.seal end + elseif entry.categories and entry.categories[1] then rest[#rest + 1] = entry.categories[1] end if dates then @@ -3130,6 +3543,8 @@ local function buildConfirmModal(imp, m) imp._modConfirm = nil if c.indexEntry then imp:_findInstall(c.indexEntry) + elseif c.kind == "cartPins" then + imp:_installCartPins(c.version, c.id) elseif c.kind == "update" then imp:_confirmModUpdate(c.id, c.release) elseif c.kind == "enableAll" then @@ -3454,12 +3869,15 @@ local function buildModHeaderActionsModal(imp, m) local pad = math.floor(18 * m.s) local w = math.floor(380 * m.s) local gap = math.floor(8 * m.s) + -- same gate as the header cluster: a cart's mod set is not bulk-editable + local bulkOk = not imp.safeMode + and not (imp.modCartPlan and imp:modCartPlan()) local btns = { { label = Strings("Mod profiles..."), action = function() imp._profilesPopup = true end }, { label = Strings("Check for updates"), action = function() imp:_syncModUpdateInfo(true) end }, - { label = Strings("Enable all mods"), kind = "good", enabled = not imp.safeMode, + { label = Strings("Enable all mods"), kind = "good", enabled = bulkOk, action = function() imp:_setAllMods(true) end }, - { label = Strings("Disable all mods"), kind = "warn", enabled = not imp.safeMode, + { label = Strings("Disable all mods"), kind = "warn", enabled = bulkOk, action = function() imp:_setAllMods(false) end }, { label = Strings("Sort mods..."), action = function() imp._sortPopup = "mods" end }, } @@ -3555,34 +3973,244 @@ end -- also what a controller reaches after the tab row. local function buildGameModal(imp, m) local pad = math.floor(18 * m.s) - local gap = math.floor(8 * m.s) - local w = math.floor(360 * m.s) - local h = pad + Kit.textHeight("button") + math.floor(12 * m.s) - + #GAME_TABS * (m.btnH + gap) + m.btnH + pad - local px, py, pw = modalPanel(m, w, h) + local headH = Kit.textHeight("button") + math.floor(12 * m.s) + local avail = m.H - 2 * m.pad + local cols, gap, btnH = 1, math.floor(8 * m.s), m.btnH + local function rows() return math.ceil(#GAME_TABS / cols) + 1 end + local function total() return 2 * pad + headH + rows() * btnH + + (rows() - 1) * gap end + if total() > avail then cols = 2 end + if total() > avail then gap = math.max(2, math.floor(3 * m.s)) end + if total() > avail then + btnH = math.max(Kit.tapMin(), + btnH - math.ceil((total() - avail) / rows())) + end + local nrows = rows() - 1 + local w = math.floor((cols > 1 and 440 or 360) * m.s) + local px, py, pw = modalPanel(m, w, total()) local cy = py + pad Kit.text("button", Strings("Choose game"), px + pad, cy, PAL.heading) - cy = cy + Kit.textHeight("button") + math.floor(12 * m.s) + cy = cy + headH local chrome = headerChrome(imp) - for _, g in ipairs(GAME_TABS) do - btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "gamepop-" .. g.id, + local colW = math.floor((pw - 2 * pad - (cols - 1) * gap) / cols) + for i, g in ipairs(GAME_TABS) do + local bx = px + pad + ((i - 1) % cols) * (colW + gap) + local by = cy + math.floor((i - 1) / cols) * (btnH + gap) + btn(imp, bx, by, colW, btnH, "gamepop-" .. g.id, Strings(g.label), { face = "tab", font = "small", letter = g.letter, color = g.color, active = imp.tab == g.id, action = chrome.tab[g.id] }) - cy = cy + m.btnH + gap end - btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "gamepop-close", + cy = cy + nrows * (btnH + gap) + btn(imp, px + pad, cy, pw - 2 * pad, btnH, "gamepop-close", Strings("Close"), { font = "small", action = function() imp._gamePopup = nil end }) end --- Category filter for FIND MODS. Two columns, because an index can list --- enough categories to overflow a single stacked column on a short window. +local SEAL_WORD = { open = "open", ["sealed+"] = "sealed+" } + +local function cartRowLabel(row) + local seal = Strings(SEAL_WORD[row.seal] or "sealed") + return Strings("%s - v%s - %s", tostring(row.title or row.id), + tostring(row.version or "?"), seal) +end + +local function buildCartModal(imp, m) + local version = imp._cartPopup + local rows = imp:_ensureCarts(version) + local info = GameVersion.info(version) + local baseName = info and (info.displayName or info.launcherName) + or tostring(version) + local active = imp.activeCart[version] + local pad = math.floor(18 * m.s) + local gap = math.floor(8 * m.s) + local w = math.floor(420 * m.s) + local rowH = m.btnH + local pagerH = math.max(Kit.tapMin(), math.floor(30 * m.s)) + local notice = imp._cartNotice + local noticeH = notice + and (Kit.wrapHeight("small", notice, w - 2 * pad, 2) + gap) or 0 + local emptyH = (#rows == 0) and (Kit.textHeight("small") + gap) or 0 + local fixed = pad + Kit.textHeight("button") + math.floor(12 * m.s) + + noticeH + emptyH + 2 * (rowH + gap) + rowH + pad + local perPage = Kit.rowsThatFit(m.H - 2 * m.pad - fixed, rowH, gap, 1, 8) + local pageKey = "cartpop-" .. tostring(version) + local first, last, cur, pages = Kit.pageBounds(page(imp, pageKey), #rows, perPage) + setPage(imp, pageKey, cur) + local shown = math.max(0, last - first + 1) + local h = fixed + shown * (rowH + gap) + (pages > 1 and (pagerH + gap) or 0) + + local px, py, pw = modalPanel(m, w, h) + local cy = py + pad + Kit.text("button", Strings("Custom Carts"), px + pad, cy, PAL.heading) + cy = cy + Kit.textHeight("button") + math.floor(12 * m.s) + if notice then + cy = cy + Kit.textWrapped("small", notice, px + pad, cy, + pw - 2 * pad, PAL.detail, 2) + gap + end + btn(imp, px + pad, cy, pw - 2 * pad, rowH, "cartpop-vanilla", baseName, { + kind = (active == nil) and "primary" or "ghost", font = "small", + action = function() imp:_selectCart(version, nil) end }) + cy = cy + rowH + gap + if #rows == 0 then + Kit.text("small", Strings("No carts installed for this game yet."), + px + pad, cy, PAL.muted) + cy = cy + Kit.textHeight("small") + gap + end + local expGap = math.floor(6 * m.s) + local expW = math.min(chipWidth(Strings("Export"), m), + math.floor((pw - 2 * pad) * 0.35)) + for i = first, last do + local row = rows[i] + local rowKey = "cartpop-id-" .. tostring(row.id) + local pickW = pw - 2 * pad - expW - expGap + btn(imp, px + pad, cy, pickW, rowH, rowKey, cartRowLabel(row), { + kind = (active == row.id) and "primary" or "ghost", font = "small", + action = function() imp:_selectCart(version, row.id) end }) + btn(imp, px + pad + pickW + expGap, cy, expW, rowH, rowKey .. "-export", + Strings("Export"), { kind = "accent", font = "small", + action = function() imp:exportCart(row.id) end }) + cy = cy + rowH + gap + end + if pages > 1 then + setPage(imp, pageKey, + Kit.pager(px + pad, cy, pw - 2 * pad, cur, #rows, perPage, pageKey)) + cy = cy + pagerH + gap + end + local FilePicker = require("src.core.FilePicker") + btn(imp, px + pad, cy, pw - 2 * pad, rowH, "cartpop-more", + FilePicker.available() and Strings("Import a cart") + or Strings("Get more carts"), + { kind = "accent", font = "small", + action = function() imp:importCartFile(version) end }) + cy = cy + rowH + gap + btn(imp, px + pad, cy, pw - 2 * pad, rowH, "cartpop-close", + Strings("Close"), { font = "small", + action = function() + imp._cartPopup = nil + imp._cartNotice = nil + end }) +end + +local CART_PIN_LINES = 4 + +local function cartPinLines(pins) + local out = {} + for i = 1, math.min(#pins, CART_PIN_LINES) do + local pin = pins[i] + out[#out + 1] = Strings("%s - %s", tostring(pin.name or pin.id), + tostring(pin.reason or "")) + end + if #pins > CART_PIN_LINES then + out[#out + 1] = Strings("...and %d more.", #pins - CART_PIN_LINES) + end + return out +end + +local function buildCartSaveModal(imp, m) + local st = imp._cartSave + local info = GameVersion.info(st.version) + local gameName = (info and (info.launcherName or info.displayName)) + or tostring(st.version) + local pad = math.floor(18 * m.s) + local gap = math.floor(8 * m.s) + local w = math.floor(500 * m.s) + local inner = w - 2 * pad + local fieldH = math.max(Kit.tapMin(), math.floor(36 * m.s)) + + local hint = (st.count == 1) + and Strings("This freezes the 1 mod enabled for %s into a cart.", gameName) + or Strings("This freezes the %d mods enabled for %s into a cart.", + st.count, gameName) + local id = imp:_cartSaveId() + local meta = id + and Strings("id %s - v%s - by %s", id, st.cartVersion, st.author) + or Strings("Type a title - the cart id is built from it.") + + local pins = st.unresolved or {} + local pinHead = (#pins > 0) and ((#pins == 1) + and Strings("1 mod could only be pinned to this install:") + or Strings("%d mods could only be pinned to this install:", #pins)) or nil + local pinRows = pinHead and cartPinLines(pins) or {} + local share = st.publishable + and Strings("Every mod is pinned to a release, so this cart can be shared.") + or Strings("This cart can be saved and played here while those mods stay installed at these versions, and cannot be shared.") + local shareCol = st.publishable and PAL.green or PAL.yellow + + local pinIndent = math.floor(10 * m.s) + local hintH = Kit.wrapHeight("small", hint, inner, 3) + gap + local metaH = Kit.textHeight("micro") + gap + local errH = st.error + and (Kit.wrapHeight("small", st.error, inner, 2) + gap) or 0 + local pinH = 0 + if pinHead then + pinH = Kit.textHeight("small") + math.floor(4 * m.s) + for _, line in ipairs(pinRows) do + pinH = pinH + Kit.wrapHeight("small", line, inner - pinIndent, 2) + + math.floor(2 * m.s) + end + pinH = pinH + gap + end + local shareH = Kit.wrapHeight("small", share, inner, 2) + gap + local footH = Kit.textHeight("micro") + math.floor(8 * m.s) + local h = pad + Kit.textHeight("button") + math.floor(10 * m.s) + hintH + + fieldH + gap + metaH + errH + pinH + shareH + m.btnH + footH + pad + + local px, py, pw = modalPanel(m, w, h) + local cy = py + pad + Kit.text("button", Strings("Save as cart"), px + pad, cy, PAL.heading) + cy = cy + Kit.textHeight("button") + math.floor(10 * m.s) + cy = cy + Kit.textWrapped("small", hint, px + pad, cy, pw - 2 * pad, + PAL.detail, 3) + gap + textField(imp, px + pad, cy, pw - 2 * pad, fieldH, "cartsave-field", + st.text or "", Strings("Cart title"), true) + cy = cy + fieldH + gap + Kit.text("micro", Kit.ellipsize("micro", meta, pw - 2 * pad), px + pad, cy, + PAL.muted) + cy = cy + Kit.textHeight("micro") + gap + if st.error then + cy = cy + Kit.textWrapped("small", st.error, px + pad, cy, pw - 2 * pad, + PAL.red, 2) + gap + end + if pinHead then + Kit.text("small", Kit.ellipsize("small", pinHead, pw - 2 * pad), + px + pad, cy, PAL.yellow) + cy = cy + Kit.textHeight("small") + math.floor(4 * m.s) + for _, line in ipairs(pinRows) do + cy = cy + Kit.textWrapped("small", line, px + pad + pinIndent, cy, + pw - 2 * pad - pinIndent, PAL.detail, 2) + math.floor(2 * m.s) + end + cy = cy + gap + end + cy = cy + Kit.textWrapped("small", share, px + pad, cy, pw - 2 * pad, + shareCol, 2) + gap + + local place = Layout.rightCluster(px + pad, pw - 2 * pad, math.floor(8 * m.s)) + local okLabel = Strings("Save as cart") + local okW = Kit.textWidth("small", okLabel) + math.floor(28 * m.s) + btn(imp, place(okW), cy, okW, m.btnH, "cartsave-ok", okLabel, + { kind = "primary", font = "small", + action = function() imp:_commitCartSave() end }) + local cw = Kit.textWidth("small", Strings("Cancel")) + math.floor(28 * m.s) + btn(imp, place(cw), cy, cw, m.btnH, "cartsave-cancel", Strings("Cancel"), + { font = "small", action = function() imp:_cancelCartSave() end }) + cy = cy + m.btnH + math.floor(8 * m.s) + Kit.text("micro", Strings("Enter to save - Esc to cancel"), px + pad, cy, + PAL.muted) +end + +-- Category filter for FIND MODS, base-game filter for FIND CARTS (a cart has +-- no categories). Two columns, because an index can list enough categories +-- to overflow a single stacked column on a short window. local function buildFilterModal(imp, m) - local cats = (imp.findIndex and imp.findIndex.categories) or {} + local carts = imp.findKind == "carts" + local keys = (imp.findIndex + and (carts and imp.findIndex.baseGames or imp.findIndex.categories)) or {} local items = { { key = nil, label = Strings("All") } } - for _, c in ipairs(cats) do items[#items + 1] = { key = c, label = c } end + for _, c in ipairs(keys) do + items[#items + 1] = { key = c, label = carts and gameLabel(c) or c } + end local pad = math.floor(18 * m.s) local w = math.floor(440 * m.s) local gap = math.floor(8 * m.s) @@ -3591,18 +4219,20 @@ local function buildFilterModal(imp, m) + nrows * (m.btnH + gap) + m.btnH + pad local px, py, pw = modalPanel(m, w, h) local cy = py + pad - Kit.text("button", Strings("Filter by category"), px + pad, cy, PAL.heading) + Kit.text("button", carts and Strings("Filter by base game") + or Strings("Filter by category"), px + pad, cy, PAL.heading) cy = cy + Kit.textHeight("button") + math.floor(12 * m.s) local colW = math.floor((pw - 2 * pad - gap) / 2) + local active = carts and imp.findBase or imp.findCategory for i, it in ipairs(items) do local bx = px + pad + ((i - 1) % 2) * (colW + gap) local by = cy + math.floor((i - 1) / 2) * (m.btnH + gap) local key = it.key btn(imp, bx, by, colW, m.btnH, "filterpop-" .. (key or "all"), it.label, { - kind = (imp.findCategory == key) and "primary" or "ghost", + kind = (active == key) and "primary" or "ghost", font = "small", action = function() - imp.findCategory = key + if carts then imp.findBase = key else imp.findCategory = key end setPage(imp, "find", 1) imp._filterPopup = nil end }) @@ -3960,8 +4590,14 @@ local function buildFindEntryModal(imp, m) end trend = (#trend > 0) and table.concat(trend, " - ") or nil local trendH = trend and (Kit.textHeight("small") + math.floor(2 * m.s)) or 0 + -- What a cart actually is: a pinned mod list. Its own page installs them; + -- this popup only ever installs the cart file. + local pins = ModIndex.isCart(entry) + and Strings("Pins %d mod(s) - install them from the cart's own page", + #(entry.mods or {})) or nil + local pinsH = pins and (Kit.textHeight("small") + math.floor(2 * m.s)) or 0 local h = pad + Kit.textHeight("button") + math.floor(4 * m.s) - + Kit.textHeight("small") + trendH + noteH + math.floor(12 * m.s) + + Kit.textHeight("small") + trendH + pinsH + noteH + math.floor(12 * m.s) + nBtns * (m.btnH + gap) - gap + pad local px, py, pw = modalPanel(m, w, h) local cy = py + pad @@ -3970,7 +4606,10 @@ local function buildFindEntryModal(imp, m) cy = cy + Kit.textHeight("button") + math.floor(4 * m.s) local lead = "v" .. tostring(ModIndex.displayVersion(entry)) if entry.author then lead = lead .. " - " .. entry.author end - if entry.categories and entry.categories[1] then + if ModIndex.isCart(entry) then + lead = lead .. " - " .. gameLabel(entry.base) + if entry.seal then lead = lead .. " - " .. entry.seal end + elseif entry.categories and entry.categories[1] then lead = lead .. " - " .. entry.categories[1] end local dl = stats and ModUpdate.downloadsShort(stats.total) or nil @@ -3987,6 +4626,12 @@ local function buildFindEntryModal(imp, m) px + pad, cy, PAL.muted) cy = cy + Kit.textHeight("small") end + if pins then + cy = cy + math.floor(2 * m.s) + Kit.text("small", Kit.ellipsize("small", pins, pw - 2 * pad), + px + pad, cy, PAL.muted) + cy = cy + Kit.textHeight("small") + end if note then cy = cy + math.floor(4 * m.s) Kit.text("small", note, px + pad, cy, PAL.green) @@ -4946,7 +5591,7 @@ local function modalUp(imp) or imp._appPatchNotes or imp._findDetails or imp._modVersions or imp._modDepResolver or imp._sortPopup or imp._filterPopup or imp._modScopePopup or imp._indexManage - or imp._gamePopup + or imp._gamePopup or imp._cartPopup or imp._cartSave or imp._modActions or imp._modImports or imp._skinActions or imp._syncModal or imp._modHeaderActionsPopup or imp._profilesPopup or imp._singleProfileActions or imp._profileSavePrompt or imp._profileRenamePrompt or imp._findEntry or imp._gameManage) ~= nil @@ -5014,6 +5659,7 @@ local function buildModals(imp, m) }) return true end + if imp._cartSave then buildCartSaveModal(imp, m) return true end if imp._settings then buildSettingsModal(imp, m) return true end if imp._rename then buildPrompt(imp, m, { @@ -5089,6 +5735,7 @@ local function buildModals(imp, m) if imp._modHeaderActionsPopup then buildModHeaderActionsModal(imp, m) return true end if imp._sortPopup then buildSortModal(imp, m) return true end if imp._gamePopup then buildGameModal(imp, m) return true end + if imp._cartPopup then buildCartModal(imp, m) return true end if imp._modScopePopup then buildModScopeModal(imp, m) return true end if imp._filterPopup then buildFilterModal(imp, m) return true end if imp._indexManage then buildIndexesModal(imp, m) return true end diff --git a/src/import/RomExtractorGen2.lua b/src/import/RomExtractorGen2.lua index 1ad47af4..9584a9b6 100644 --- a/src/import/RomExtractorGen2.lua +++ b/src/import/RomExtractorGen2.lua @@ -7,6 +7,7 @@ local bit = require("bit") local GameVersion = require("src.core.GameVersion") local ImageWriter = require("src.import.ImageWriter") local LuaWriter = require("src.import.LuaWriter") +local MonAnim = require("src.render.MonAnim") local Rom = require("src.import.Rom") -- engine/gfx/load_pics.asm FixPicBank. `dba_pic` does NOT store the real @@ -19,6 +20,9 @@ local FIX_PIC_BANK = { [0x14] = 0x20, -- BANK("Pics 13") [0x1f] = 0x2e, -- BANK("Pics 14") } +-- ../pokecrystal/engine/gfx/load_pics.asm:250 EXPORT DEF PICS_FIX EQU $36, and +-- ../pokecrystal/macros/data.asm:93-96 stores BANK(pic) - PICS_FIX flat. +local PICS_FIX = 0x36 local RomExtractorGen2 = {} RomExtractorGen2.__index = RomExtractorGen2 @@ -48,8 +52,10 @@ local ENV_POINTER_COUNT = 8 -- NUM_ENVIRONMENTS + 1 (row 0 is unused) local MAP_GROUP_COUNT = 26 -- constants/map_constants.asm NUM_MAP_GROUPS -- gfx/tileset_palette_maps.asm lives in "bank2" (main.asm) alongside -- EnvironmentColorsPointers, and the Tilesets row only stores a 16-bit --- pointer, so the bank has to come from here. +-- pointer, so the bank has to come from here. Crystal moved the include to +-- "bank13" (../pokecrystal/main.asm:192-195). local PAL_MAP_BANK = 0x02 +local PAL_MAP_BANK_CRYSTAL = 0x13 -- LoadBallIconGFX.gfx (engine/battle/trainer_huds.asm:225-232); bank $0b -- carries no manifest symbol to resolve it through. local BALL_ICON_GFX = { 0x0b, 0x41a4 } @@ -57,6 +63,10 @@ local BALL_ICON_GFX = { 0x0b, 0x41a4 } -- tiles per byte: low nibble first tile, high nibble second (`dn` in the -- tilepal macro). The high bit of each nibble is the VRAM bank, not colour. local TILESET_TILE_COUNT = 96 +-- ../pokecrystal/home/map.asm:1368 copies a second $60 tiles to VRAM bank 1, +-- and engine/tilesets/map_palettes.asm:40 carries that bank in the id's bit 7. +local TILESET_VRAM_TILES = 256 +local CRYSTAL_PAL_MAP_BYTES = 112 -- Every Gen 2 back pic is 6x6 tiles (48x48); only front pics vary in size. local BACK_PIC_TILES = 6 @@ -99,10 +109,27 @@ local MENU_HEADER_LENGTH = 8 -- its own terminator inside its 11 bytes. local NPCTRADE_STRUCT_LENGTH = 32 local MON_NAME_LENGTH, NAME_LENGTH = 11, 11 +-- ../pokecrystal/constants/npc_trade_constants.asm:23 adds NPC_TRADE_FOREST, +-- and :48 adds TRADE_DIALOGSET_GIRL, so PrintTradeText's row stride grows too +-- (../pokecrystal/engine/events/npc_trade.asm:389-395 `ld bc, 2 * 4`). local NUM_NPC_TRADES = 6 +local NUM_NPC_TRADES_CRYSTAL = 7 -- constants/script_constants.asm NUM_BUG_CONTESTANTS, "not counting the -- player", which data/events/bug_contest_flags.asm asserts its length against. local NUM_BUG_CONTESTANTS = 10 +-- ../pokecrystal/constants/script_constants.asm:323-324 NUM_UNOWN_WALLS and +-- UNOWN_WALL_MENU_HEADER_SIZE. +local NUM_UNOWN_WALLS = 4 +local UNOWN_WALL_HEADER_SIZE = 5 +-- constants/item_constants.asm NUM_TM_HM: 50 TMs + 7 HMs in both trees. +local NUM_TM_HM = 57 +-- ../pokecrystal/constants/pokemon_data_constants.asm:113-115. +local PARTYMON_STRUCT_LENGTH = 48 +local NICKNAMED_MON_STRUCT_LENGTH = PARTYMON_STRUCT_LENGTH + MON_NAME_LENGTH +-- ../pokecrystal/data/battle_tower/parties.asm:2, and +-- ../pokecrystal/constants/battle_tower_constants.asm:4. +local BT_LEVEL_GROUPS = 10 +local BT_PARTY_LENGTH = 3 -- constants/phone_constants.asm PHONE_CONTACT_SIZE and SPECIALCALL_SIZE. local PHONE_CONTACT_SIZE = 12 local SPECIALCALL_SIZE = 6 @@ -175,19 +202,35 @@ local function copy(value) end function RomExtractorGen2.new(romData, manifest, progress) + -- _GOLD / _SILVER: the labels are shared, the data behind a handful of + -- them is not (gfx/misc.asm:9-20 vs :46-57). + local edition = GameVersion.forSha1(manifest.romSha1) or "gold" return setmetatable({ rom = Rom.new(romData), manifest = manifest, symbols = manifest.symbols, progress = progress, stage = 0, - -- _GOLD / _SILVER: the labels are shared, the data behind a handful of - -- them is not (gfx/misc.asm:9-20 vs :46-57). - edition = GameVersion.forSha1(manifest.romSha1) == "silver" - and "silver" or "gold", + edition = edition, + -- ../pokecrystal/macros/scripts/events.asm:540 renumbers every command + -- from $52 up, so the whole table has to be resolved per edition. + opcodes = Opcodes.forEdition(edition), }, RomExtractorGen2) end +-- gfx/tileset_palette_maps.asm's bank (../pokecrystal/main.asm:192-195). +function RomExtractorGen2:palMapBank() + if self.edition == "crystal" then return PAL_MAP_BANK_CRYSTAL end + return PAL_MAP_BANK +end + +-- A `dba_pic` bank byte back to the real ROM bank: Gold's three-entry remap +-- (macros/data.asm:101-107) or Crystal's flat `+ PICS_FIX`. +function RomExtractorGen2:picBank(stored) + if self.edition == "crystal" then return stored + PICS_FIX end + return FIX_PIC_BANK[stored] or stored +end + function RomExtractorGen2:symbol(name) local location = self.symbols[name] if not location then error("required symbol is missing: " .. tostring(name)) end @@ -254,17 +297,21 @@ end -- square). pret builds these with `rgbgfx --columns`, so the 2bpp stream -- is column-major; ImageWriter.columnsToRows puts them back into a normal -- top-to-bottom PNG (same as Gen 1's interleave handling). +-- Returns the whole stream: past the base picture are the animation's extra +-- tiles, GetAnimatedEnemyFrontpic's second Get2bpp. +-- ../pokecrystal/engine/gfx/load_pics.asm:132-171 function RomExtractorGen2:writeCompressedPic(label, tiles, relative) - local pixels = self:decompressLz3Symbol(label) + local stream = self:decompressLz3Symbol(label) local size = tiles * 8 local byteLength = size * size / 4 - while #pixels < byteLength do pixels[#pixels + 1] = 0 end - while #pixels > byteLength do table.remove(pixels) end + local pixels = {} + for index = 1, byteLength do pixels[index] = stream[index] or 0 end pixels = ImageWriter.columnsToRows(pixels, tiles, tiles) -- pokegold engine/battle/core.asm GetTrainerBackpic: no hardware masking, -- so matte the white backdrop like Gen 1's writeCompressedPic does. self:save(ImageWriter.matteColor0( ImageWriter.decode2bpp(pixels, size, size)), relative) + return stream end function RomExtractorGen2:extractConstants() @@ -407,6 +454,19 @@ function RomExtractorGen2:extractFont() ImageWriter.blit(extraImg, solidImg, 0, 0, 0, 0, 8, 8) -- $60 ImageWriter.blit(extraImg, solidImg, 8, 0, 8, 0, 8, 8) -- $61 end + -- Crystal splits the pair and makes the arrow 2bpp: black.1bpp at $60, + -- up_arrow.2bpp at $61 (../pokecrystal/gfx/font.asm:51,63; + -- ../pokecrystal/engine/gfx/load_font.asm:44-48,59-64). + if self.symbols["FontsExtra_SolidBlackGFX"] then + local solid = self:symbol("FontsExtra_SolidBlackGFX") + ImageWriter.blit(extraImg, inkFrom1bpp( + self.rom:bytes(solid.bank, solid.address, 8), 8, 8), 0, 0, 0, 0, 8, 8) + end + if self.symbols["FontsExtra2_UpArrowGFX"] then + local arrow = self:symbol("FontsExtra2_UpArrowGFX") + ImageWriter.blit(extraImg, inkFrom2bpp( + self.rom:bytes(arrow.bank, arrow.address, 16), 8, 8), 8, 0, 0, 0, 8, 8) + end if self.symbols["PokegearPhoneIconGFX"] then local phone = self:symbol("PokegearPhoneIconGFX") local phoneImg = inkFrom2bpp( @@ -518,13 +578,15 @@ function RomExtractorGen2:battleObjectPals() return out end --- A tileset's PalMap: 48 bytes, two tiles apiece. `tilepal` emits --- `dn (bank | PAL_BG_second), (bank | PAL_BG_first)`, so the low nibble is --- the even tile and the high nibble the odd one; masking to 3 bits drops the --- OAM_BANK flag and leaves the PAL_BG_* slot. Returned 1-based so the value --- indexes an 8-entry Lua palette set directly. +-- A tileset's PalMap: 48 bytes on Gold and 112 on Crystal, two tiles apiece. +-- `tilepal` emits `dn (bank | PAL_BG_second), (bank | PAL_BG_first)`, so the +-- low nibble is the even tile and the high nibble the odd one; masking to 3 +-- bits drops the OAM_BANK flag and leaves the PAL_BG_* slot. Returned 1-based +-- so the value indexes an 8-entry Lua palette set directly. function RomExtractorGen2:readPalMap(address) - local raw = self.rom:bytes(PAL_MAP_BANK, address, TILESET_TILE_COUNT / 2) + local length = TILESET_TILE_COUNT / 2 + if self.edition == "crystal" then length = CRYSTAL_PAL_MAP_BYTES end + local raw = self.rom:bytes(self:palMapBank(), address, length) local out = {} for i, byte in ipairs(raw) do out[(i - 1) * 2 + 1] = byte % 8 + 1 @@ -681,6 +743,13 @@ local ANIM_FUNCTIONS = { "ScrollTileLeft", "ScrollTileRight", "AnimateWhirlpoolTile", "AnimateLavaBubbleTile1", "AnimateLavaBubbleTile2", "AnimateTowerPillarTile", "FlickeringCaveEntrancePalette", + -- Crystal only: TilesetParkAnim and TilesetForestAnim stop aliasing + -- Tileset0Anim and name these instead + -- (../pokecrystal/data/tileset_anims.asm:25,38, + -- ../pokecrystal/engine/tilesets/tileset_anims.asm:159,234,277,316,354). + "AnimateFountainTile", + "ForestTreeLeftAnimation", "ForestTreeRightAnimation", + "ForestTreeLeftAnimation2", "ForestTreeRightAnimation2", } local ANIM_BANK = 0x3f @@ -727,44 +796,50 @@ function RomExtractorGen2:readTilesetAnim(address, byAddress, strips) local arg = self.rom:word(ANIM_BANK, at) local func = self.rom:word(ANIM_BANK, at + 2) local name = byAddress[func] - if not name then return nil end - local frame = { func = name } - -- A vTiles2 argument is the VRAM tile the step writes; a wTileAnimBuffer - -- one is WRAM and has no tile id. - if arg >= VTILES2 and arg < VTILES2 + 0x800 then - frame.tile = math.floor((arg - VTILES2) / 16) - end - local kind = ANIM_POINTER_KIND[name] - if kind then - local dest = self.rom:word(ANIM_BANK, arg) - if dest >= VTILES2 and dest < VTILES2 + 0x800 then - frame.tile = math.floor((dest - VTILES2) / 16) - local strip = self:animStrip(strips, - ("%s_%02x"):format(kind, frame.tile), kind, - ANIM_BANK, self.rom:word(ANIM_BANK, arg + 2)) - frame.sheet, frame.frames = strip.image, strip.frames - end - elseif name == "AnimateLavaBubbleTile1" or name == "AnimateLavaBubbleTile2" - then - -- Both take no argument: tile $5b and tile $38, one strip (:254, :279). - frame.tile = (name == "AnimateLavaBubbleTile1") and 0x5b or 0x38 - local lava = self.symbols["LavaBubbleTileFrames"] - if lava then - local strip = self:animStrip( - strips, "lava", "lava", lava[1], lava[2]) - frame.sheet, frame.frames = strip.image, strip.frames - end - elseif name == "ReadTileToAnimBuffer" then - pending = { h = 0, v = 0 } - elseif name == "ScrollTileRightLeft" then - if pending then pending.h = pending.h + 1 end - elseif name == "ScrollTileDown" then - if pending then pending.v = pending.v + 1 end - elseif name == "ScrollTileUp" then - if pending then pending.v = pending.v - 1 end - elseif name == "WriteTileFromAnimBuffer" then - if pending and frame.tile then frame.scroll = pending end + -- _AnimateTileset runs one row per frame off wTilesetAnim + -- (../pokecrystal/engine/tilesets/tileset_anims.asm:1-3), so a step with + -- no symbol costs that frame rather than the whole program. + local frame = { func = name or ("unknown_%04x"):format(func) } + if not name then pending = nil + else + -- A vTiles2 argument is the VRAM tile the step writes; a wTileAnimBuffer + -- one is WRAM and has no tile id. + if arg >= VTILES2 and arg < VTILES2 + 0x800 then + frame.tile = math.floor((arg - VTILES2) / 16) + end + local kind = ANIM_POINTER_KIND[name] + if kind then + local dest = self.rom:word(ANIM_BANK, arg) + if dest >= VTILES2 and dest < VTILES2 + 0x800 then + frame.tile = math.floor((dest - VTILES2) / 16) + local strip = self:animStrip(strips, + ("%s_%02x"):format(kind, frame.tile), kind, + ANIM_BANK, self.rom:word(ANIM_BANK, arg + 2)) + frame.sheet, frame.frames = strip.image, strip.frames + end + elseif name == "AnimateLavaBubbleTile1" + or name == "AnimateLavaBubbleTile2" then + -- Both take no argument: tile $5b and tile $38, one strip (:254, :279). + frame.tile = (name == "AnimateLavaBubbleTile1") and 0x5b or 0x38 + local lava = self.symbols["LavaBubbleTileFrames"] + if lava then + local strip = self:animStrip( + strips, "lava", "lava", lava[1], lava[2]) + frame.sheet, frame.frames = strip.image, strip.frames + end + elseif name == "ReadTileToAnimBuffer" then + pending = { h = 0, v = 0 } + elseif name == "ScrollTileRightLeft" then + if pending then pending.h = pending.h + 1 end + elseif name == "ScrollTileDown" then + if pending then pending.v = pending.v + 1 end + elseif name == "ScrollTileUp" then + if pending then pending.v = pending.v - 1 end + elseif name == "WriteTileFromAnimBuffer" then + if pending and frame.tile then frame.scroll = pending end + pending = nil + end end frames[#frames + 1] = frame if name == "DoneTileAnimation" then @@ -774,6 +849,19 @@ function RomExtractorGen2:readTilesetAnim(address, byAddress, strips) return nil end +-- ../pokecrystal/home/map.asm:1357-1370 LoadTilesetGFX's two CopyBytes. +local function crystalTilesetSheet(pixels) + local half = TILESET_TILE_COUNT * 16 + local bank1 = (TILESET_VRAM_TILES / 2) * 16 + local out = {} + for i = 1, bank1 + half do out[i] = 0 end + for i = 1, half do + out[i] = pixels[i] or 0 + out[bank1 + i] = pixels[half + i] or 0 + end + return out +end + -- Every Tilesets row is TILESET_LENGTH (15) bytes: dba GFX, dba Meta, -- dba Coll, dw Anim, dw NULL, dw PalMap (data/tilesets.asm's `tileset` -- macro). Row 0 is the unused "Tileset0" alias of TilesetJohto, and row @@ -783,7 +871,10 @@ function RomExtractorGen2:extractTilesets() self:beginStage("World tiles") local order = self.manifest.constants.tilesetOrder local headers = self:symbol("Tilesets") - local imageWidth, imageHeight = 128, 48 + local twoBank = self.edition == "crystal" + local sheetTiles = twoBank and TILESET_VRAM_TILES or TILESET_TILE_COUNT + local imageWidth = 128 + local imageHeight = sheetTiles / (imageWidth / 8) * 8 local byteLength = imageWidth * imageHeight / 4 -- A cache built from a manifest without the tileset_anims symbols simply @@ -815,6 +906,7 @@ function RomExtractorGen2:extractTilesets() local compressed = self.rom:bytes( gfxBank, gfxAddress, 0x8000 - gfxAddress) local pixels = Rom.decompressLz3(compressed) + if twoBank then pixels = crystalTilesetSheet(pixels) end while #pixels < byteLength do pixels[#pixels + 1] = 0 end while #pixels > byteLength do table.remove(pixels) end local base = constName:lower():gsub("^tileset_", "") @@ -851,9 +943,9 @@ function RomExtractorGen2:extractTilesets() collision = collision, -- Anim callbacks live in bank $3f (data/tilesets.asm). anim = self:readTilesetAnim(animAddress, animByAddress, animStrips), - palMap = { bank = PAL_MAP_BANK, address = palMapAddress }, - -- Which of the eight loaded BG palettes each of the 96 tiles draws - -- with, 1-based into palettes.bg slots (see readPalMap). + palMap = { bank = self:palMapBank(), address = palMapAddress }, + -- Which of the eight loaded BG palettes each sheet tile draws with, + -- 1-based into palettes.bg slots (see readPalMap). tilePalettes = self:readPalMap(palMapAddress), } self:tick("World tiles", index, #order) @@ -1294,11 +1386,9 @@ end -- tiles, no sheet of its own. So the rows here point at the icon sheets -- extractIcons already writes rather than at a second copy of them. -- --- One frame, not two. _DoesSpriteHaveFacings sends everything from --- SPRITE_POKEMON up to .only_down, and a doll is a still object that never --- steps, so the only OAM set it ever uses is FacingStepDown0 -- tiles $00..$03, --- the icon's FIRST frame (data/sprites/facings.asm). The second frame is the --- party menu's bob and never reaches the map. +-- Two frames: SPRITEMOVEDATA_POKEMON's OBJECT_ACTION_BOUNCE swaps +-- FacingStepDown0's tiles $00..$03 for FacingStepUp0's $04..$07 (#1748). +-- data/sprites/map_objects.asm:181-187, engine/overworld/map_object_action.asm:184 -- -- Palette 0 because _GetSpritePalette answers `xor a` for every mon sprite, -- which is PAL_OW_RED in the MapObjectPals set. @@ -1326,7 +1416,7 @@ function RomExtractorGen2:extractMonSprites(out) id = constName, source = ("ROM:SpriteMons[%d]"):format(row), image = "assets/generated/icons/gen2/" .. base .. ".png", - frames = 1, + frames = 2, walker = false, spriteType = "POKEMON_SPRITE", palette = SPRITE_PALETTE_NAME[0], @@ -1340,6 +1430,129 @@ function RomExtractorGen2:extractMonSprites(out) return out end +-- GetMonFramesPointer reads the pointer out of BANK(FramesPointers) but the +-- blob out of BANK(KantoFrames) below this and BANK(JohtoFrames) at or above. +-- ../pokecrystal/engine/gfx/pic_animation.asm:956-1000 +local JOHTO_POKEMON = 152 + +-- A frames blob is one `dw` per frame -- the first points past the list, so +-- the gap IS the count -- then a bitmask id and one tile per set bit. +-- ../pokecrystal/engine/gfx/pic_animation.asm:487-519 +function RomExtractorGen2:readMonFrames(bank, address, bitmaskBank, bitmaskAddress, + tiles) + local first = self.rom:word(bank, address) + local count = (first - address) / 2 + if count < 1 or count > 64 or count % 1 ~= 0 then return nil end + local width = MonAnim.BITMASK_BYTES[tiles] + if not width then return nil end + local bitmasks, ids, frames = {}, {}, {} + for index = 1, count do + local pointer = self.rom:word(bank, address + (index - 1) * 2) + local raw = self.rom:byte(bank, pointer) + local slot = ids[raw] + if not slot then + slot = #bitmasks + 1 + ids[raw] = slot + bitmasks[slot] = self.rom:bytes( + bitmaskBank, bitmaskAddress + raw * width, width) + end + local list = {} + for bit = 0, tiles * tiles - 1 do + local byte = bitmasks[slot][math.floor(bit / 8) + 1] or 0 + if math.floor(byte / 2 ^ (bit % 8)) % 2 == 1 then + list[#list + 1] = self.rom:byte(bank, pointer + #list + 1) + end + end + frames[index] = { bitmask = slot, tiles = list } + end + return frames, bitmasks +end + +-- A script is (command, parameter) pairs; $ff ends it, $fe/$fd are the repeat +-- pair and everything else is a frame id with a duration. +-- ../pokecrystal/macros/scripts/pic_anims.asm:1-27 +function RomExtractorGen2:readMonAnimScript(bank, address) + local rows = {} + for index = 0, 127 do + local command = self.rom:byte(bank, address + index * 2) + if command == MonAnim.END then return rows end + rows[#rows + 1] = { command, self.rom:byte(bank, address + index * 2 + 1) } + end + return nil +end + +-- One column of whole pictures, the base picture first, matted the way +-- writeCompressedPic mattes the static pic. +function RomExtractorGen2:writeMonAnimSheet(stream, tiles, frames, bitmasks, + relative) + local size = tiles * 8 + local perTile = 16 + local available = math.floor(#stream / perTile) + local sheet = ImageWriter.blank(size, size * (#frames + 1), 0, 0, 0, 0) + local data = { tiles = tiles, frames = frames, bitmasks = bitmasks } + for index = 0, #frames do + local map = MonAnim.tileMap(data, index) + if not map then return nil end + local raw = {} + for _, id in ipairs(map) do + if id >= available then return nil end + for offset = 1, perTile do raw[#raw + 1] = stream[id * perTile + offset] end + end + local image = ImageWriter.matteColor0(ImageWriter.decode2bpp( + ImageWriter.columnsToRows(raw, tiles, tiles), size, size)) + ImageWriter.blit(sheet, image, 0, index * size) + end + self:save(sheet, relative) + return "assets/generated/" .. relative +end + +-- The four pointer tables, or nil when this ROM names none of them. +function RomExtractorGen2:monAnimTables(unown) + local prefix = unown and "Unown" or "" + local names = { anim = prefix .. "AnimationPointers", + idle = prefix .. "AnimationIdlePointers", + bitmasks = prefix .. "BitmasksPointers", + frames = prefix .. "FramesPointers" } + local out = {} + for key, name in pairs(names) do + local location = self.symbols[name] + if not location then return nil end + out[key] = { bank = location[1], address = location[2] } + end + -- KantoFrames shares FramesPointers' section and JohtoFrames shares + -- UnownFramesPointers', so both data banks come off those two symbols. + -- ../pokecrystal/main.asm:439-449 + local johto = self.symbols["UnownFramesPointers"] + if not johto then return nil end + out.kantoFrames = out.frames.bank + out.johtoFrames = unown and out.frames.bank or johto[1] + return out +end + +-- Everything one pic needs to animate, or nil when the data does not read +-- back cleanly. +function RomExtractorGen2:monAnimation(tables, index, tiles, stream, name) + if not (tables and stream and tiles) then return nil end + local frames, bitmasks = self:readMonFrames( + (index < JOHTO_POKEMON) and tables.kantoFrames or tables.johtoFrames, + self.rom:word(tables.frames.bank, tables.frames.address + (index - 1) * 2), + tables.bitmasks.bank, + self.rom:word(tables.bitmasks.bank, + tables.bitmasks.address + (index - 1) * 2), + tiles) + if not frames then return nil end + local play = self:readMonAnimScript(tables.anim.bank, + self.rom:word(tables.anim.bank, tables.anim.address + (index - 1) * 2)) + local idle = self:readMonAnimScript(tables.idle.bank, + self.rom:word(tables.idle.bank, tables.idle.address + (index - 1) * 2)) + if not (play and idle and #play > 0) then return nil end + local sheet = self:writeMonAnimSheet(stream, tiles, frames, bitmasks, + "battle/anim/" .. name .. ".png") + if not sheet then return nil end + return { tiles = tiles, sheet = sheet, count = #frames, + bitmasks = bitmasks, frames = frames, play = play, idle = idle } +end + -- BaseData rows are BASE_DATA_SIZE (32) bytes, and -- unlike Gen 1's Kanto -- reorder -- the row index already IS the dex number (data/pokemon/ -- base_stats.asm lists species in constants/pokemon_constants.asm order, @@ -1434,11 +1647,17 @@ function RomExtractorGen2:extractPokemon() -- TMHMMoves maps a TM/HM number to the move it teaches; a species' BASE_TMHM -- bitfield is indexed by that same number (see the tmhm macro), so decoding - -- it needs this table rather than a move id. - local tmhmList = {} + -- it needs this table rather than a move id. Crystal appends the three move + -- tutors as numbers 58-60 (../pokecrystal/data/moves/tmhm_moves.asm:20-25, + -- ../pokecrystal/constants/item_constants.asm:304-310), which are teachable + -- but are not TMs, so they come out as their own list. + local tmhmList, tutorList = {}, {} for i = 0, 63 do local moveId = self.rom:byte(tmhmMoves.bank, tmhmMoves.address + i) if moveId == 0 then break end + if i + 1 > NUM_TM_HM then + tutorList[i + 1 - NUM_TM_HM] = moveOrder[moveId] or moveId + end tmhmList[i + 1] = moveOrder[moveId] or moveId end @@ -1461,6 +1680,8 @@ function RomExtractorGen2:extractPokemon() end local out = { growthRates = growth, tmhmMoves = tmhmList } + if #tutorList > 0 then out.tutorMoves = tutorList end + local animTables = self:monAnimTables(false) for index, species in ipairs(speciesOrder) do if species then local row = self.rom:bytes( @@ -1472,11 +1693,17 @@ function RomExtractorGen2:extractPokemon() local asset = self.manifest.pokemonAssets[species] local tiles = row[18] % 16 -- BASE_PIC_SIZE low nibble, tiles wide/tall - local front, back + local front, back, anim if asset and asset.frontLabel then - self:writeCompressedPic(asset.frontLabel, tiles, + local stream = self:writeCompressedPic(asset.frontLabel, tiles, "battle/front/" .. asset.front .. ".png") front = asset.front + if animTables and asset.animLabel and asset.framesLabel + and asset.bitmaskLabel and asset.idleLabel then + local ok, result = pcall(self.monAnimation, self, animTables, index, + tiles, stream, asset.front) + if ok then anim = result else self:trace(species .. " anim: " .. tostring(result)) end + end end if asset and asset.backLabel then -- Back pics are ALWAYS 6x6 tiles (48x48). BASE_PIC_SIZE's low nibble @@ -1518,13 +1745,17 @@ function RomExtractorGen2:extractPokemon() -- TM/HM number n * 8 + i + 1 (the tmhm macro's layout). local tmhmRaw = { row[25], row[26], row[27], row[28], row[29], row[30], row[31], row[32] } - local tmhm = {} + local tmhm, tutorMoves = {}, {} for byteIndex, byteValue in ipairs(tmhmRaw) do for bit = 0, 7 do if math.floor(byteValue / 2 ^ bit) % 2 == 1 then local number = (byteIndex - 1) * 8 + bit + 1 local move = tmhmList[number] - if move then tmhm[#tmhm + 1] = move end + if move and number > NUM_TM_HM then + tutorMoves[#tutorMoves + 1] = move + elseif move then + tmhm[#tmhm + 1] = move + end end end end @@ -1554,11 +1785,13 @@ function RomExtractorGen2:extractPokemon() eggGroupsRaw = row[24], tmhmRaw = tmhmRaw, tmhm = tmhm, + tutorMoves = (#tutorMoves > 0) and tutorMoves or nil, evolutions = evolutions, levelMoves = levelMoves, eggMoves = eggMoves, spriteFront = front and ("assets/generated/battle/front/" .. front .. ".png") or nil, spriteBack = back and ("assets/generated/battle/back/" .. back .. ".png") or nil, + anim = anim, } end self:tick("Pokemon", index, #speciesOrder) @@ -1571,13 +1804,14 @@ function RomExtractorGen2:extractPokemon() local symbol = self:symbol("UnownPicPointers") local letters = {} local tiles = out.UNOWN.picSize or 6 + local unownTables = self:monAnimTables(true) for index = 0, 25 do local letter = string.char(string.byte("A") + index) local base = symbol.address + index * 6 local entry = {} + local frontStream local function readPic(offset, size, folder, key) - local bank = FIX_PIC_BANK[self.rom:byte(symbol.bank, base + offset)] - or self.rom:byte(symbol.bank, base + offset) + local bank = self:picBank(self.rom:byte(symbol.bank, base + offset)) local address = self.rom:word(symbol.bank, base + offset + 1) local rel = ("battle/%s/unown_%s.png"):format(folder, letter:lower()) local ok, err = pcall(function() @@ -1589,11 +1823,12 @@ function RomExtractorGen2:extractPokemon() for _, byte in ipairs(nextBank) do compressed[#compressed + 1] = byte end - local pixels = Rom.decompressLz3(compressed) + local stream = Rom.decompressLz3(compressed) + if key == "spriteFront" then frontStream = stream end local pixelSize = size * 8 local byteLength = pixelSize * pixelSize / 4 - while #pixels < byteLength do pixels[#pixels + 1] = 0 end - while #pixels > byteLength do table.remove(pixels) end + local pixels = {} + for i = 1, byteLength do pixels[i] = stream[i] or 0 end pixels = ImageWriter.columnsToRows(pixels, size, size) self:write2bpp(pixels, pixelSize, pixelSize, rel) end) @@ -1605,6 +1840,15 @@ function RomExtractorGen2:extractPokemon() end readPic(0, tiles, "front", "spriteFront") readPic(3, 6, "back", "spriteBack") + if unownTables and frontStream then + local ok, result = pcall(self.monAnimation, self, unownTables, + index + 1, tiles, frontStream, "unown_" .. letter:lower()) + if ok then + entry.anim = result + else + self:trace(("unown %s anim: %s"):format(letter, tostring(result))) + end + end letters[letter] = entry end out.UNOWN.letters = letters @@ -1614,6 +1858,7 @@ function RomExtractorGen2:extractPokemon() or (letters.A and letters.A.spriteFront) out.UNOWN.spriteBack = out.UNOWN.spriteBack or (letters.A and letters.A.spriteBack) + out.UNOWN.anim = out.UNOWN.anim or (letters.A and letters.A.anim) end self:write("pokemon", out) @@ -1629,6 +1874,11 @@ end -- FillTitleScreenPals / title_bg_gold.pal colorize BG zones; title_fg.pal -- tints Ho-Oh. Clouds (rows 11+) scroll via LYOverrides in retail. function RomExtractorGen2:extractTitle() + -- Crystal has no title tilemap: DrawTitleGraphic composes on the fly + -- (../pokecrystal/engine/movie/title.asm:104-118). + if self.edition == "crystal" then + return require("src.import.CrystalMovie").extractTitle(self) + end self:beginStage("Title screen") local function tilesFrom2bpp(raw, transparent) @@ -2077,12 +2327,22 @@ local CREDITS_SCENES = { { species = "ELEKID", label = "CreditsElekidGFX", frames = 3 }, { species = "SENTRET", label = "CreditsSentretGFX", frames = 4 }, } +-- ../pokecrystal/engine/movie/credits.asm:610-613; each is $400 bytes, so all +-- four run 4 frames of 4x4 tiles rather than Gold's 3/3/3/4. +local CREDITS_SCENES_CRYSTAL = { + { species = "PICHU", label = "CreditsPichuGFX", frames = 4 }, + { species = "SMOOCHUM", label = "CreditsSmoochumGFX", frames = 4 }, + { species = "DITTO", label = "CreditsDittoGFX", frames = 4 }, + { species = "IGGLYBUFF", label = "CreditsIgglybuffGFX", frames = 4 }, +} local CREDITS_BORDER_TILES = 9 local CREDITS_THEEND_TILES = 16 function RomExtractorGen2:extractCredits() self:beginStage("Credits") - local steps = #CREDITS_SCENES + 3 + local sceneList = (self.edition == "crystal") + and CREDITS_SCENES_CRYSTAL or CREDITS_SCENES + local steps = #sceneList + 3 local data = { generation = 2, source = "ROM:CreditsBorderGFX + CreditsGFX + TheEndGFX" @@ -2114,7 +2374,7 @@ function RomExtractorGen2:extractCredits() self:tick("Credits", 2, steps) local scenes = {} - for index, scene in ipairs(CREDITS_SCENES) do + for index, scene in ipairs(sceneList) do if self.symbols[scene.label] then local sym = self:symbol(scene.label) local raw = self.rom:bytes(sym.bank, sym.address, scene.frames * 16 * 16) @@ -2130,7 +2390,7 @@ function RomExtractorGen2:extractCredits() end self:tick("Credits", 2 + index, steps) end - if #scenes == #CREDITS_SCENES then data.scenes = scenes end + if #scenes == #sceneList then data.scenes = scenes end if self.symbols["CreditsPalettes"] then local pal = self:symbol("CreditsPalettes") @@ -2370,7 +2630,10 @@ end -- generation-2 channel driver. Banks are NOT Gen 1's {2,8,31}. function RomExtractorGen2:extractAudio(maps) self:beginStage("Sound programs") + -- "Songs 5" and "Crystal Sound Effects" are bank $5e + -- (../pokecrystal/layout.link:246-249). local bankOrder = { 0x07, 0x33, 0x3a, 0x3b, 0x3c, 0x3d } + if self.edition == "crystal" then bankOrder[#bankOrder + 1] = 0x5e end local chunks = {} for index, bank in ipairs(bankOrder) do local first = Rom.offset(bank, 0x4000) + 1 @@ -2410,10 +2673,17 @@ function RomExtractorGen2:extractAudio(maps) local wave = self:symbol("WaveSamples") local drums = self:symbol("Drumkits") local mapSongs = {} + -- GetMapMusic (home/map.asm:2204-2207) takes MUSIC_MAHOGANY_MART and the + -- RADIO_TOWER_MUSIC bit off the table before it ever indexes Music. On Gold + -- both fall outside NUM_MUSIC_SONGS and drop out on their own; Crystal's + -- MUSIC_MAHOGANY_MART aliases MUSIC_SUICUNE_BATTLE ($64), which resolves. + -- ../pokecrystal/constants/music_constants.asm:112,120. + local MUSIC_MAHOGANY_MART, RADIO_TOWER_MUSIC = 100, 0x80 if maps then for mapId, def in pairs(maps) do local id = def.music - if type(id) == "number" and id > 0 then + if type(id) == "number" and id > 0 + and id ~= MUSIC_MAHOGANY_MART and id < RADIO_TOWER_MUSIC then local label = musicOrder[id + 1] if label and songs[label] then mapSongs[mapId] = label @@ -2738,7 +3008,9 @@ function RomExtractorGen2:readEventTables() -- so the cache carries the items.lua key the rest of the port indexes with. local itemOrder = self.manifest.constants.itemOrder or {} local tradeRows = {} - for row = 0, NUM_NPC_TRADES - 1 do + local tradeCount = (self.edition == "crystal") + and NUM_NPC_TRADES_CRYSTAL or NUM_NPC_TRADES + for row = 0, tradeCount - 1 do local base = trades.address + row * NPCTRADE_STRUCT_LENGTH local raw = self.rom:bytes(trades.bank, base, 3) local dvBase = base + 3 + MON_NAME_LENGTH @@ -2770,7 +3042,10 @@ function RomExtractorGen2:readEventTables() -- The trade conversation's five lines, one set per TRADE_DIALOGSET_*. -- PrintTradeText is `TradeTexts + 6 * dialog + 2 * dialogset`, so the table -- is stored dialog-major: three INTROs, then three CANCELs, and so on. + -- Crystal's fourth dialogset makes the row stride 8 + -- (../pokecrystal/engine/events/npc_trade.asm:389-399 `ld bc, 2 * 4`). local tradeTexts = self:symbol("TradeTexts") + local tradeStride = (self.edition == "crystal") and 8 or 6 local dialogs = { "TRADE_DIALOG_INTRO", "TRADE_DIALOG_CANCEL", "TRADE_DIALOG_WRONG", "TRADE_DIALOG_COMPLETE", "TRADE_DIALOG_AFTER" } local sets = consts.tradeDialogOrder or {} @@ -2783,7 +3058,7 @@ function RomExtractorGen2:readEventTables() local row, bufRow = {}, {} for s = 1, #sets do local addr = self.rom:word(tradeTexts.bank, - tradeTexts.address + (d - 1) * 6 + (s - 1) * 2) + tradeTexts.address + (d - 1) * tradeStride + (s - 1) * 2) local buffers = {} row[sets[s]] = self:decodeGen2Text(tradeTexts.bank, addr, charmap, buffers) @@ -2872,6 +3147,35 @@ function RomExtractorGen2:readEventTables() } out.decorationOrder = consts.decoDescOrder + -- ../pokecrystal/data/events/unown_walls.asm:7 UnownWalls and :15 MenuHeaders_UnownWalls; + -- macros/coords.asm:71 `menu_coords` emits y before x. + local unownWalls = self.symbols.UnownWalls + local unownHeaders = self.symbols.MenuHeaders_UnownWalls + local unownMap = self.manifest.unownCharmap + if unownWalls and unownHeaders and unownMap then + local walls = {} + local address = unownWalls[2] + for wall = 0, NUM_UNOWN_WALLS - 1 do + local chars, word = {}, {} + while true do + local byte = self.rom:byte(unownWalls[1], address) + address = address + 1 + if byte == 0xff then break end + chars[#chars + 1] = byte + word[#word + 1] = unownMap[tostring(byte)] or "?" + end + local head = self.rom:bytes( + unownHeaders[1], unownHeaders[2] + wall * UNOWN_WALL_HEADER_SIZE, + UNOWN_WALL_HEADER_SIZE) + walls[wall + 1] = { + id = wall, word = table.concat(word), chars = chars, + flags = head[1], + y1 = head[2], x1 = head[3], y2 = head[4], x2 = head[5], + } + end + out.unownWalls = walls + end + return out end @@ -3366,7 +3670,7 @@ function RomExtractorGen2:extractScriptsAndText(maps, stdScripts) commands[#commands + 1] = { op = "truncated", reason = "read" } break end - local info = Opcodes[opcode] + local info = self.opcodes[opcode] if not info then commands[#commands + 1] = { op = "unknown", code = opcode, @@ -3395,7 +3699,9 @@ function RomExtractorGen2:extractScriptsAndText(maps, stdScripts) if info.name == "writetext" or info.name == "jumptext" or info.name == "jumptextfaceplayer" then cmd.text = ensureText(bank, wordFromArgs(args)) - elseif info.name == "farwritetext" then + -- farjumptext is Crystal's new $52 and carries a dba, not a dw + -- (../pokecrystal/engine/overworld/scripting.asm:318-327). + elseif info.name == "farwritetext" or info.name == "farjumptext" then local tBank, tAddr = dbaFromArgs(args) cmd.text = ensureText(tBank, tAddr) elseif info.name == "checkevent" or info.name == "setevent" @@ -3613,7 +3919,7 @@ function RomExtractorGen2:extractInitialEvents() if not romAddrOk(bank, pc) then break end local okByte, opcode = pcall(self.rom.byte, self.rom, bank, pc) if not okByte then break end - local info = Opcodes[opcode] + local info = self.opcodes[opcode] if not info then break end local size = info.size local okArgs, args = true, {} @@ -3678,10 +3984,7 @@ local PREDEFPAL_GAMEFREAK_LOGO_BG = 78 -- indexes straight off it. The star is 1x2 tiles and the OAM set mirrors it -- to make the other half; the sparkle strip is 3 frames in a row. function RomExtractorGen2:splashGfx() - if not (self.symbols.GameFreakLogoGFX - and self.symbols.GameFreakLogoStarsGFX) then - return nil - end + if not self.symbols.GameFreakLogoGFX then return nil end local gfx = self:symbol("GameFreakLogoGFX") local raw = self.rom:bytes(gfx.bank, gfx.address, 28 * 8) local presents, logo = {}, {} @@ -3695,21 +3998,53 @@ function RomExtractorGen2:splashGfx() -- finds a logo image there, and now it is the right one. self:save(inkFrom1bpp(logo, 24, 40), "intro/gamefreak_logo.png") - local stars = self:symbol("GameFreakLogoStarsGFX") - local starRaw = self.rom:bytes(stars.bank, stars.address, 2 * 16) - local sparkleRaw = self.rom:bytes(stars.bank, stars.address + 2 * 16, 3 * 16) - -- 2bpp with shade 0 transparent, the way every OBJ sheet is written. - self:write2bpp(starRaw, 8, 16, "splash/star.png", true) - self:write2bpp(sparkleRaw, 24, 8, "splash/sparkle.png", true) - - return { + local out = { presents = "assets/generated/splash/presents.png", logo = "assets/generated/splash/logo.png", - star = "assets/generated/splash/star.png", - sparkle = "assets/generated/splash/sparkle.png", obPalette = self:predefPal(PREDEFPAL_GAMEFREAK_LOGO_OB), bgPalette = self:predefPal(PREDEFPAL_GAMEFREAK_LOGO_BG), } + + if self.symbols.GameFreakLogoStarsGFX then + local stars = self:symbol("GameFreakLogoStarsGFX") + local starRaw = self.rom:bytes(stars.bank, stars.address, 2 * 16) + local sparkleRaw = self.rom:bytes( + stars.bank, stars.address + 2 * 16, 3 * 16) + -- 2bpp with shade 0 transparent, the way every OBJ sheet is written. + self:write2bpp(starRaw, 8, 16, "splash/star.png", true) + self:write2bpp(sparkleRaw, 24, 8, "splash/sparkle.png", true) + out.star = "assets/generated/splash/star.png" + out.sparkle = "assets/generated/splash/sparkle.png" + end + + -- Crystal has no stars: GameFreakPresentsInit decompresses ditto.2bpp.lz and + -- requests 8 tiles to vTiles0 and 8 more from +$80 tiles to vTiles1 + -- (../pokecrystal/engine/movie/splash.asm:61-88). The whole 256-tile sheet + -- is written as one 16-wide page so tile n is (n % 16, n / 16). + if self.symbols.GameFreakDittoGFX then + local ditto = self:decompressLz3Symbol("GameFreakDittoGFX") + local tiles = math.floor(#ditto / 16) + local rows = math.ceil(tiles / 16) + while #ditto < rows * 16 * 16 do ditto[#ditto + 1] = 0 end + self:write2bpp(ditto, 128, rows * 8, "splash/ditto.png", true) + out.ditto = "assets/generated/splash/ditto.png" + out.dittoTiles = tiles + out.dittoTilesWide = 16 + end + -- gfx/splash/ditto_fade.pal, one colour per step of the pink-to-orange fade + -- Ditto runs as it becomes the logo. + if self.symbols.GameFreakDittoPaletteFade then + local fade = self:symbol("GameFreakDittoPaletteFade") + out.dittoFade = self:colors(fade.bank, fade.address, 16) + end + -- gfx/splash/ditto.pal, the OBJ palette _CGB_GamefreakLogo loads + -- (../pokecrystal/engine/gfx/cgb_layouts.asm:876-893). + if self.symbols["_CGB_GamefreakLogo.GamefreakDittoPalette"] then + local pal = self:symbol("_CGB_GamefreakLogo.GamefreakDittoPalette") + out.dittoPalette = self:colors(pal.bank, pal.address, 4) + end + + return out end -- The engine's own strings, keyed by the label the disassembly gives them. @@ -3764,17 +4099,39 @@ function RomExtractorGen2:extractOakSpeech(pokemon) if self.symbols.Shrink2Pic then pcall(self.writeCompressedPic, self, "Shrink2Pic", 7, "intro/shrink2.png") end + -- The player pic OakSpeech draws: Crystal's DrawIntroPlayerPic picks + -- Chris/Kris by gender (../pokecrystal/engine/gfx/player_gfx.asm:170-201), + -- both raw column-major 7x7 2bpp (../pokecrystal/Makefile:319,321). + local playerPic, playerPicFemale = nil, nil + for key, label in pairs({ chris = "ChrisPic", kris = "KrisPic" }) do + if self.symbols[label] then + local sym = self:symbol(label) + local pixels = ImageWriter.columnsToRows( + self.rom:bytes(sym.bank, sym.address, 7 * 7 * 16), 7, 7) + self:write2bpp(pixels, 56, 56, "intro/" .. key .. ".png") + if key == "chris" then + playerPic = "assets/generated/intro/chris.png" + else + playerPicFemale = "assets/generated/intro/kris.png" + end + end + end local splash = self:splashGfx() - local marill = pokemon and pokemon.MARILL + -- OakSpeech's demo mon: MARILL on Gold (../pokegold/engine/menus/ + -- intro_menu.asm:519), WOOPER on Crystal (../pokecrystal/engine/menus/ + -- intro_menu.asm:652). + local demoSpecies = (self.edition == "crystal") and "WOOPER" or "MARILL" + local demoMon = pokemon and pokemon[demoSpecies] local data = { generation = 2, source = "ROM:OakSpeech (_OakText1-7, PokemonProfPic, CalPic)", music = "Music_Route30", - demoSpecies = "MARILL", + demoSpecies = demoSpecies, oakPic = "assets/generated/intro/oak.png", - playerPic = "assets/generated/intro/cal.png", - marillPic = marill and marill.spriteFront - or "assets/generated/battle/front/marill.png", + playerPic = playerPic or "assets/generated/intro/cal.png", + playerPicFemale = playerPicFemale, + marillPic = demoMon and demoMon.spriteFront + or ("assets/generated/battle/front/%s.png"):format(demoSpecies:lower()), shrink1 = "assets/generated/intro/shrink1.png", shrink2 = "assets/generated/intro/shrink2.png", gamefreakLogo = "assets/generated/intro/gamefreak_logo.png", @@ -4201,6 +4558,56 @@ function RomExtractorGen2:readRoamMaps() return out end +-- InitRoamMons (engine/overworld/wildmons.asm) is straight-line +-- `ld a, n` / `ld [wRoamMonN], a` writes with one `xor a` before the HP +-- pair, so the roster is read out of the code rather than a table: Gold seeds +-- three beasts (:488-529), Crystal two (../pokecrystal/.../wildmons.asm: +-- 493-524). roam_struct is species, level, group, number, hp, dvs +-- (macros/ram.asm:218-225), so the writes land at base+0..base+4, stride 7. +local ROAMMON_STRUCT_LENGTH = 7 +local LD_A_N, LD_NN_A, XOR_A, RET = 0x3e, 0xea, 0xaf, 0xc9 + +function RomExtractorGen2:readRoamMons() + local symbol = self:symbol("InitRoamMons") + local writes, order = {}, {} + local at, acc = symbol.address, 0 + for _ = 1, 128 do + if not romAddrOk(symbol.bank, at + 2) then return nil end + local op = self.rom:byte(symbol.bank, at) + if op == RET then break end + if op == XOR_A then + acc, at = 0, at + 1 + elseif op == LD_A_N then + acc, at = self.rom:byte(symbol.bank, at + 1), at + 2 + elseif op == LD_NN_A then + local dest = self.rom:word(symbol.bank, at + 1) + if writes[dest] == nil then order[#order + 1] = dest end + writes[dest] = acc + at = at + 3 + else + return nil + end + end + -- wRoamMon1..3 are consecutive (../pokecrystal/ram/wram.asm:3486-3488), so + -- the first destination written is wRoamMon1Species. + local base = order[1] + if not base then return nil end + local roamers = {} + for slot = 0, 2 do + local at2 = base + slot * ROAMMON_STRUCT_LENGTH + local species = writes[at2] + if not species or species == 0 then break end + roamers[#roamers + 1] = { + species = self:speciesName(species), + level = writes[at2 + 1], + mapGroup = writes[at2 + 2], + mapNumber = writes[at2 + 3], + map = self:mapNameByIds(writes[at2 + 2] or 0, writes[at2 + 3] or 0), + } + end + return (#roamers > 0) and roamers or nil +end + -- data/wild/bug_contest_mons.asm. A shape of its own, and deliberately not -- read through readGrassTable: there is no map key, no per-time rate and no -- seven-slot list, just `db %, species, min, max` rows that @@ -4399,6 +4806,12 @@ function RomExtractorGen2:extractEncounters() local swarmWater = self.symbols.SwarmWaterWildMons and self:readWaterTable("SwarmWaterWildMons") or nil local roamMaps = self.symbols.RoamMaps and self:readRoamMaps() or nil + local roamMons = nil + if self.symbols.InitRoamMons then + local ok, result = pcall(self.readRoamMons, self) + roamMons = ok and result or nil + if not ok then self:trace("roam mons: " .. tostring(result)) end + end local data = { generation = 2, @@ -4414,6 +4827,7 @@ function RomExtractorGen2:extractEncounters() swarmGrass = swarmGrass, swarmWater = swarmWater, roamMaps = roamMaps, + roamMons = roamMons, } self:trace("writing encounters.lua") self:write("encounters", data) @@ -4421,6 +4835,150 @@ function RomExtractorGen2:extractEncounters() return data end +-- ../pokecrystal/engine/events/battle_tower/load_trainer.asm:29-38 and +-- :117-119. Both rejection loops are `maskbits N / cp N / jr nc, .resample`, +-- which rgbasm lays down as E6 mask / FE count / 30 rel -- so the ceiling the +-- cart really samples against is read here rather than written down. Crystal +-- 1.0 and 1.1 differ in exactly this byte (:33-37). +function RomExtractorGen2:btSampleCeiling(label) + local symbol = self:symbol(label) + local raw = self.rom:bytes(symbol.bank, symbol.address, 24) + for index = 1, #raw - 4 do + if raw[index] == 0xe6 and raw[index + 2] == 0xfe + and raw[index + 4] == 0x30 then + return raw[index + 3] + end + end + error("battle tower sample ceiling not found at " .. label) +end + +-- One `party_struct` (../pokecrystal/macros/ram.asm party_struct, laid out by +-- ../pokecrystal/constants/pokemon_data_constants.asm:75-113). Every word in +-- it is big-endian, which is what `bigdw` / `bigdt` in parties.asm emit. +function RomExtractorGen2:readBattleTowerMon(bank, address, moveOrder, itemOrder) + local raw = self.rom:bytes(bank, address, PARTYMON_STRUCT_LENGTH) + local function word(index) return raw[index] * 0x100 + raw[index + 1] end + local moves, pp = {}, {} + for slot = 0, 3 do + local moveId = raw[3 + slot] + if moveId ~= 0 then + moves[#moves + 1] = moveOrder[moveId] or moveId + -- MON_PP's top two bits are the PP Ups (constants/pokemon_data_ + -- constants.asm:135 PP_UP_MASK); no Tower row uses them, and masking + -- here keeps a count out of the port's pp field either way. + pp[#pp + 1] = raw[24 + slot] % 64 + end + end + local item = itemOrder[raw[2]] + if item == "NO_ITEM" then item = nil end + return { + species = self:speciesName(raw[1]), + item = item, + moves = moves, + pp = pp, + otId = word(7), + experience = raw[9] * 0x10000 + raw[10] * 0x100 + raw[11], + statExp = { + hp = word(12), attack = word(14), defense = word(16), + speed = word(18), special = word(20), + }, + -- MON_DVS: two nibble pairs, attack/defense then speed/special. + dvs = { + attack = math.floor(raw[22] / 16), defense = raw[22] % 16, + speed = math.floor(raw[23] / 16), special = raw[23] % 16, + }, + happiness = raw[28], + level = raw[32], + -- MON_HP / MON_MAXHP / MON_STATS, stored rather than recomputed: the cart + -- copies these bytes straight into wOTPartyMon. + hp = word(35), + maxHp = word(37), + stats = { + hp = word(37), attack = word(39), defense = word(41), + speed = word(43), specialAttack = word(45), specialDefense = word(47), + }, + } +end + +-- The Battle Tower roster. A separate table from Trainers/TrainerGroups in +-- every way: the trainers are 70 fixed-width name+class rows +-- (../pokecrystal/data/battle_tower/classes.asm:1-4 `bt_trainer`), and their +-- parties are not read from the trainer row at all -- LoadRandomBattleTowerMon +-- draws three FULLY BUILT party_structs out of BattleTowerMons, which is ten +-- level groups of twenty-one mons carrying their own DVs, stat exp, PP and +-- final stats. So none of extractTrainers' TRAINERTYPE_* walk applies here. +-- +-- Gold and Silver have neither symbol and get nil. +function RomExtractorGen2:readBattleTowerRoster(consts, charmap) + if not (self.symbols["BattleTowerTrainers"] + and self.symbols["BattleTowerMons"]) then + return nil + end + local classOrder = consts.trainerClassOrder or {} + local spriteOrder = consts.spriteOrder or {} + local moveOrder = consts.moveOrder or {} + local itemOrder = consts.itemOrder or {} + local trainerSym = self:symbol("BattleTowerTrainers") + local monSym = self:symbol("BattleTowerMons") + -- BattleTowerMons is the label straight after BattleTowerTrainers + -- (../pokecrystal/engine/events/battle_tower/load_trainer.asm:210-212 + -- includes classes.asm then parties.asm), so the gap IS the row count. + local uniqueTrainers = + math.floor((monSym.address - trainerSym.address) / NAME_LENGTH) + local uniqueMon = self:btSampleCeiling("LoadRandomBattleTowerMon.resample") + local trainers = {} + for index = 0, uniqueTrainers - 1 do + local address = trainerSym.address + index * NAME_LENGTH + local raw = self.rom:bytes(trainerSym.bank, address, NAME_LENGTH - 1) + local classId = self.rom:byte(trainerSym.bank, address + NAME_LENGTH - 1) + trainers[index + 1] = { + index = index, + name = self.rom:decodeText(raw, charmap, 0x50), + class = classId, + classId = classOrder[classId + 1], + } + end + local groups = {} + for group = 1, BT_LEVEL_GROUPS do + local rows = {} + for slot = 0, uniqueMon - 1 do + local address = monSym.address + + ((group - 1) * uniqueMon + slot) * NICKNAMED_MON_STRUCT_LENGTH + rows[slot + 1] = self:readBattleTowerMon( + monSym.bank, address, moveOrder, itemOrder) + end + groups[group] = rows + end + -- ../pokecrystal/data/trainers/sprites.asm:1-3, one SPRITE_* per trainer class starting at + -- class 1; LoadOpponentTrainerAndPokemonWithOTSprite indexes it with + -- `wBT_OTTrainerClass - 1` (battle_tower.asm:1540-1550). The table stops + -- one row short of the class list: `assert_table_length NUM_TRAINER_CLASSES + -- - 1 ; exclude MYSTICALMAN` (../pokecrystal/data/trainers/sprites.asm:70). + local sprites = {} + if self.symbols["BTTrainerClassSprites"] then + local spriteSym = self:symbol("BTTrainerClassSprites") + for classId = 1, #classOrder - 2 do + local byte = self.rom:byte(spriteSym.bank, spriteSym.address + classId - 1) + sprites[classOrder[classId + 1]] = spriteOrder[byte] or byte + end + end + return { + source = "ROM:BattleTowerTrainers + BattleTowerMons" + .. " + BTTrainerClassSprites", + partyLength = BT_PARTY_LENGTH, + levelGroups = BT_LEVEL_GROUPS, + uniqueMon = uniqueMon, + uniqueTrainers = uniqueTrainers, + -- The trainer draw's own ceiling, which on Crystal 1.0 is uniqueMon and + -- not uniqueTrainers (load_trainer.asm:33-37). + sampleTrainers = + self:btSampleCeiling("LoadOpponentTrainerAndPokemon.resample"), + trainers = trainers, + groups = groups, + classSprites = sprites, + } +end + -- Trainers. TrainerGroups is indexed by trainer class - 1 (the table starts -- at FALKNER = 1); each group is a run of variable-length parties whose shape -- depends on the party's own TRAINERTYPE_* byte, ending at -1. @@ -4566,6 +5124,7 @@ function RomExtractorGen2:extractTrainers() } self:tick("Trainers", classIndex, #classOrder) end + out.battleTower = self:readBattleTowerRoster(consts, charmap) self:write("trainers", out) return out end @@ -4910,6 +5469,11 @@ function RomExtractorGen2:introPalettes() end function RomExtractorGen2:extractIntro() + -- Crystal's intro is a different program with its own asset set + -- (../pokecrystal/engine/movie/intro.asm:1 CrystalIntro, :1678-1777 GFX). + if self.edition == "crystal" then + return require("src.import.CrystalMovie").extractIntro(self) + end self:beginStage("Intro movie") local out = { generation = 2, source = "ROM:Intro_*GFX/Tilemap/Meta" } @@ -5045,6 +5609,25 @@ function RomExtractorGen2:extractMenuGfx() if ok then hud.playerBack = "assets/generated/battle/player_back.png" end end + -- GetKrisBackpic, six tiles square like Chris but stored raw rather than lz3 + -- (../pokecrystal/engine/gfx/player_gfx.asm:209-217). + if self.symbols["KrisBackpic"] then + local sym = self:symbol("KrisBackpic") + local ok = pcall(function() + local size = BACK_PIC_TILES * 8 + local pixels = ImageWriter.columnsToRows( + self.rom:bytes(sym.bank, sym.address, + BACK_PIC_TILES * BACK_PIC_TILES * 16), + BACK_PIC_TILES, BACK_PIC_TILES) + self:save(ImageWriter.matteColor0( + ImageWriter.decode2bpp(pixels, size, size)), + "battle/player_back_female.png") + end) + if ok then + hud.playerBackFemale = "assets/generated/battle/player_back_female.png" + end + end + -- GetTrainerBackpic's "Special exception for Dude": the catching tutorial -- draws DudeBackpic in that same box, from the same bank and at the same six -- tiles square (src/core/gen2/CatchTutorial.lua). @@ -5069,8 +5652,7 @@ function RomExtractorGen2:extractMenuGfx() -- index 1 is TRAINER_NONE (class 0), which the `dec a` skips. if index >= 2 then local base = symbol.address + (index - 2) * 3 - local bank = FIX_PIC_BANK[self.rom:byte(symbol.bank, base)] - or self.rom:byte(symbol.bank, base) + local bank = self:picBank(self.rom:byte(symbol.bank, base)) local address = self.rom:word(symbol.bank, base + 1) local rel = ("battle/trainers/%s.png"):format(class:lower()) local ok, err = pcall(function() @@ -5095,6 +5677,24 @@ function RomExtractorGen2:extractMenuGfx() end end end + -- HOF_LoadTrainerFrontpic sets wTrainerClass to CHRIS or KRIS and loads + -- ChrisPic / KrisPic (../pokecrystal/engine/gfx/player_gfx.asm:138,203,206). + for class, label in pairs({ CHRIS = "ChrisPic", KRIS = "KrisPic" }) do + if self.symbols[label] then + local sym = self:symbol(label) + local rel = ("battle/trainers/%s.png"):format(class:lower()) + local ok, err = pcall(function() + local pixels = ImageWriter.columnsToRows( + self.rom:bytes(sym.bank, sym.address, 7 * 7 * 16), 7, 7) + self:write2bpp(pixels, 56, 56, rel) + end) + if ok then + pics[class] = "assets/generated/" .. rel + else + self:trace(("trainer pic %s: %s"):format(class, tostring(err))) + end + end + end hud.trainerPics = pics end @@ -5181,10 +5781,23 @@ function RomExtractorGen2:extractMenuGfx() -- constants/item_data_constants.asm *_POCKET order. pack.pocketOrder = { "ITEM", "BALL", "KEY_ITEM", "TM_HM" } - local packPals = self:symbol("_CGB_PackPals.PackPals") - pack.palettes = {} - for i = 0, 5 do - pack.palettes[i + 1] = self:colors(packPals.bank, packPals.address + i * 8, 4) + -- Crystal splits the pack palette by player gender + -- (../pokecrystal/engine/gfx/cgb_layouts.asm:818,821); the FillBoxCGB zones + -- below are byte-identical in both trees (:792-811 vs pokegold :715-734). + local function packPalettes(symbol) + local out2 = {} + for i = 0, 5 do + out2[i + 1] = self:colors(symbol.bank, symbol.address + i * 8, 4) + end + return out2 + end + local packPals = self.symbols["_CGB_PackPals.PackPals"] + and self:symbol("_CGB_PackPals.PackPals") + or self:symbol("_CGB_PackPals.ChrisPackPals") + pack.palettes = packPalettes(packPals) + if self.symbols["_CGB_PackPals.KrisPackPals"] then + pack.palettesFemale = + packPalettes(self:symbol("_CGB_PackPals.KrisPackPals")) end -- _CGB_PackPals' FillBoxCGB calls, as {x, y, width, height, palette}. pack.paletteZones = { @@ -5235,6 +5848,21 @@ function RomExtractorGen2:extractMenuGfx() 8, 8, "emotes/grass_rustle.png", true) emotes.grassRustle = "assets/generated/emotes/grass_rustle.png" end + -- LoadFishingGFX (engine/events/fishing_gfx.asm:1-21): three 16x8 pose rows + -- over the standing frames' bottom tiles, then the rod tiles $fc/$fd (#1708). + local fishing = self.symbols["FishingGFX"] + if fishing then + self:write2bpp(self.rom:bytes(fishing[1], fishing[2], 8 * 16), + 16, 32, "emotes/fishing.png", true) + emotes.fishing = "assets/generated/emotes/fishing.png" + end + -- ../pokecrystal/engine/events/fishing_gfx.asm:41, Kris' half of that art. + local krisFishing = self.symbols["KrisFishingGFX"] + if krisFishing then + self:write2bpp(self.rom:bytes(krisFishing[1], krisFishing[2], 8 * 16), + 16, 32, "emotes/fishing_female.png", true) + emotes.fishingFemale = "assets/generated/emotes/fishing_female.png" + end out.emotes = emotes -- The Pokecenter heal machine's OBJ art (engine/events/heal_machine_anim @@ -5848,10 +6476,20 @@ function RomExtractorGen2:pokegearGfx() kanto = self:readFlatTilemap("KantoMap", cells), } - local pals = self:symbol("PokegearPals") - gear.palettes = {} - for i = 0, 5 do - gear.palettes[i + 1] = self:colors(pals.bank, pals.address + i * 8, 4) + -- Crystal picks the set by player gender + -- (../pokecrystal/engine/gfx/color.asm:1330,1333). + local function gearPalettes(symbol) + local out2 = {} + for i = 0, 5 do + out2[i + 1] = self:colors(symbol.bank, symbol.address + i * 8, 4) + end + return out2 + end + local pals = self.symbols["PokegearPals"] and self:symbol("PokegearPals") + or self:symbol("MalePokegearPals") + gear.palettes = gearPalettes(pals) + if self.symbols["FemalePokegearPals"] then + gear.palettesFemale = gearPalettes(self:symbol("FemalePokegearPals")) end -- TownMapPals.PalMap: 48 bytes covering tiles $00..$5f, 1-based so the value -- indexes gear.palettes directly. @@ -5878,13 +6516,45 @@ end -- of BadgeGFX with TrainerCard_JohtoBadgesOAM as their template table. function RomExtractorGen2:trainerCardGfx() local card = {} - local chris = self:symbol("ChrisPicAndTrainerCardGFX") - -- 41 tiles padded out to three 16-tile rows so the sheet is addressable as - -- (id % 16, id / 16) like every other one here. - local cardTiles = self.rom:bytes(chris.bank, chris.address, 41 * 16) - while #cardTiles < 48 * 16 do cardTiles[#cardTiles + 1] = 0 end - self:write2bpp(cardTiles, 128, 24, "trainer_card/card.png") - card.card = "assets/generated/trainer_card/card.png" + -- Crystal keeps the same $23-portrait-then-6-frame-tiles VRAM layout but + -- splits the source into three labels and a gender pair: GetCardPic copies + -- $23 tiles of Chris/KrisCardPic to vTiles2 $00 and 6 tiles of + -- TrainerCardGFX to $23 (../pokecrystal/engine/gfx/player_gfx.asm:96-112). + local function cardSheet(portraitLabel, frameLabel, relative) + local cardTiles + if frameLabel then + local portrait = self:symbol(portraitLabel) + local frame = self:symbol(frameLabel) + -- Crystal builds the 5x7 portrait with --columns + -- (../pokecrystal/Makefile:324-325); the 6 frame tiles have no such rule. + cardTiles = ImageWriter.columnsToRows( + self.rom:bytes(portrait.bank, portrait.address, 35 * 16), 5, 7) + for _, byte in ipairs(self.rom:bytes(frame.bank, frame.address, 6 * 16)) + do + cardTiles[#cardTiles + 1] = byte + end + else + local sym = self:symbol(portraitLabel) + cardTiles = self.rom:bytes(sym.bank, sym.address, 41 * 16) + end + -- 41 tiles padded out to three 16-tile rows so the sheet is addressable as + -- (id % 16, id / 16) like every other one here. + while #cardTiles < 48 * 16 do cardTiles[#cardTiles + 1] = 0 end + self:write2bpp(cardTiles, 128, 24, relative) + return "assets/generated/" .. relative + end + + if self.symbols["ChrisPicAndTrainerCardGFX"] then + card.card = cardSheet("ChrisPicAndTrainerCardGFX", nil, + "trainer_card/card.png") + else + card.card = cardSheet("ChrisCardPic", "TrainerCardGFX", + "trainer_card/card.png") + if self.symbols["KrisCardPic"] then + card.cardFemale = cardSheet("KrisCardPic", "TrainerCardGFX", + "trainer_card/card_f.png") + end + end card.cardTilesWide = 16 card.portraitTiles = 35 card.portraitWide = 5 @@ -6204,6 +6874,254 @@ function RomExtractorGen2:readBattleAnimGfx() return out end +-- Mobile System GB art: `tiles` is the ROM block's tile count and `wide` the +-- tile width its own PNG uses (../pokecrystal/Makefile:340-350). +local MOBILE_SHEETS = { + -- ../pokecrystal/mobile/mobile_5c.asm:290,293,296,753,866 + { key = "asciiFont", file = "ascii_font", + label = "AsciiFontGFX", tiles = 110, wide = 16 }, + { key = "pichuAnimated", file = "pichu_animated", + label = "PichuAnimatedMobileGFX", tiles = 193, wide = 16, lz = true }, + { key = "electroBall", file = "electro_ball", + label = "ElectroBallMobileGFX", tiles = 83, wide = 16, lz = true }, + { key = "pichuBorder", file = "pichu_border", + label = "PichuBorderMobileGFX", tiles = 24, wide = 4 }, + { key = "stadium2N64", file = "stadium2_n64", + label = "Stadium2N64GFX", tiles = 73, wide = 11 }, + -- ../pokecrystal/mobile/mobile_5e.asm:2,5,8,11,14,18,931,934,941 + { key = "card", file = "card", label = "MobileCardGFX", + tiles = 32, wide = 16 }, + { key = "chrisSilhouette", file = "chris_silhouette", + label = "ChrisSilhouetteGFX", tiles = 35, wide = 5 }, + { key = "krisSilhouette", file = "kris_silhouette", + label = "KrisSilhouetteGFX", tiles = 35, wide = 5 }, + { key = "card2", file = "card_2", label = "MobileCard2GFX", + tiles = 23, wide = 12 }, + { key = "cardLargeSprite", file = "card_large_sprite", + label = "CardLargeSpriteAndFolderGFX", tiles = 8, wide = 4 }, + { key = "cardFolder", file = "card_folder", + label = "CardLargeSpriteAndFolderGFX", tiles = 65, wide = 6, + offset = 8 * 16 }, + { key = "cardSprite", file = "card_sprite", + label = "CardSpriteGFX", tiles = 4, wide = 2 }, + { key = "dialpad", file = "dialpad", label = "DialpadGFX", + tiles = 76, wide = 16 }, + { key = "dialpadCursor", file = "dialpad_cursor", + label = "DialpadCursorGFX", tiles = 5, wide = 2 }, + { key = "cardList", file = "card_list", + label = "MobileCardListGFX", tiles = 24, wide = 12 }, + -- ../pokecrystal/mobile/mobile_5f.asm:84,87,3528 + { key = "haveWant", file = "havewant", label = "HaveWantGFX", + tiles = 144, wide = 16 }, + { key = "select", file = "select", label = "MobileSelectGFX", + tiles = 32, wide = 4 }, + { key = "pokemonNews", file = "pokemon_news", + label = "PokemonNewsGFX", tiles = 72, wide = 8 }, + -- ../pokecrystal/mobile/mobile_5b.asm:206,758 + { key = "splash", file = "mobile_splash", + label = "MobileSystemSplashScreen_InitGFX.Tiles", tiles = 76, wide = 14 }, + { key = "adapterCheck", file = "mobile_splash_check", + label = "MobileAdapterCheckGFX", tiles = 48, wide = 16 }, + -- ../pokecrystal/mobile/mobile_42.asm:1733,1736,1757,1760 and + -- ../pokecrystal/mobile/mobile_40.asm:6921 + { key = "trade", file = "mobile_trade", label = "MobileTradeGFX", + tiles = 128, wide = 8, lz = true }, + { key = "tradeSprites", file = "mobile_trade_sprites", + label = "MobileTradeSpritesGFX", tiles = 16, wide = 4, lz = true }, + { key = "cable1", file = "mobile_cable_1", + label = "MobileCable1GFX", tiles = 16, wide = 4 }, + { key = "cable2", file = "mobile_cable_2", + label = "MobileCable2GFX", tiles = 16, wide = 4 }, + { key = "tradeLights", file = "mobile_trade_lights", + label = "MobileTradeLightsGFX", tiles = 4, wide = 2 }, + -- ../pokecrystal/mobile/mobile_45_sprite_engine.asm:311 and + -- ../pokecrystal/mobile/mobile_41.asm:1115 + { key = "dialing", file = "dialing", label = "MobileDialingGFX", + tiles = 20, wide = 2 }, + { key = "dialingFrame", file = "dialing_frame", + label = "MobileDialingFrameGFX", tiles = 8, wide = 2 }, + -- ../pokecrystal/mobile/mobile_22.asm:518 and + -- ../pokecrystal/mobile/fixed_words.asm:3231 + { key = "ezChatCursor", file = "ez_chat_cursor", + label = "EZChatCursorGFX", tiles = 2, wide = 1 }, + { key = "selectStart", file = "select_start", + label = "SelectStartGFX", tiles = 6, wide = 3 }, + -- ../pokecrystal/mobile/mobile_12.asm:1007,1010, the only 1bpp pair + { key = "upArrow", file = "up_arrow", label = "MobileUpArrowGFX", + tiles = 1, wide = 1, bpp = 1 }, + { key = "downArrow", file = "down_arrow", label = "MobileDownArrowGFX", + tiles = 1, wide = 1, bpp = 1 }, + -- ../pokecrystal/engine/menus/main_menu.asm:24 and + -- ../pokecrystal/gfx/font.asm:58 + { key = "menu", file = "mobile_menu", label = "MobileMenuGFX", + tiles = 13, wide = 13 }, + { key = "phoneTiles", file = "phone_tiles", + label = "MobilePhoneTilesGFX", tiles = 17, wide = 2 }, + -- ../pokecrystal/engine/link/mystery_gift.asm:1606,1920,1923 + { key = "mysteryGift", file = "mystery_gift/mystery_gift", + label = "MysteryGiftGFX", tiles = 67, wide = 16 }, + { key = "cardTrade", file = "mystery_gift/card_trade", + label = "CardTradeGFX", tiles = 64, wide = 16 }, + { key = "cardTradeSprite", file = "mystery_gift/card_sprite", + label = "CardTradeSpriteGFX", tiles = 8, wide = 4 }, +} + +local MOBILE_MAPS = { + -- ../pokecrystal/mobile/mobile_5c.asm:756,759,762,765,768,771,874,878 + { key = "passwordTop", file = "password_top", + label = "PasswordTopTilemap", bytes = 140 }, + { key = "passwordBottom", file = "password_bottom", + label = "PasswordBottomTilemap", bytes = 220 }, + { key = "passwordShift", file = "password_shift", + label = "PasswordShiftTilemap", bytes = 140 }, + { key = "passwordAttrmap", file = "password_attrmap", + label = "MobilePasswordAttrmap", bytes = 360 }, + { key = "centerTilemap", file = "mobile_center_tilemap", + label = "ChooseMobileCenterTilemap", bytes = 360 }, + { key = "centerAttrmap", file = "mobile_center_attrmap", + label = "ChooseMobileCenterAttrmap", bytes = 360 }, + { key = "stadium2N64Tilemap", file = "stadium2_n64_tilemap", + label = "Stadium2N64Tilemap", bytes = 360 }, + { key = "stadium2N64Attrmap", file = "stadium2_n64_attrmap", + label = "Stadium2N64Attrmap", bytes = 360 }, + -- ../pokecrystal/mobile/mobile_5e.asm:925,928 + { key = "dialpadTilemap", file = "dialpad_tilemap", + label = "DialpadTilemap", bytes = 360 }, + { key = "dialpadAttrmap", file = "dialpad_attrmap", + label = "DialpadAttrmap", bytes = 360 }, + -- ../pokecrystal/mobile/mobile_5f.asm:91,3534 + { key = "haveWantMap", file = "havewant_map", + label = "HaveWantMap", bytes = 1136 }, + { key = "newsAttrmap", file = "pokemon_news_attrmap", + label = "PokemonNewsTileAttrmap", bytes = 1128 }, + -- ../pokecrystal/mobile/mobile_5b.asm:209,212 + { key = "splashTilemap", file = "mobile_splash_tilemap", + label = "MobileSystemSplashScreen_InitGFX.Tilemap", bytes = 360 }, + { key = "splashAttrmap", file = "mobile_splash_attrmap", + label = "MobileSystemSplashScreen_InitGFX.Attrmap", bytes = 360 }, + -- ../pokecrystal/mobile/mobile_45_2.asm:1368,1369, one label over both + { key = "pichuBorderTilemap", file = "pichu_border_tilemap", + label = "PichuBorderMobileTilemapAttrmap", bytes = 384 }, + { key = "pichuBorderAttrmap", file = "pichu_border_attrmap", + label = "PichuBorderMobileTilemapAttrmap", bytes = 384, offset = 384 }, + -- ../pokecrystal/mobile/mobile_42.asm:1739,1742 + { key = "tradeTilemap", file = "mobile_trade_tilemap", + label = "MobileTradeTilemapLZ", bytes = 1024, lz = true }, + { key = "tradeAttrmap", file = "mobile_trade_attrmap", + label = "MobileTradeAttrmapLZ", bytes = 1024, lz = true }, +} + +-- ../pokecrystal/mobile/mobile_5b.asm:215 and the other INCLUDEd .pal files; +-- `colors` is the RGB word count, not the palette count. +local MOBILE_PALETTES = { + { key = "splash", label = "MobileSplashScreenPalettes", colors = 32 }, + { key = "password", label = "MobilePasswordPalettes", colors = 32 }, + { key = "pokemonNews", label = "PokemonNewsPalettes", colors = 32 }, + { key = "pichuBorderOB", label = "PichuBorderMobileOBPalettes", colors = 32 }, + { key = "pichuBorderBG", label = "PichuBorderMobileBGPalettes", colors = 4 }, + { key = "tradeBG", label = "MobileTradeBGPalettes", colors = 32 }, + { key = "tradeOB1", label = "MobileTradeOB1Palettes", colors = 32 }, + { key = "tradeOB2", label = "MobileTradeOB2Palettes", colors = 32 }, + { key = "tradeLights", label = "MobileTradeLightsPalettes", colors = 16 }, + { key = "adapters", label = "MobileAdapterPalettes", colors = 8 }, + { key = "unusedPulses", label = "UnusedMobilePulsePalettes", colors = 8 }, +} + +-- The mobile banks at ../pokecrystal/main.asm:187-575, which no Gold tree has. +function RomExtractorGen2:extractMobileGfx() + if self.edition ~= "crystal" then return nil end + local CacheFs = require("src.import.CacheFs") + self:trace("Mobile System GB graphics") + + local sheets, maps, palettes = {}, {}, {} + local total = #MOBILE_SHEETS + #MOBILE_MAPS + local step = 0 + + for _, row in ipairs(MOBILE_SHEETS) do + step = step + 1 + self:tick("Mobile graphics", step, total) + if self.symbols[row.label] then + local perTile = (row.bpp == 1) and 8 or 16 + local rel = ("mobile/%s.png"):format(row.file) + local ok, err = pcall(function() + local sym = self:symbol(row.label) + local offset = row.offset or 0 + local bytes + if row.lz then + bytes = self:decompressLz3Symbol(row.label) + else + bytes = self.rom:bytes(sym.bank, sym.address + offset, + row.tiles * perTile) + offset = 0 + end + local rows = math.ceil(row.tiles / row.wide) + local need = rows * row.wide * perTile + local pixels = {} + for index = 1, need do pixels[index] = bytes[offset + index] or 0 end + local width, height = row.wide * 8, rows * 8 + if row.bpp == 1 then + self:save(ImageWriter.decode1bpp(pixels, width, height), rel) + else + self:write2bpp(pixels, width, height, rel) + end + sheets[row.key] = { + path = "assets/generated/" .. rel, + tiles = row.tiles, tilesWide = row.wide, + } + end) + if not ok then + self:trace(("mobile sheet %s: %s"):format(row.key, tostring(err))) + end + end + end + + for _, row in ipairs(MOBILE_MAPS) do + step = step + 1 + self:tick("Mobile graphics", step, total) + if self.symbols[row.label] then + local rel = ("assets/generated/mobile/%s.bin"):format(row.file) + local ok, err = pcall(function() + local sym = self:symbol(row.label) + local blob + if row.lz then + local bytes = self:decompressLz3Symbol(row.label) + local chars = {} + for index = 1, row.bytes do + chars[index] = string.char(bytes[index] or 0) + end + blob = table.concat(chars) + else + local first = Rom.offset(sym.bank, sym.address) + 1 + (row.offset or 0) + blob = self.rom.data:sub(first, first + row.bytes - 1) + end + local written, writeError = CacheFs.write(rel, blob) + if not written then error(tostring(writeError)) end + maps[row.key] = { path = rel, bytes = row.bytes } + end) + if not ok then + self:trace(("mobile map %s: %s"):format(row.key, tostring(err))) + end + end + end + + for _, row in ipairs(MOBILE_PALETTES) do + if self.symbols[row.label] then + local sym = self:symbol(row.label) + palettes[row.key] = self:colors(sym.bank, sym.address, row.colors) + end + end + + local data = { + generation = 2, + source = "ROM:mobile/*.asm + engine/link/mystery_gift.asm", + sheets = sheets, + maps = maps, + palettes = palettes, + } + self:write("mobile_gfx", data) + return data +end + function RomExtractorGen2:extractBattleAnims() self:beginStage("Battle animations") local moveOrder = (self.manifest.constants or {}).moveOrder or {} @@ -6340,6 +7258,7 @@ function RomExtractorGen2:run() results.icons = self:extractIcons() results.intro = self:extractIntro() results.menuGfx = self:extractMenuGfx() + results.mobileGfx = self:extractMobileGfx() results.oakSpeech = self:extractOakSpeech(results.pokemon) results.title = self:extractTitle() results.credits = self:extractCredits() diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 152fe88e..d0671ad7 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -28,6 +28,13 @@ local function pickFile(...) return fn(...) and true or false end +local CART_SCOPE = "cart_" + +local function cartOfScope(scope) + if type(scope) ~= "string" then return nil end + return scope:match("^cart_(.+)$") +end + local CacheContract = require("src.import.CacheContract") local COMMUNITY_URL = "https://bois.icu" local TRUST_WARNING = "if you did not get this from bryanthaboi's github " .. @@ -427,6 +434,27 @@ local function isAcceptedRomSize(n) return n == ROM_BYTES_GEN1 or n == ROM_BYTES_GEN2 end +local function cartLabels(generation) + local out = {} + for _, id in ipairs(GameVersion.ORDER) do + if generation == nil or GameVersion.generation(id) == generation then + out[#out + 1] = GameVersion.info(id).label + end + end + return out +end + +local function cartsSlashed(generation) + return table.concat(cartLabels(generation), "/") +end + +local function cartsProse() + local names = cartLabels(nil) + local last = table.remove(names) + if #names == 0 then return last end + return table.concat(names, ", ") .. ", or " .. last +end + local function savesInboxDir(version) return SAVES_INBOX_DIR .. "/" .. tostring(version) end @@ -456,7 +484,7 @@ function RomImporter:ensureImportsDir() return false end --- NX mod zip inbox (separate from ROM imports/). Parent imports/ first — +-- NX mod zip inbox (separate from ROM imports/). Parent imports/ first -- -- love.filesystem.createDirectory does not create nested parents. function RomImporter:ensureModsInboxDir() self:ensureImportsDir() @@ -470,7 +498,7 @@ function RomImporter:ensureModsInboxDir() end -- NX raw .sav inbox per game: imports/saves/{red,blue,yellow}/. --- Parent imports/ then imports/saves/ first — createDirectory is not nested. +-- Parent imports/ then imports/saves/ first -- createDirectory is not nested. -- Creates all three version folders so MTP browsing shows where each game goes. function RomImporter:ensureSavesInboxDir(version) self:ensureImportsDir() @@ -544,7 +572,7 @@ local function listRomPaths(dir) local paths = {} for _, name in ipairs(love.filesystem.getDirectoryItems(dir) or {}) do -- Skip AppleDouble / hidden junk from Mac MTP (._cart.gb ends in .gb - -- but is not a ROM — rescan would try it first and block the real dump). + -- but is not a ROM -- rescan would try it first and block the real dump). if name:sub(1, 1) ~= "." then local path = (dir == "" or dir == "/") and name or (dir .. "/" .. name) if name:lower():match("%.gbc?$") @@ -619,7 +647,7 @@ local function listZipPaths(dir) local paths = {} for _, name in ipairs(love.filesystem.getDirectoryItems(dir) or {}) do -- Skip AppleDouble / hidden junk from Mac MTP (._foo.zip ends in .zip - -- but is not a PhysFS archive — mount fails with "could not be opened"). + -- but is not a PhysFS archive -- mount fails with "could not be opened"). if name:sub(1, 1) ~= "." then local path = (dir == "" or dir == "/") and name or (dir .. "/" .. name) if name:lower():match("%.zip$") @@ -635,7 +663,7 @@ local function listSavPaths(dir) local paths = {} for _, name in ipairs(love.filesystem.getDirectoryItems(dir) or {}) do -- Skip AppleDouble / hidden junk from Mac MTP (._foo.sav ends in .sav - -- but is not a real battery save — import would fail and invent noise). + -- but is not a real battery save -- import would fail and invent noise). if name:sub(1, 1) ~= "." then local path = (dir == "" or dir == "/") and name or (dir .. "/" .. name) if name:lower():match("%.sav$") @@ -811,7 +839,7 @@ function RomImporter:rescanSavesAction(version) elseif skipCount > 0 then self.saveNotice[version] = { ok = true, - text = Strings("Already imported — %d file(s) skipped. Check SAVE SLOT.", + text = Strings("Already imported: %d file(s) skipped. Check SAVE SLOT.", skipCount), } end @@ -822,7 +850,7 @@ end -- picking the first pending file would jump Yellow → Red (and switch the -- launcher tab via startData). Other known dumps stay for their own tabs. -- Junk (wrong size / unknown hash) still surfaces when nothing matches the --- tab and no other known dump is present — same feedback as before for a +-- tab and no other known dump is present -- same feedback as before for a -- lone bad file. function RomImporter:rescanAction(version) if self.workState == "working" then return end @@ -877,7 +905,7 @@ function RomImporter:rescanAction(version) status = Strings("No matching ROM found."), detail = Strings( "%s is matched by SHA-1 on this tab. Other dumps in imports/ stay " - .. "for their own tabs — open that game and Scan again.", label), + .. "for their own tabs; open that game and Scan again.", label), } return end @@ -1246,12 +1274,13 @@ end -- column with no Play button would read as the launcher losing the import. -- Called from new() once self.ready is filled, which is what that check needs. function RomImporter:_applyLastVersionTab() - local okLO, LO = pcall(require, "src.core.LaunchOptions") - if okLO and LO.pendingTab then return end - if os.getenv("POKEPORT_LAUNCHER_TAB") then return end local okOpt, opts = pcall(function() return require("src.core.SaveData").loadOptions() end) + self:_restoreActiveCarts(okOpt and opts or nil) + local okLO, LO = pcall(require, "src.core.LaunchOptions") + if okLO and LO.pendingTab then return end + if os.getenv("POKEPORT_LAUNCHER_TAB") then return end local last = okOpt and opts and opts.lastVersion if last and GameVersion.VERSIONS[last] and self.ready[last] then self.tab = last @@ -1261,8 +1290,9 @@ end -- The launcher runs each GameVersion as an independent tab. Each dropped or -- chosen ROM is routed to its version by SHA-1, extracted into that version's -- own cache (Red at the root, Blue under blue/, Yellow under yellow/), so all --- can be imported and played side by side. onComplete(version) hands the --- chosen game off to boot. +-- can be imported and played side by side. onComplete(version, cartId) hands +-- the chosen game -- and the custom cart its page is showing, if any -- off to +-- boot. -- opts: launcher (a fresh import stays on the launcher instead of auto-booting), -- forceImport (treat every version as not-yet-imported, so re-import is forced), -- onEditSave(version, slotId) (host handler for the Edit affordance on a save @@ -1336,6 +1366,7 @@ function RomImporter.new(onComplete, opts) -- any slot mutation); activeSlot drives the LOADED pill; slotScroll is the -- per-version list scroll offset (px), clamped against content in draw. slots = {}, activeSlot = {}, slotScroll = {}, + carts = {}, activeCart = {}, -- SAVE FILES card state: the last import/export result per version, shown as -- a green/red notice line under the Import save / Export save buttons. A -- successful export carries { dir } so the notice can offer an open-folder @@ -1361,6 +1392,9 @@ function RomImporter.new(onComplete, opts) findLoaded = false, findSources = nil, findIndex = nil, findScroll = 0, findNotice = nil, findQuery = "", findCategory = nil, _findSearchFocus = false, _findThumbs = nil, + -- Which half of the feed the panel is browsing. Mods by default: carts + -- are the newer, much shorter list, and a feed may carry none at all. + findKind = "mods", findBase = nil, skinUrl = "", _skinUrlFocus = false, -- Page scroll offset (px) for the column under the tab bar -- panel, updater -- banner and footer -- used only while that column is taller than the window @@ -1522,6 +1556,11 @@ function RomImporter:focus(f) if love.filesystem.getInfo("export_done.flag", "file") then love.filesystem.remove("export_done.flag") love.filesystem.remove("pending_export.sav") + if self.androidPendingCartExport then + self.androidPendingCartExport = nil + self._cartNotice = Strings("Cart exported.") + return + end local version = self.androidPendingExportVersion or self:_savedropTarget() self.androidPendingExportVersion = nil self.saveNotice[version] = { ok = true, text = "Save exported." } @@ -1668,18 +1707,18 @@ function RomImporter:startData(data, displayName) return end if not isAcceptedRomSize(#data) then - self:setError(("Expected a 1 MiB Game Boy ROM (Red/Blue/Yellow) or a " - .. "2 MiB Game Boy Color ROM (Gold/Silver); this file is %.2f MiB.") - :format(#data / 1024 / 1024)) + self:setError(("Expected a 1 MiB Game Boy ROM (%s) or a " + .. "2 MiB Game Boy Color ROM (%s); this file is %.2f MiB.") + :format(cartsSlashed(1), cartsSlashed(2), #data / 1024 / 1024)) return end local actualHash = sha1(data) local version = GameVersion.forSha1(actualHash) if not version then self:setError(("Unsupported ROM (SHA-1 %s). This needs a clean US Pokemon " - .. "Red, Blue, Yellow, Gold, or Silver dump; patched, trimmed or " + .. "%s dump; patched, trimmed or " .. "\"fixed\" dumps " - .. "(tagged [b] or [BF]) never verify."):format(actualHash)) + .. "(tagged [b] or [BF]) never verify."):format(actualHash, cartsProse())) return end local info = GameVersion.info(version) @@ -1959,7 +1998,7 @@ end -- Android mirrors ROM import: scan for a pending .zip in the save dir (USB -- or a fresh SAF drop), else love.system.pickFile("mod") -> picked_mod.zip -- which focus/Choose consumes on return. --- NX: no HostShell/desktop picker — rescan imports/mods/ inbox instead. +-- NX: no HostShell/desktop picker -- rescan imports/mods/ inbox instead. function RomImporter:chooseMod() if self.workState == "working" then return end if self.isNX then @@ -2253,7 +2292,7 @@ end -- "Import save" button: open a native .sav picker and import the pick. -- Android mirrors ROM / mod import via love.system.pickFile("sav"). --- NX: no HostShell/desktop picker — rescan imports/saves/ inbox instead. +-- NX: no HostShell/desktop picker -- rescan imports/saves/ inbox instead. function RomImporter:chooseSaveImport(version) if self.workState == "working" then return end version = self:_resolveSaveVersion(version) @@ -2356,15 +2395,21 @@ end -- Delete a save slot from the registry and disk, then refresh the panel. If the -- deleted slot was active, SaveData.deleteSlot points active at another slot. -function RomImporter:_deleteSlot(version, id) +function RomImporter:_deleteSlot(scope, id) if self.workState == "working" then return end local SaveData = require("src.core.SaveData") - local ok, err = SaveData.deleteSlot(version, id) - if ok then - self:_refreshSlots(version) - self.saveNotice[version] = { ok = true, text = "Deleted " .. tostring(id) .. "." } + local cart = cartOfScope(scope) + local ok, err + if cart then + ok, err = SaveData.deleteCartSlot(cart, id) else - self.saveNotice[version] = { ok = false, text = tostring(err) } + ok, err = SaveData.deleteSlot(scope, id) + end + if ok then + self:_refreshSlots(scope) + self.saveNotice[scope] = { ok = true, text = "Deleted " .. tostring(id) .. "." } + else + self.saveNotice[scope] = { ok = false, text = tostring(err) } end end @@ -2550,6 +2595,8 @@ function RomImporter:update(dt) self:_pumpSync(dt) self:_pumpModCheck() self:_pumpModInstall() + self:_pumpCartInstall() + self:_pumpCartFill() self:_pumpExtract() -- Dev harness: POKEPORT_LAUNCHER_SHOT=/path.png resizes the window from -- POKEPORT_WIN=WxH, lets the view settle, then captures one frame and @@ -2584,6 +2631,10 @@ function RomImporter:update(dt) if os.getenv("POKEPORT_LAUNCHER_SETTINGS") == "1" then self:_openSettings() end + -- POKEPORT_LAUNCHER_FIND_KIND=mods|carts picks which half of the feed + -- the FIND tab is browsing; the switch is otherwise only a click. + local findKind = os.getenv("POKEPORT_LAUNCHER_FIND_KIND") + if findKind and findKind ~= "" then self:_setFindKind(findKind) end local query = os.getenv("POKEPORT_LAUNCHER_QUERY") if query and query ~= "" then self.findQuery = query @@ -2784,8 +2835,10 @@ function RomImporter:resumeAfterOverlay() end function RomImporter:_cycleTab(delta) - local order = { "red", "blue", "yellow", "gold", "silver", - "mods", "find", "skins", "bug" } + local order = { "mods", "find", "skins", "bug" } + for i = #GameVersion.ORDER, 1, -1 do + table.insert(order, 1, GameVersion.ORDER[i]) + end local idx = 1 for i, id in ipairs(order) do if id == self.tab then idx = i; break end @@ -2852,7 +2905,7 @@ function RomImporter:_updatePadCursor(dt) require("src.import.LauncherView").wheelmoved(self, 0, -overY / 48) end -- Desktop: FlexLove polls the real mouse, so warp it with the pad pointer. - -- NX: the getPosition bridge already returns pad coords — skip setPosition. + -- NX: the getPosition bridge already returns pad coords -- skip setPosition. if not self.isNX and love.mouse.setPosition then pcall(love.mouse.setPosition, self._padCursor.x, self._padCursor.y) self._lastMouseX, self._lastMouseY = self._padCursor.x, self._padCursor.y @@ -2974,17 +3027,30 @@ function RomImporter:play(version, fade) -- of its own, so portable installs and POKEPORT_IDENTITY sandboxes keep it -- with the rest of the launcher's persisted state. A failed write only -- costs the memory of the choice, so it must never block the boot. + local cartId = self.activeCart and self.activeCart[version] or nil pcall(function() local SaveData = require("src.core.SaveData") local opts = SaveData.loadOptions() opts.lastVersion = version + local map = self:_activeCartMap() + opts.activeCart = next(map) ~= nil and map or nil SaveData.saveOptions(opts) end) resetPointerCursor(self) -- The game draws with raw love.graphics from here on; drop the view's -- element tree and canvases before the handoff. if self._flex then require("src.import.LauncherView").detach(self) end - if self.onComplete then self.onComplete(version) end + if self.onComplete then self.onComplete(version, cartId) end +end + +function RomImporter:_activeCartMap() + local out = {} + for version, id in pairs(self.activeCart or {}) do + if GameVersion.VERSIONS[version] and type(id) == "string" then + out[version] = id + end + end + return out end -- "re-import" a column: drop it back to the choose/drop state so a fresh ROM @@ -3227,7 +3293,11 @@ function RomImporter:_disableSkins() local SaveData = require("src.core.SaveData") local opts = SaveData.loadOptions() local tc = type(opts.touchControls) == "table" and opts.touchControls or {} - tc.enabled, tc.skin = false, nil + -- `enabled` is the pad's own switch, not a skins switch: turning a skin off + -- has to leave the built-in pad on, which is what the notice promises. A + -- skin can only be active while the pad is enabled, so this never overrides + -- a player who turned the pad off deliberately. + tc.enabled, tc.skin = true, nil opts.touchControls = tc SaveData.saveOptions(opts) self._skinNotice = { ok = true, text = "Skins are off. Mobile will use the built-in pad when needed." } @@ -3790,6 +3860,50 @@ end -- The view's open-folder affordance needs the same file:// encoding the old -- notice line used. +-- Desktop picks a .cart file; everywhere else CartStore's stray scan already +-- adopts anything dropped in the folder, so we just point at it. +function RomImporter:importCartFile(version) + local CartStore = require("src.carts.CartStore") + local FilePicker = require("src.core.FilePicker") + if not FilePicker.available() then + local dir = self:cartsDir() + self._cartNotice = dir + and Strings("Drop .cart files in %s, then reopen this list.", dir) + or Strings("No filesystem available to import from.") + return false + end + local path = FilePicker.open("Choose a cart", + { label = "Cart", exts = { CartStore.EXT:gsub("^%.", "") } }) + if not path then return false end + local bytes = FilePicker.read(path) + if not bytes then + self._cartNotice = Strings("Could not read %s", FilePicker.basename(path)) + return false + end + local cart, err = CartStore.install(bytes) + if not cart then + self._cartNotice = Strings("That cart could not be imported: %s", + tostring(err)) + return false + end + self:_refreshCarts(version) + self._cartNotice = Strings("Imported %s. It is in this list now.", + tostring(cart.title or cart.id)) + return true +end + +-- The real OS path of the cart folder, so the launcher can open it and so a +-- player can drop a .cart in by hand. +function RomImporter:cartsDir() + local CartStore = require("src.carts.CartStore") + local fs = love and love.filesystem + if not fs then return nil end + if fs.createDirectory then pcall(fs.createDirectory, CartStore.DIR) end + local base = fs.getSaveDirectory and fs.getSaveDirectory() + if not base or base == "" then return nil end + return base .. "/" .. CartStore.DIR +end + function RomImporter:fileUrl(path) return fileUrl(path) end @@ -3832,6 +3946,17 @@ function RomImporter:keypressed(key) end return end + if self._cartSave then + if key == "backspace" then + self._cartSave.text = utf8Back(self._cartSave.text) + self._cartSave.error = nil + elseif key == "return" or key == "kpenter" then + self:_commitCartSave() + elseif key == "escape" then + self:_cancelCartSave() + end + return + end if self._settingsText then if key == "backspace" then self._settingsText.text = utf8Back(self._settingsText.text) @@ -3917,6 +4042,13 @@ function RomImporter:keypressed(key) end return end + if self._cartPopup then + if self._flex and require("src.import.LauncherView").keypressed(self, key) then + return + end + if key == "escape" then self._cartPopup = nil end + return + end if self._skinUrlFocus then if key == "backspace" then self.skinUrl = utf8Back(self.skinUrl or "") @@ -3958,21 +4090,532 @@ function RomImporter:keypressed(key) end end end --- Reload a version's slot list + active id from SaveData (the source of truth). --- Cheap enough to call on any mutation; the per-frame draw only calls it lazily --- through _ensureSlots so a still list costs nothing after the first paint. -function RomImporter:_refreshSlots(version) - local SaveData = require("src.core.SaveData") - self.slots[version] = SaveData.listSlots(version) or {} - local opts = SaveData.loadOptions() - local reg = opts.saveSlots and opts.saveSlots[version] - -- fall back to the first slot as the shown "loaded" one when the registry - -- has a list but no explicit active id (matches saveNames' own resolution) - self.activeSlot[version] = reg and (reg.active or reg.list[1]) or nil + +-- list, not index: index reads only the registry, so a .g1rcart dropped into +-- the carts folder by hand would never appear. list adopts strays and heals +-- the registry, and this is cached per version so it parses once. +function RomImporter:_refreshCarts(version) + local out = {} + local ok, rows = pcall(function() + return require("src.carts.CartStore").listFor(version) + end) + if ok and type(rows) == "table" then + for _, row in ipairs(rows) do out[#out + 1] = row end + end + self.carts[version] = out + -- The FIND panel's installed-cart map spans every game, so it goes stale + -- on any cart change, not just this version's. + self._findCartMap = nil + return out end -function RomImporter:_ensureSlots(version) - if not self.slots[version] then self:_refreshSlots(version) end +function RomImporter:_ensureCarts(version) + return self.carts[version] or self:_refreshCarts(version) +end + +function RomImporter:_cartById(version, id) + if type(id) ~= "string" then return nil end + for _, row in ipairs(self:_ensureCarts(version)) do + if row.id == id then return row end + end + return nil +end + +function RomImporter:activeCartRow(version) + local id = self.activeCart[version] + if not id then return nil end + return self:_cartById(version, id) +end + +function RomImporter:slotScope(version) + local id = self.activeCart[version] + if id then return CART_SCOPE .. id end + return version +end + +function RomImporter:_selectCart(version, id) + self._cartPopup = nil + self._cartNotice = nil + self.cartFillNotice = nil + if id ~= nil and not self:_cartById(version, id) then return end + self.activeCart[version] = id + self._cartPlan = nil + -- the MODS panel answers for the cart now, so its cached rows are stale + self.mods, self._modSortCache = nil, nil + local scope = self:slotScope(version) + self.slots[scope] = nil + self.slotScroll[scope] = nil + if self._pages then self._pages["slots-" .. scope] = 1 end +end + +function RomImporter:_cartSealSlot(version) + local id = self.activeCart and self.activeCart[version] or nil + if not id then return nil, nil end + local scope = self:slotScope(version) + self:_ensureSlots(scope) + local active = self.activeSlot[scope] + for _, slot in ipairs(self.slots[scope] or {}) do + if slot.id == active then return slot, scope end + end + return nil, scope +end + +function RomImporter:cartPlan(version) + local id = self.activeCart and self.activeCart[version] or nil + if not id then return nil, nil end + local slot = self:_cartSealSlot(version) + local broken = (slot and slot.sealBroken == true) or false + local key = table.concat( + { id, tostring(slot and slot.id), tostring(broken) }, "|") + local cached = self._cartPlan + if cached and cached.key == key then return cached.report, slot end + local installed = {} + pcall(function() + local rows = require("src.mods.LauncherMods").list(version) or {} + for _, row in ipairs(rows) do + local manifest = type(row.manifest) == "table" and row.manifest or row + if type(manifest.id) == "string" then + installed[manifest.id] = + { id = manifest.id, version = manifest.version } + end + end + end) + local ok, cart = pcall(function() + return require("src.carts.CartStore").get(id) + end) + if not ok or type(cart) ~= "table" then cart = nil end + local report = require("src.mods.Loader").planCart(cart, installed, broken) + report.id = id + self._cartPlan = { key = key, report = report } + return report, slot +end + +function RomImporter:pressBreakSeal(version) + local slot, scope = self:_cartSealSlot(version) + if not scope then return false end + return self:pressDelete("seal", slot and slot.id or nil, scope, function() + self:breakCartSeal(version) + end) +end + +function RomImporter:breakCartSeal(version) + local id = self.activeCart and self.activeCart[version] or nil + if not id then return false end + local SaveData = require("src.core.SaveData") + local scope = self:slotScope(version) + self:_ensureSlots(scope) + local slotId = self.activeSlot[scope] + if type(slotId) ~= "string" then + slotId = SaveData.createCartSlot(id) + if type(slotId) ~= "string" then return false end + SaveData.setActiveCartSlot(id, slotId) + end + local ok = SaveData.markSlotSealBroken(id, slotId) + self:_refreshSlots(scope) + self.activeSlot[scope] = slotId + self._cartPlan = nil + return ok and true or false +end + +-- ------- installing the mods a cart pins +-- +-- The other way out of a refusal: fetch every pin at the version the cart +-- names, verified against the sha256 it recorded, instead of breaking the seal. + +local function pinSameVersion(want, have) + local Semver = require("src.mods.Semver") + local order = Semver.compare(want, have) + if order ~= nil then return order == 0 end + return want == have +end + +-- Why a pin cannot be fetched. Only a github pin carries a repo, a version +-- and an archive hash, which is all three things an install needs. +local function pinUnresolvable(pin) + local source = type(pin) == "table" and pin.source or nil + if source == "gamebanana" then + return "is pinned to a GameBanana file, which the launcher cannot download" + end + if source == "local" then + return "is pinned to a copy on the machine that built the cart, so there is nothing to download" + end + if source ~= "github" then + return "carries no source the launcher can install from" + end + if type(pin.repo) ~= "string" or pin.repo == "" then + return "names no GitHub repository" + end + if type(pin.version) ~= "string" or pin.version == "" then + return "names no version to install" + end + if type(pin.sha256) ~= "string" or #pin.sha256 ~= 64 then + return "records no sha256, so its archive could not be verified" + end + return nil +end + +-- The pins the player cannot satisfy: absent, or installed at some other +-- version. A mismatch needs the PINNED version, so it queues like a gap. +function RomImporter:cartFillRows(version) + local report = self:cartPlan(version) + if type(report) ~= "table" then return {} end + local rows = {} + for _, row in ipairs(report.missing or {}) do + rows[#rows + 1] = { id = row.id, version = row.version, + pin = (report.pins or {})[row.id] } + end + for _, row in ipairs(report.mismatched or {}) do + rows[#rows + 1] = { id = row.id, version = row.version, + installed = row.installed, pin = (report.pins or {})[row.id] } + end + return rows +end + +-- A configured index already knows where a mod's archive lives, so it saves a +-- round trip -- but only when it points at exactly the pinned version. +function RomImporter:_cartPinIndexRelease(row) + local mods = self.findIndex and self.findIndex.mods or nil + if type(mods) ~= "table" then return nil end + local ModIndex = require("src.mods.ModIndex") + for _, entry in ipairs(mods) do + if type(entry) == "table" and entry.id == row.id + and pinSameVersion(row.version, ModIndex.displayVersion(entry)) then + local release = ModIndex.releaseFor(entry) + if type(release) == "table" and release.zip and release.zip.url then + return release + end + end + end + return nil +end + +function RomImporter:pressInstallCartMods(version) + if self._cartFill or self._modInstall or self._cartInstall then return false end + local rows = self:cartFillRows(version) + if #rows == 0 then return false end + self.cartFillNotice = nil + self._cartFill = { version = version, rows = rows, index = 0, + installed = 0, failures = {}, stage = "next" } + self:_pumpCartFill() + return true +end + +function RomImporter:_cartFillFailed(msg) + local job = self._cartFill + if not job then return end + local row = job.row + job.failures[#job.failures + 1] = + ("%s %s"):format(tostring(row and row.id or "?"), tostring(msg)) + job.stage = "next" +end + +function RomImporter:_finishCartFill() + local job = self._cartFill + self._cartFill = nil + self:_clearBusy() + -- Re-plan against what is on disk now, so the card answers for itself. + self._cartPlan = nil + pcall(self._refreshMods, self) + if not job then return end + if #job.failures == 0 then + self.cartFillNotice = { ok = true, + text = Strings("Installed %d of the mods this cart pins.", job.installed) } + return + end + self.cartFillNotice = { ok = false, failures = job.failures, + text = Strings("Installed %d of %d. %d could not be installed:", + job.installed, #job.rows, #job.failures) } +end + +function RomImporter:_pumpCartFill() + local job = self._cartFill + if not job then return end + local ModUpdate = require("src.mods.ModUpdate") + + if job.stage == "next" then + job.index = job.index + 1 + job.row = job.rows[job.index] + if not job.row then return self:_finishCartFill() end + local pin = job.row.pin + local why = pinUnresolvable(pin) + if why then return self:_cartFillFailed(why) end + local release = self:_cartPinIndexRelease(job.row) + if release then + job.release, job.stage = release, "install" + return + end + job.fetch = ModUpdate.beginFetchReleases(pin.repo, job.row.id, + { force = true }) + job.stage = "fetch" + self:_setBusy(Strings("Finding %s v%s", tostring(job.row.id), + tostring(job.row.version))) + return + end + + if job.stage == "fetch" then + local done, releases, err = ModUpdate.pumpFetchReleases(job.fetch) + if not done then return end + job.fetch = nil + if type(releases) ~= "table" then + return self:_cartFillFailed("could not be looked up: " + .. tostring(err or "release check failed")) + end + local pick + for _, rel in ipairs(releases) do + if type(rel) == "table" and pinSameVersion(job.row.version, rel.version) then + pick = rel + break + end + end + if not pick then + return self:_cartFillFailed(("has no release tagged v%s") + :format(tostring(job.row.version))) + end + if not (pick.zip and pick.zip.url) then + return self:_cartFillFailed(("v%s has no .zip asset") + :format(tostring(job.row.version))) + end + job.release, job.stage = pick, "install" + return + end + + if job.stage == "install" then + if self._modInstall then return end + local row = job.row + job.stage = "installing" + self:_beginModInstall({ + modId = row.id, name = row.id, release = job.release, + verb = "Installed", notice = "cart", sha256 = row.pin.sha256, + done = function(ok, text) + if ok then + job.installed = job.installed + 1 + job.stage = "next" + else + self:_cartFillFailed(text) + end + end, + }) + end +end + +function RomImporter:_restoreActiveCarts(opts) + local saved = opts and opts.activeCart + if type(saved) ~= "table" then return end + for version, id in pairs(saved) do + if GameVersion.VERSIONS[version] and type(id) == "string" + and self:_cartById(version, id) then + self.activeCart[version] = id + end + end +end + +local CART_RAIL = { red = "railRed", blue = "railBlue", yellow = "railGold", + gold = "railAmber", silver = "railSilver" } +local CART_START_VERSION = "1.0.0" + +local function cartShellHex(version) + local Theme = require("src.ui.kit.Theme") + local col = Theme.PAL[CART_RAIL[version] or ""] or Theme.PAL.green + return ("#%02x%02x%02x"):format(col[1] or 0, col[2] or 0, col[3] or 0) +end + +local function cartIdFromTitle(title) + local CartManifest = require("src.carts.CartManifest") + local id = tostring(title or ""):lower():gsub("[^%w]+", "_") + id = id:sub(1, CartManifest.MAX_ID):gsub("^_+", ""):gsub("_+$", "") + if id == "" then return nil end + return id +end + +local function cartModRows(imp, version) + local LauncherMods = require("src.mods.LauncherMods") + local rows, kept = LauncherMods.list(version) or {}, {} + for _, row in ipairs(rows) do + if row.targetsHere ~= false then kept[#kept + 1] = row end + end + return kept +end + +-- Every mod that targets this game, switched on or off: CartStore.capture +-- pins the off ones too, so they are part of the cart it would write. +function RomImporter:_cartCaptureCount(version) + if not GameVersion.VERSIONS[version] then return 0 end + return #cartModRows(self, version) +end + +function RomImporter:_cartAuthor() + local SaveData = require("src.core.SaveData") + local ok, opts = pcall(SaveData.loadOptions) + local sync = ok and type(opts) == "table" and opts.saveSync or nil + local label = type(sync) == "table" and sync.deviceLabel or nil + if type(label) == "string" and label:match("%S") then return label end + return Strings("Unknown") +end + +function RomImporter:_cartSaveId() + local st = self._cartSave + if not st then return nil end + return cartIdFromTitle(st.text) +end + +local function cartIdentity(st, id, title) + return { id = id, title = title, version = st.cartVersion, + author = st.author, base = st.version, + shell = st.shell, seal = "sealed" } +end + +function RomImporter:_beginCartSave(version) + if not GameVersion.VERSIONS[version] then return end + local LauncherMods = require("src.mods.LauncherMods") + local CartStore = require("src.carts.CartStore") + local st = { + version = version, text = "", author = self:_cartAuthor(), + cartVersion = CART_START_VERSION, shell = cartShellHex(version), + mods = cartModRows(self, version), + modOptions = LauncherMods.modOptions(), + unresolved = {}, publishable = false, + } + st.count = #st.mods + local cart, unresolved = CartStore.capture( + cartIdentity(st, "preview", "Preview"), st.mods, st.modOptions) + if cart then + st.unresolved = unresolved or {} + local CartManifest = require("src.carts.CartManifest") + st.publishable = CartManifest.publishable(cart) and true or false + else + st.error = tostring(unresolved) + end + self._cartSave = st + self:_armTextInput() +end + +function RomImporter:_cancelCartSave() + self._cartSave = nil + self:_disarmTextInput() +end + +function RomImporter:_commitCartSave() + local st = self._cartSave + if not st then return end + local CartManifest = require("src.carts.CartManifest") + local CartStore = require("src.carts.CartStore") + local title = tostring(st.text or ""):match("^%s*(.-)%s*$") + if title == "" then + st.error = Strings("Type a title for this cart.") + return + end + local id = cartIdFromTitle(title) + if not id then + st.error = Strings("That title has no letters or numbers to build an id from.") + return + end + local existing = CartStore.get(id) + if existing then + st.error = Strings("%s already uses the id %s. Pick a different title.", + tostring(existing.title), id) + return + end + local cart, unresolved = CartStore.capture( + cartIdentity(st, id, title), st.mods, st.modOptions) + if not cart then + st.error = tostring(unresolved) + return + end + st.unresolved = unresolved or {} + st.publishable = CartManifest.publishable(cart) and true or false + local ok, err = CartStore.install(CartManifest.encode(cart)) + if not ok then + st.error = tostring(err) + return + end + local version = st.version + self._cartSave = nil + self:_disarmTextInput() + self:_refreshCarts(version) + self._cartPopup = version + self._cartNotice = Strings("Saved %s. It is in this list now.", title) +end + +function RomImporter:exportCart(id) + if self.workState == "working" then return end + local CartStore = require("src.carts.CartStore") + local SaveData = require("src.core.SaveData") + local bytes, err = CartStore.export(id) + if type(bytes) ~= "string" then + self._cartNotice = tostring(err or "that cart could not be read") + return + end + local fs = SaveData.portableFs() or (love and love.filesystem) + if not (fs and fs.write) then + self._cartNotice = Strings("No filesystem available to export to.") + return + end + if fs.createDirectory then + fs.createDirectory("exports") + fs.createDirectory("exports/carts") + end + local rel = "exports/carts/" .. id .. CartStore.EXT + local wrote, writeErr = fs.write(rel, bytes) + if not wrote then + self._cartNotice = Strings("Could not write the export: %s", tostring(writeErr)) + return + end + local abs, portableBase = rel, SaveData.portableBaseDir() + if portableBase then + local sep = package.config:sub(1, 1) + abs = portableBase .. sep .. rel:gsub("/", sep) + else + local base = fs.getSaveDirectory and fs.getSaveDirectory() or "" + if base ~= "" then abs = base .. "/" .. rel end + end + if self.isNX then + local hint = RomImporter.mtpHintPath(love.filesystem.getSaveDirectory()) + if hint ~= "" and hint:sub(-1) ~= "/" then hint = hint .. "/" end + self._cartNotice = + Strings("Exported to %s\nDBI MTP → 1: SD Card/%sexports/carts/", abs, hint) + return + end + if self.android then + local suggested = id .. CartStore.EXT + local staged = love.filesystem.write("pending_export.sav", bytes) + if staged and love.system.createFile + and love.system.createFile(suggested, love.filesystem.getSaveDirectory()) then + self.pickPending = true + self.pickTimer = 0 + self.androidPendingCartExport = true + self._cartNotice = Strings("Pick where to save %s...", suggested) + else + self._cartNotice = Strings("Exported inside the app folder: %s", abs) + end + return + end + self._cartNotice = Strings("Exported to %s", abs) +end + +-- Reload a scope's slot list + active id from SaveData (the source of truth). +-- Cheap enough to call on any mutation; the per-frame draw only calls it lazily +-- through _ensureSlots so a still list costs nothing after the first paint. +function RomImporter:_refreshSlots(scope) + local SaveData = require("src.core.SaveData") + local cart = cartOfScope(scope) + if cart then + self.slots[scope] = SaveData.listCartSlots(cart) or {} + local opts = SaveData.loadOptions() + local reg = opts.cartSlots and opts.cartSlots[cart] + self.activeSlot[scope] = + reg and (reg.active or (reg.list and reg.list[1])) or nil + return + end + self.slots[scope] = SaveData.listSlots(scope) or {} + local opts = SaveData.loadOptions() + local reg = opts.saveSlots and opts.saveSlots[scope] + -- fall back to the first slot as the shown "loaded" one when the registry + -- has a list but no explicit active id (matches saveNames' own resolution) + self.activeSlot[scope] = reg and (reg.active or reg.list[1]) or nil +end + +function RomImporter:_ensureSlots(scope) + if not self.slots[scope] then self:_refreshSlots(scope) end end -- The host calls this when the save editor closes: the edited slot's player @@ -3984,9 +4627,15 @@ end -- Point the active slot at id (persisted immediately, per the contract) and -- reflect it in the LOADED pill without a full relist. -function RomImporter:_selectSlot(version, id) - require("src.core.SaveData").setActiveSlot(version, id) - self.activeSlot[version] = id +function RomImporter:_selectSlot(scope, id) + local SaveData = require("src.core.SaveData") + local cart = cartOfScope(scope) + if cart then + SaveData.setActiveCartSlot(cart, id) + else + SaveData.setActiveSlot(scope, id) + end + self.activeSlot[scope] = id end -- Inline slot rename (#205): right-click arms a modal text field; Enter @@ -4031,12 +4680,12 @@ function RomImporter:_blurPanelFields() self:_disarmTextInput() end -function RomImporter:_beginRename(version, id) +function RomImporter:_beginRename(scope, id) local label - for _, slot in ipairs(self.slots[version] or {}) do + for _, slot in ipairs(self.slots[scope] or {}) do if slot.id == id then label = slot.label break end end - self._rename = { version = version, id = id, text = label or "" } + self._rename = { version = scope, id = id, text = label or "" } self._slotPress = nil -- cancel any armed click/drag on the list self:_armTextInput() end @@ -4046,7 +4695,13 @@ function RomImporter:_commitRename() if not r then return end self._rename = nil self:_disarmTextInput() - require("src.core.SaveData").renameSlot(r.version, r.id, r.text) + local SaveData = require("src.core.SaveData") + local cart = cartOfScope(r.version) + if cart then + SaveData.renameCartSlot(cart, r.id, r.text) + else + SaveData.renameSlot(r.version, r.id, r.text) + end self:_refreshSlots(r.version) end @@ -4063,6 +4718,13 @@ function RomImporter:textinput(text) self._profileRenamePrompt.text = utf8Cap((self._profileRenamePrompt.text or "") .. text, MAX_SLOT_LABEL) return end + if self._cartSave then + local CartManifest = require("src.carts.CartManifest") + self._cartSave.text = + utf8Cap(self._cartSave.text .. text, CartManifest.MAX_TITLE) + self._cartSave.error = nil + return + end if self._settingsText then local st = self._settingsText st.text = utf8Cap(st.text .. text, st.maxLen or MAX_SLOT_LABEL) @@ -4104,13 +4766,20 @@ end -- "+ New save slot": register an empty slot, make it active, relist, and pin the -- scroll to the bottom (clamped next draw) so the new row is on screen. -function RomImporter:_newSlot(version) +function RomImporter:_newSlot(scope) local SaveData = require("src.core.SaveData") - local id = SaveData.createSlot(version) - SaveData.setActiveSlot(version, id) - self:_refreshSlots(version) - self.activeSlot[version] = id - self.slotScroll[version] = math.huge + local cart = cartOfScope(scope) + local id + if cart then + id = SaveData.createCartSlot(cart) + if id then SaveData.setActiveCartSlot(cart, id) end + else + id = SaveData.createSlot(scope) + SaveData.setActiveSlot(scope, id) + end + self:_refreshSlots(scope) + self.activeSlot[scope] = id + self.slotScroll[scope] = math.huge end -- Mouse wheel: forwarded into the FlexLove view (installed onto the global @@ -4128,6 +4797,7 @@ end function RomImporter:_refreshMods() local LauncherMods = require("src.mods.LauncherMods") local SaveData = require("src.core.SaveData") + self._cartPlan = nil self.safeMode = SaveData.isSafeMode(SaveData.loadOptions()) -- Once per session, ahead of the first listing: pull in any mod the player -- unzipped beside the executable, which an ordinary (non-portable) install @@ -4153,14 +4823,84 @@ function RomImporter:_refreshMods() .. table.concat(failed, ", ") } end end - self.mods = LauncherMods.list(self.modScope) or {} + local listed = LauncherMods.list(self.modScope) or {} + self.mods = listed if self.modScope then local kept = {} - for _, m in ipairs(self.mods) do + for _, m in ipairs(listed) do if m.targetsHere ~= false then kept[#kept + 1] = m end end self.mods = kept end + -- a pin is judged against the whole listing: the cart named it, so it is + -- listed even where the game filter above would have dropped it + local cartId, report = self:modCartPlan() + if cartId then self.mods = self:_cartPinRows(cartId, report, listed) end +end + +-- The cart the MODS panel is answering for, with the plan that resolves its +-- pins, or nil when the panel is on a base game. +function RomImporter:modCartPlan() + local version = self.modScope + if not version then return nil end + local id = self.activeCart and self.activeCart[version] or nil + if not id then return nil end + local report = self:cartPlan(version) + if type(report) ~= "table" or type(report.pins) ~= "table" then return nil end + return id, report, version +end + +local function missingPinRow(imp, id, pin) + local version = type(pin) == "table" and pin.version or nil + return { id = id, name = id, version = version, badge = "MOD", + description = "", enabled = false, status = "missing", + statusDetail = Strings("Pinned by this cart but not installed"), + experimental = false, targetsHere = true, + manifest = { id = id, version = version }, + dependencySpecs = {}, requiredImports = {}, imports = {}, + missingRequiredImports = 0, missingOptionalImports = 0, + safeMode = imp.safeMode == true, cartMissing = true } +end + +-- The pinned set, resolved exactly as Loader:_applyCart resolves it: the +-- cart's own switch, overridden by the player's answer only where the seal +-- hands that switch over (Loader.pinTogglable). +function RomImporter:_cartPinRows(cartId, report, rows) + local CartManifest = require("src.carts.CartManifest") + local Loader = require("src.mods.Loader") + local SaveData = require("src.core.SaveData") + local byId = {} + for _, row in ipairs(rows or {}) do byId[row.id] = row end + local options = SaveData.loadOptions() + local out = {} + for _, id in ipairs(report.order or {}) do + local pin = report.pins[id] + local source = byId[id] + -- a copy: the listing's own row still answers for the base game + local row = {} + if source then + for key, value in pairs(source) do row[key] = value end + row.cartMissing = nil + else + row = missingPinRow(self, id, pin) + end + local togglable = Loader.pinTogglable(report, pin) + local on = CartManifest.modEnabled(pin) + if togglable then + local chosen = SaveData.cartModEnabled(options, cartId, id) + if type(chosen) == "boolean" then on = chosen end + end + row.cartId, row.cartSeal = cartId, report.seal + row.cartTitle = report.title or cartId + row.cartPin, row.cartTogglable = true, togglable and not row.cartMissing + row.cartBase = self.modScope + -- a pin nothing provides cannot run, whatever the cart asked for + row.enabled = on and not self.safeMode and not row.cartMissing + -- one cart, one game: the per-game checkbox row is not this row's answer + row.enabledByVersion = nil + out[#out + 1] = row + end + return out end -- Point the MODS panel at one game (or nil for all of them) and relist, so @@ -4268,6 +5008,11 @@ function RomImporter:_toggleMod(id, confirmed, version) self.modNotice = { ok = false, text = "Safe mode is active. Turn it off in the Bug tab to change mods." } return end + local cartId, cartReport = self:modCartPlan() + if cartId then + self:_toggleCartMod(cartId, cartReport, id, confirmed) + return + end local LauncherMods = require("src.mods.LauncherMods") local cur, experimental = false, false for _, m in ipairs(self.mods or {}) do @@ -4300,6 +5045,62 @@ function RomImporter:_toggleMod(id, confirmed, version) self:_refreshMods() end +-- A cart's pin is switched in the cart's own scope, never in the per-game +-- flags, and only where the seal hands that switch over. Loader.pinTogglable +-- is the single authority on that, in the launcher as in the game. +function RomImporter:_toggleCartMod(cartId, report, id, confirmed) + local CartManifest = require("src.carts.CartManifest") + local Loader = require("src.mods.Loader") + local SaveData = require("src.core.SaveData") + local title = (report and report.title) or cartId + local pin = report and report.pins and report.pins[id] or nil + if not pin then + self.modNotice = { ok = false, text = Strings( + "%s decides which mods run. A cart's mod set cannot be added to or taken from.", + title) } + return + end + local row + for _, m in ipairs(self.mods or {}) do + if m.id == id then row = m break end + end + if row and row.cartMissing then + self.modNotice = { ok = false, text = Strings( + "%s pins %s, but it is not installed.", title, id) } + return + end + if not Loader.pinTogglable(report, pin) then + self.modNotice = { ok = false, text = Strings( + "%s is sealed: its mods run exactly as pinned. Break the seal on the cart's own page to change that.", + title) } + return + end + local on = CartManifest.modEnabled(pin) + local chosen = SaveData.cartModEnabled(SaveData.loadOptions(), cartId, id) + if type(chosen) == "boolean" then on = chosen end + local want = not on + if want and row and row.experimental and not confirmed then + self._modConfirm = { + kind = "experimental", id = id, version = self.modScope, + title = "Experimental mod", + yesLabel = "Enable", + lines = { + "This mod is marked experimental.", + "It may be unfinished or unstable.", + "Enable it anyway?", + }, + } + return + end + self._modConfirm = nil + SaveData.setCartModEnabled(cartId, id, want) + self:_refreshMods() + local name = (row and row.name) or id + self.modNotice = { ok = true, text = want + and Strings("Switched %s on for %s.", name, title) + or Strings("Switched %s off for %s.", name, title) } +end + -- Enable all / Disable all (#647). One options write for the whole list -- (LauncherMods.setAllEnabled) and one relist afterwards, so the count, the -- switches and every status chip resolve together instead of per mod. @@ -4307,11 +5108,20 @@ end -- opt-in is the only warning an experimental mod ever gets, and a bulk button -- must not be the way around it. Disabling needs no confirm -- it is the -- recovery action, and Delete is the only destructive one on this panel. +-- An active cart owns its mod set, so the bulk buttons are refused there +-- rather than becoming a way to add to it or empty it. function RomImporter:_setAllMods(want, confirmed) if self.safeMode then self.modNotice = { ok = false, text = "Safe mode is active. Turn it off in the Bug tab to change mods." } return end + local cartId, cartReport = self:modCartPlan() + if cartId then + self.modNotice = { ok = false, text = Strings( + "%s decides which mods run. Switch its pins one at a time, or pick the base game above.", + (cartReport and cartReport.title) or cartId) } + return + end local LauncherMods = require("src.mods.LauncherMods") local ids, experimental = {}, false for _, m in ipairs(self.mods or {}) do @@ -4485,9 +5295,12 @@ end -- (update a mod, install a specific version, install from an index) funnel -- into one in-flight job, so two installs can never race for the same id. -- --- `spec` = { modId, name, release, notice = "mod"|"find", verb, entry } +-- `spec` = { modId, name, release, notice = "mod"|"find"|"cart", verb, entry, +-- sha256, done } +-- `sha256` gates the unzip; `done(ok, text)` fires on either outcome so a +-- queue can drive one install at a time. function RomImporter:_beginModInstall(spec) - if self._modInstall then return end + if self._modInstall or self._cartInstall then return end local ModIndex = require("src.mods.ModIndex") local release = spec.release -- An index entry only tells us WHERE the zip is; resolving that is @@ -4520,8 +5333,39 @@ end function RomImporter:_modInstallFailed(spec, msg) local notice = { ok = false, text = tostring(msg) } if spec.notice == "find" then self.findNotice = notice - else self.modNotice = notice end + elseif spec.notice ~= "cart" then self.modNotice = notice end self:_clearBusy() + if spec.done then spec.done(false, tostring(msg)) end +end + +local function sha256hex(data) + local digest = love.data.hash("sha256", data) + if type(digest) == "userdata" and digest.getString then + digest = digest:getString() + end + return love.data.encode("string", "hex", digest) +end + +-- A cart pin records the sha256 of the archive it was built against. A hash +-- that does not match is a different build, so nothing is installed. +function RomImporter.verifyArchiveSha256(path, want) + if type(want) ~= "string" or not want:lower():match("^%x+$") + or #want ~= 64 then + return false, "the cart records no usable sha256 for this mod" + end + local ok, data = pcall(love.filesystem.read, path) + if not ok or type(data) ~= "string" or data == "" then + return false, "the downloaded archive could not be read back" + end + local hashed, got = pcall(sha256hex, data) + if not hashed or type(got) ~= "string" then + return false, "this platform cannot hash the archive, so it was not installed" + end + if got:lower() ~= want:lower() then + return false, ("archive sha256 %s does not match the pinned %s") + :format(got:sub(1, 12), want:sub(1, 12)) + end + return true end function RomImporter:_pumpModInstall() @@ -4544,6 +5388,16 @@ function RomImporter:_pumpModInstall() self:_modInstallFailed(spec, err or "download failed") return end + -- Hash gate before the unzip: a pinned archive that does not match the cart + -- is thrown away rather than installed. + if spec.sha256 then + local verified, why = RomImporter.verifyArchiveSha256(path, spec.sha256) + if not verified then + pcall(love.filesystem.remove, path) + self:_modInstallFailed(spec, why) + return + end + end -- Unzip + manifest check. Fast, and it must run here: love.filesystem -- writes are main-thread only. self:_setBusy(Strings("Installing %s", tostring(spec.name or spec.modId))) @@ -4567,14 +5421,138 @@ function RomImporter:_pumpModInstall() tostring(spec.name or spec.modId), shown) if spec.notice == "find" then self.findNotice = { ok = true, text = text } - else + elseif spec.notice ~= "cart" then self.modNotice = { ok = true, text = text } end - local LauncherMods = require("src.mods.LauncherMods") - local depCheck = LauncherMods.checkDependencies({ id = spec.modId }) - if depCheck and depCheck.hasIssues then - self._modDepResolver = depCheck + -- A cart pins its whole load order, so the dependency modal would only + -- interrupt the queue that is already installing the rest of it. + if spec.notice ~= "cart" then + local depCheck = LauncherMods.checkDependencies({ id = spec.modId }) + if depCheck and depCheck.hasIssues then + self._modDepResolver = depCheck + end end + if spec.done then spec.done(true, text) end +end + +-- ------- cart install from an index listing +-- +-- A cart is one .g1rcart file, so the bytes go whole to CartStore.install and +-- never through the mod installer. Shares the mod job's single-in-flight +-- rule: either entry point refuses while the other is running. +function RomImporter:_beginCartInstall(entry) + if self._modInstall or self._cartInstall then return end + local ModIndex = require("src.mods.ModIndex") + local release, why = ModIndex.releaseFor(entry) + if type(release) ~= "table" or not (release.zip and release.zip.url) then + self.findNotice = { ok = false, + text = ("%s: %s"):format(tostring(entry.title or entry.id), + tostring(why or "this cart has no downloadable release")) } + return + end + local ModUpdate = require("src.mods.ModUpdate") + local version = release.version or entry.version or os.time() + local tmpName = ("cart_install_%s_%s%s"):format( + tostring(entry.id):gsub("[^%w%-_]", "_"), tostring(version), + require("src.carts.CartStore").EXT) + self._cartInstall = { + entry = entry, version = release.version or entry.version, + h = ModUpdate.beginDownloadZip(release.zip.url, tmpName, release.zip.size), + } + self:_setBusy(Strings("Downloading %s", tostring(entry.title or entry.id)), + "v" .. tostring(release.version or entry.version or "?")) +end + +function RomImporter:_cartInstallFailed(msg) + self._cartInstall = nil + self:_clearBusy() + self.findNotice = { ok = false, text = tostring(msg) } +end + +function RomImporter:_pumpCartInstall() + local job = self._cartInstall + if not job then return end + local ModUpdate = require("src.mods.ModUpdate") + local ok, done, path, err, progress = pcall(ModUpdate.pumpDownloadZip, job.h) + if ok and not done then + if progress and self._busy then self._busy.progress = progress end + return + end + local entry = job.entry + local name = tostring(entry.title or entry.id) + if not ok then + return self:_cartInstallFailed(name .. ": download failed: " .. tostring(done)) + end + if not path then + return self:_cartInstallFailed(name .. ": " .. tostring(err or "download failed")) + end + self._cartInstall = nil + local read, bytes = pcall(love.filesystem.read, path) + pcall(love.filesystem.remove, path) + if not read or type(bytes) ~= "string" or bytes == "" then + return self:_cartInstallFailed(name .. ": the download could not be read back") + end + self:_setBusy(Strings("Installing %s", name)) + local CartStore = require("src.carts.CartStore") + local ran, cart, installErr = pcall(CartStore.install, bytes) + self:_clearBusy() + if not ran then + return self:_cartInstallFailed(name .. ": install failed: " .. tostring(cart)) + end + if not cart then + return self:_cartInstallFailed(name .. ": " .. tostring(installErr)) + end + -- _refreshCarts caches per version, so the Custom Carts list for the game + -- this cart plays as would keep its pre-install copy until a relaunch. + -- The label art is cached per cart too, and a reinstall can repaint it. + self:_refreshCarts(cart.base) + self._cartPlan = nil + self._cartridgeLabels = nil + self.findNotice = { ok = true, + text = Strings("Installed %s v%s. It is in this game's cart list now.", + tostring(cart.title or cart.id), tostring(cart.version or "?")) } + self:_offerCartPins(cart) +end + +-- The pins a cart is missing, answered without disturbing whichever cart the +-- player currently has selected for that game. +function RomImporter:_cartPinsMissing(version, id) + local wasCart = self.activeCart[version] + local wasPlan = self._cartPlan + self.activeCart[version] = id + self._cartPlan = nil + local ok, rows = pcall(self.cartFillRows, self, version) + self.activeCart[version] = wasCart + self._cartPlan = wasPlan + return (ok and type(rows) == "table") and rows or {} +end + +-- A cart whose pins are missing will not start, so ask once rather than +-- dead-end, and route a yes at the existing hash-verified fill queue. +function RomImporter:_offerCartPins(cart) + local rows = self:_cartPinsMissing(cart.base, cart.id) + if #rows == 0 then return end + local info = GameVersion.info(cart.base) + local title = tostring(cart.title or cart.id) + self._modConfirm = { + kind = "cartPins", version = cart.base, id = cart.id, + title = "Install this cart's mods", + yesLabel = "Install", + lines = { + ("%s pins %d mod(s) you do not have."):format(title, #rows), + "Install them now? Each one is checked against the cart's own hash.", + ("This selects %s as the cart for %s."):format(title, + tostring((info and (info.launcherName or info.displayName)) + or cart.base)), + }, + } +end + +-- The confirm's yes. The cart being filled has to be the selected one, which +-- is what cartFillRows and pressInstallCartMods both answer for. +function RomImporter:_installCartPins(version, id) + self:_selectCart(version, id) + self:pressInstallCartMods(version) end -- Start an async pull for a single dependency @@ -4751,7 +5729,7 @@ function RomImporter:_refreshFind(force) -- the index looks empty. if not Platform.canFetchRemote() then self.findLoaded = true - self.findIndex = { mods = {}, categories = {} } + self.findIndex = { mods = {}, carts = {}, categories = {}, baseGames = {} } self.findNotice = { ok = false, text = "Mod indexes cannot be fetched on this platform. Install a mod .zip from storage instead." } return @@ -4760,7 +5738,7 @@ function RomImporter:_refreshFind(force) self:_refreshFindSources() local sources = self.findSources or {} if #sources == 0 then - self.findIndex = { mods = {}, categories = {} } + self.findIndex = { mods = {}, carts = {}, categories = {}, baseGames = {} } self.findLoaded = true return end @@ -4775,6 +5753,7 @@ function RomImporter:_refreshFind(force) self._findFetch = { handles = handles, force = force == true, mods = {}, seen = {}, cats = {}, catSeen = {}, errs = {}, + carts = {}, cartSeen = {}, bases = {}, baseSeen = {}, stale = false, oldest = nil, at = 1, } self:_setBusy(Strings("Fetching mod index"), @@ -4831,29 +5810,51 @@ function RomImporter:_pumpFindFetch() f.mods[#f.mods + 1] = entry end end + -- Carts merge on the same first-source-wins rule, in their own id + -- space: a cart and a mod may share an id without shadowing each other. + for _, entry in ipairs(index.carts or {}) do + if not f.cartSeen[entry.id] then + f.cartSeen[entry.id] = true + entry._source = source.label or source.feed + entry._base = source.base + f.carts[#f.carts + 1] = entry + end + end for _, c in ipairs(ModIndex.categoriesIn(index)) do if not f.catSeen[c] then f.catSeen[c] = true f.cats[#f.cats + 1] = c end end + for _, b in ipairs(ModIndex.baseGamesIn(index)) do + if not f.baseSeen[b] then + f.baseSeen[b] = true + f.bases[#f.bases + 1] = b + end + end end end - self.findIndex = { mods = f.mods, categories = f.cats, stale = f.stale, + self.findIndex = { mods = f.mods, carts = f.carts, categories = f.cats, + baseGames = f.bases, stale = f.stale, checkedAt = f.oldest } self.findLoaded = true if #f.errs > 0 then self.findNotice = { ok = false, text = table.concat(f.errs, " - ") } elseif f.force then self.findNotice = { ok = true, - text = Strings("Refreshed - %d mods listed", #f.mods) } + text = (#f.carts > 0) + and Strings("Refreshed - %d mods and %d carts listed", #f.mods, #f.carts) + or Strings("Refreshed - %d mods listed", #f.mods) } end -- A category that no longer exists after a refresh would filter everything - -- away with no way back except guessing. + -- away with no way back except guessing. Same for a base game. if self.findCategory and not f.catSeen[self.findCategory] then self.findCategory = nil end + if self.findBase and not f.baseSeen[self.findBase] then + self.findBase = nil + end self.findPage = 1 self._findFetch = nil self:_clearBusy() @@ -4882,7 +5883,7 @@ function RomImporter:_ensureFind() if #(self.findSources or {}) == 0 then -- Nothing to fetch, but the panel is loaded: the empty state is the -- answer, not a pending request. - self.findIndex = { mods = {}, categories = {} } + self.findIndex = { mods = {}, carts = {}, categories = {}, baseGames = {} } self.findLoaded = true else self:_refreshFind(false) @@ -4890,45 +5891,93 @@ function RomImporter:_ensureFind() end end +-- Whether the FIND panel is browsing carts rather than mods. +function RomImporter:findingCarts() + return self.findKind == "carts" +end + +-- The switch itself. Query survives the flip; the category / base filter +-- does not, because neither vocabulary applies to the other list. +function RomImporter:_setFindKind(kind) + kind = (kind == "carts") and "carts" or "mods" + if self.findKind == kind then return end + self.findKind = kind + self._findRowsCache = nil + self._findSortCache = nil + if self._pages then self._pages["find"] = 1 end + self.findPage = 1 +end + -- The rows the filters leave, and the installed-mod context the compatibility -- warnings are judged against. function RomImporter:_findRows() - local all = (self.findIndex and self.findIndex.mods) or {} + local carts = self:findingCarts() + local all = (self.findIndex + and (carts and self.findIndex.carts or self.findIndex.mods)) or {} -- The view asks every frame (immediate mode); only re-filter when the - -- index, query, or category actually changed. + -- index, query, or filter actually changed. local c = self._findRowsCache if c and c.src == all and c.query == self.findQuery - and c.category == self.findCategory and c.scope == self.modScope then + and c.category == self.findCategory and c.base == self.findBase + and c.scope == self.modScope then return c.rows end local ModIndex = require("src.mods.ModIndex") local rows = ModIndex.filter(all, { query = self.findQuery, - category = self.findCategory, + category = (not carts) and self.findCategory or nil, + base = carts and self.findBase or nil, }) if self.modScope then - local ModTargets = require("src.mods.ModTargets") - local gen = GameVersion.generation(self.modScope) - local kept = {} - for _, entry in ipairs(rows) do - local versions = ModTargets.normalize(entry.games) - if #versions == 0 or ModTargets.covers(versions, gen) then - kept[#kept + 1] = entry + if carts then + -- A cart plays as exactly one game, so the scope is a plain match on + -- the base rather than a ModTargets coverage question. + local kept = {} + for _, entry in ipairs(rows) do + if entry.base == self.modScope then kept[#kept + 1] = entry end end + rows = kept + else + local ModTargets = require("src.mods.ModTargets") + local gen = GameVersion.generation(self.modScope) + local kept = {} + for _, entry in ipairs(rows) do + local versions = ModTargets.normalize(entry.games) + if #versions == 0 or ModTargets.covers(versions, gen) then + kept[#kept + 1] = entry + end + end + rows = kept end - rows = kept end self._findRowsCache = { src = all, query = self.findQuery, - category = self.findCategory, scope = self.modScope, rows = rows } + category = self.findCategory, base = self.findBase, + scope = self.modScope, rows = rows } return rows end function RomImporter:_findInstalledMap() + if self:findingCarts() then return self:_findInstalledCarts() end local map = {} for _, m in ipairs(self.mods or {}) do map[m.id] = m.version or true end return map end +-- id -> installed version for every cart on disk, whatever game it plays as. +-- Cached because the row loop asks once per frame. +function RomImporter:_findInstalledCarts() + if self._findCartMap then return self._findCartMap end + local map = {} + local ok, rows = pcall(function() + return require("src.carts.CartStore").index() + end) + if ok and type(rows) == "table" then + for _, row in ipairs(rows) do map[row.id] = row.version or true end + end + self._findCartMap = map + return map +end + -- One thumbnail per frame, and only for a card actually on screen: the fetch -- is a blocking curl, so downloading a whole listing's worth on open would -- stall the launcher for as many seconds as there are mods. A failure is @@ -5185,6 +6234,9 @@ function RomImporter:_findConfirmInstall(entry) text = (entry.title or entry.id) .. ": " .. tostring(why) } return end + if ModIndex.isCart(entry) then + return self:_findConfirmCartInstall(entry) + end local installed = self:_findInstalledMap() local issues = ModIndex.compatIssues(entry, { modApi = Version.modApi, @@ -5211,7 +6263,45 @@ function RomImporter:_findConfirmInstall(entry) } end +-- The cart twin of the confirm above. The pinned list is what the player is +-- agreeing to; installing the cart installs none of it. +function RomImporter:_findConfirmCartInstall(entry) + local ModIndex = require("src.mods.ModIndex") + local Version = require("src.core.Version") + local issues = ModIndex.compatIssues(entry, { + modApi = Version.modApi, + engineVersion = Version.engine, + installed = self:_findInstalledMap(), + }) + local version = ModIndex.displayVersion(entry) + local info = GameVersion.info(entry.base) + local lines = { (entry.title or entry.id) .. " v" .. tostring(version) } + if entry.author then lines[#lines + 1] = "by " .. entry.author end + lines[#lines + 1] = "Plays as " + .. tostring((info and (info.launcherName or info.displayName)) or entry.base) + .. " - " .. tostring(entry.seal) + lines[#lines + 1] = ("Pins %d mod(s), installed separately from its page") + :format(#(entry.mods or {})) + local have = self:_findInstalledCarts()[entry.id] + if have then + lines[#lines + 1] = "Replaces installed v" .. tostring(have) + end + for _, issue in ipairs(issues) do + lines[#lines + 1] = "! " .. issue.text + end + lines[#lines + 1] = "Carts are not reviewed - trust the author." + self._modConfirm = { + kind = (#issues > 0) and "warn" or "update", + indexEntry = entry, + title = have and "Reinstall cart" or "Install cart", + yesLabel = have and "Reinstall" or "Install", + lines = lines, + } +end + function RomImporter:_findInstall(entry) + local ModIndex = require("src.mods.ModIndex") + if ModIndex.isCart(entry) then return self:_beginCartInstall(entry) end self:_beginModInstall({ modId = entry.id, name = entry.title or entry.id, entry = entry, verb = "Installed", notice = "find", diff --git a/src/mods/Gen2Compat.lua b/src/mods/Gen2Compat.lua index 2594e8b6..b2a6c789 100644 --- a/src/mods/Gen2Compat.lua +++ b/src/mods/Gen2Compat.lua @@ -1582,7 +1582,7 @@ local function buildPartyMenu() elseif opts.battle and opts.onSwitch then prompt, battleSubmenu = "choose", true elseif opts.pickOnly then - prompt = "useItem" + prompt = opts.itemUse and "useItem" or "choose" elseif opts.onSwitch then -- src/ui/PartyMenu.lua:569: onSwitch OUTSIDE battle fires on A itself, -- so the field submenu must not swallow the press @@ -1647,7 +1647,7 @@ COVERAGE["src.ui.PartyMenu"] = { warned = "keepOpen tmhm", absent = "drawIcon frameFor mirrorsIcon iconFrames sgbPalettes animateTo " .. "heal softboiledFrom battle subItems subIndex swapFrom blink onSwitch " - .. "pickOnly forceSwitch", + .. "pickOnly itemUse forceSwitch", notes = { new = "onSwitch(mon, menu) is wrapped onto onChoose(index, mon); opts." .. "battle carries only its BOOLEAN sense and self.battle is left nil " @@ -1663,7 +1663,7 @@ COVERAGE["src.ui.PartyMenu"] = { onSwitch = "replacing menu.onSwitch on a LIVE instance writes a field " .. "Gen 2 never reads; pass it to .new instead", bottomMessage = "returns Gold's strings with / charmap glyphs, so " - .. "a compare against \"Use on which one?\" will not match", + .. "a compare against \"Use item on which\\nPOKéMON?\" will not match", ["hook ui.party.submenu"] = "same name and arity; rows carry `id` on Gold " .. "where Gen 1 carries `action`, and ctx.battle is a BOOLEAN, not a " .. "BattleState", @@ -1838,7 +1838,7 @@ local function buildBattleState() "syncSides", "playerHasPP", "lockedAction", "computeMusicKind", "throwBall", "ballChain", "tossAnimFor", "ballFlicker", "ballMissMessage", "storeCaughtMon", "safariAction", "safariEnemyTurn", "drawBallRow", - "drawClassic", "isWideBattleLayout", "wideLayout", "bgMode", "uiSize", + "drawClassic", "isWideBattleLayout", "wideLayout", "uiSize", "sgbPalettes", "trainerPalette", "trainerPicPath", "trainerTrueColor", "trainerSprite", "invalidate", "imageBattleScale", "resolveBattleScale", "backPlacement", @@ -1922,11 +1922,11 @@ COVERAGE["src.battle.BattleState"] = { backed = "update draw __index isOpaque openParty swapMoves " .. "lowHealthAlarmActive playVictoryMusic say sayAuto openItems " .. "openReplacementMenu finish askNicknameUI playEntranceCry stampOT " - .. "tryRun wantsFillScale", + .. "tryRun wantsFillScale bgMode", warned = "tryRun askNicknameUI", absent = "newWild newTrainer makeSafari makeGhost makeBattler resolveTurn " .. "computeDamage catchAttempt runRoll enter exit sgbPalettes " - .. "isWideBattleLayout wideLayout bgMode uiSize letterboxWhite " + .. "isWideBattleLayout wideLayout uiSize letterboxWhite " .. "holdsUIAnchors BG_WORLD_DIM trainerPalette trainerPicPath " .. "trainerTrueColor trainerSprite invalidate " .. "backPlacement frontPlacement StatBox drawClassic drawBallRow " diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 5a060b4f..5b24bcd7 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -1,6 +1,8 @@ local Json = require("src.link.Json") local Logger = require("src.core.Logger") local SaveData = require("src.core.SaveData") +local CartManifest = require("src.carts.CartManifest") +local CartStore = require("src.carts.CartStore") local Data = require("src.core.Data") local GameVersion = require("src.core.GameVersion") local Version = require("src.core.Version") @@ -268,8 +270,9 @@ function Loader.new(opts) events = Events.new(), hooks = Hooks.new(), content = {}, assets = {}, exports = {}, migrations = {}, order = {}, modSave = {}, modOptions = {}, optionSchemas = {}, imageCache = {}, - modInput = {}, modEnv = {}, stepsQueues = {}, + modInput = {}, modEnv = {}, stepsQueues = {}, cartSwitches = {}, fs = (opts and opts.fs) or (love and love.filesystem), + cart = opts and opts.cart or nil, dev = dev == true, safeMode = false, -- Which generation this boot is (1 or 2). Fixed at construction: the @@ -367,7 +370,11 @@ function Loader:_saveState() local scope = self:_enableScope() local version = self:_targetVersion() for id in pairs(self.mods) do - SaveData.setModEnabled(options, id, not self.disabled[id], scope) + -- a switch the cart owns is answered in the cart's scope by setEnabled, + -- so it never rewrites what the base game runs + if not self.cartSwitches[id] then + SaveData.setModEnabled(options, id, not self.disabled[id], scope) + end -- only the games this boot can answer for: another version's overrides -- are not this run's to rewrite. With no version (an injected-generation -- harness) the override stays in memory for this boot only. @@ -434,6 +441,9 @@ function Loader:setEnabled(id, enabled) if not self.mods[id] then return false end self.disabled[id] = not enabled self.mods[id].enabled = enabled + if self.cartSwitches[id] and self.cartReport then + SaveData.setCartModEnabled(self.cartReport.id, id, enabled, self.fs) + end self:_saveState() return true end @@ -488,6 +498,161 @@ function Loader:_discover() end end +-- ------- custom carts + +local function installedVersions(installed) + local out = {} + for key, entry in pairs(installed or {}) do + if type(entry) == "table" then + local manifest = type(entry.manifest) == "table" and entry.manifest or entry + local id = manifest.id or (type(key) == "string" and key or nil) + if type(id) == "string" then out[id] = manifest.version end + end + end + return out +end + +local function pinnedVersion(pin) + local version = pin.version + if type(version) ~= "string" or version == "" then return nil end + if pin.source == "local" and version == CartStore.UNPINNED_VERSION then return nil end + return version +end + +local function sameVersion(want, have) + local order = Semver.compare(want, have) + if order ~= nil then return order == 0 end + return want == have +end + +local function cartComplaints(report) + local parts = {} + for _, row in ipairs(report.missing) do + parts[#parts + 1] = ("%s %s is not installed") + :format(row.id, row.version or "(any version)") + end + for _, row in ipairs(report.mismatched) do + parts[#parts + 1] = ("%s is pinned at %s but %s is installed") + :format(row.id, row.version, row.installed) + end + return parts +end + +-- Whether the player's own enable flag decides a pinned mod. "sealed+" hands +-- every pin over; any other seal hands over only the pins the cart ships off. +function Loader.pinTogglable(report, pin) + if type(report) ~= "table" or type(pin) ~= "table" then return false end + if report.seal == "sealed+" then return true end + if pin.enabled ~= false then return false end + return not (report.seal == "sealed" and not report.broken) +end + +function Loader.planCart(cart, installed, broken) + local report = { seal = "sealed", sealed = true, broken = broken == true, + order = {}, rank = {}, pins = {}, missing = {}, mismatched = {}, + floor = 1, refused = false } + if type(cart) ~= "table" then + report.enforced = true + report.refused = true + report.message = "this cart is not installed" + return report + end + report.id, report.title = cart.id, cart.title + report.seal = CartManifest.SEALS[cart.seal] and cart.seal or "sealed" + report.sealed = report.seal ~= "open" + report.enforced = report.sealed and not report.broken + local have = installedVersions(installed) + local pins = {} + for _, pin in ipairs(cart.mods or {}) do + if type(pin) == "table" and type(pin.id) == "string" then pins[pin.id] = pin end + end + for _, id in ipairs(cart.load_order or {}) do + local pin = pins[id] + if pin and not report.pins[id] then + report.order[#report.order + 1] = id + report.rank[id] = #report.order + report.pins[id] = pin + local want, got = pinnedVersion(pin), have[id] + if got == nil then + report.missing[#report.missing + 1] = + { id = id, version = want, source = pin.source } + elseif want and not sameVersion(want, got) then + report.mismatched[#report.mismatched + 1] = + { id = id, version = want, installed = got } + end + end + end + report.floor = #report.order + 1 + local parts = cartComplaints(report) + if #parts > 0 then + report.message = ("%s: %s"):format(cart.title or cart.id or "cart", + table.concat(parts, "; ")) + report.refused = report.enforced + end + return report +end + +function Loader:cartStatus() + return self.cartReport +end + +function Loader:_applyCart() + local cartId = SaveData.getCart() + if not cartId or self.safeMode then return end + local cart, err = self.cart, nil + if not cart then cart, err = CartStore.get(cartId, self.fs) end + local report = Loader.planCart(cart, self.mods, SaveData.isSealBroken()) + report.id = cartId + if not cart then + report.message = ("%s: %s"):format(cartId, tostring(err or "this cart is not installed")) + end + self.cartReport = report + if report.refused then + for _, mod in pairs(self.mods) do + mod.enabled, mod.state = false, "disabled" + end + self.errors[#self.errors + 1] = report.message + Logger.error("cart %s refused: %s", cartId, report.message) + return + end + if report.message then Logger.warn("cart %s: %s", cartId, report.message) end + -- the pins whose switch the cart hands to the player: their answer lives in + -- the cart's own scope, never in the per-game flags + self.cartSwitches = {} + local options = SaveData.loadOptions(self.fs) + for id, mod in pairs(self.mods) do + local pin = report.pins[id] + if pin then + local on = CartManifest.modEnabled(pin) + if Loader.pinTogglable(report, pin) then + local chosen = SaveData.cartModEnabled(options, cartId, id) + if type(chosen) == "boolean" then on = chosen end + self.cartSwitches[id] = true + end + mod.enabled, mod.state = on, on and "pending" or "disabled" + elseif report.enforced then + mod.enabled, mod.state = false, "disabled" + end + end + local merged = {} + for id, bucket in pairs(self.modOptions) do merged[id] = bucket end + for id, pin in pairs(report.pins) do + local bucket = {} + for key, value in pairs(pin.options or {}) do bucket[key] = value end + if not report.enforced then + for key, value in pairs(self.modOptions[id] or {}) do bucket[key] = value end + end + merged[id] = bucket + end + self.modOptions = merged +end + +function Loader:_cartRank(id) + local report = self.cartReport + if not report then return 0 end + return report.rank[id] or report.floor +end + -- ------- validate and resolve -- a failed mod keeps the user's enable flag (the manager still shows it as @@ -773,9 +938,12 @@ function Loader:_order() if not best then best = id else + local ra, rb = self:_cartRank(id), self:_cartRank(best) local pa, pb = self.mods[id].manifest.priority, self.mods[best].manifest.priority - if pa < pb or (pa == pb and id < best) then best = id end + if ra < rb or (ra == rb and (pa < pb or (pa == pb and id < best))) then + best = id + end end end end @@ -1614,6 +1782,7 @@ function Loader:load(data) mod.enabled = not self.disabled[id] mod.state = mod.enabled and "pending" or "disabled" end + self:_applyCart() -- engine call sites reach these buses -- and this error feed, for failures -- that only surface at play time -- through Runtime from here on Runtime.install(self.events, self.hooks, self.errors) @@ -1787,7 +1956,7 @@ function Loader:status() table.sort(available, function(a, b) return a.id < b.id end) table.sort(loaded, function(a, b) return a.id < b.id end) return { available = available, loaded = loaded, errors = self.errors, - order = self.order } + order = self.order, cart = self.cartReport } end return Loader diff --git a/src/mods/ModIndex.lua b/src/mods/ModIndex.lua index b136297d..78618e49 100644 --- a/src/mods/ModIndex.lua +++ b/src/mods/ModIndex.lua @@ -18,6 +18,9 @@ -- schema_version is a hard gate, not a hint: a bumped feed may reuse a field -- name for something else, so an unknown version is refused outright rather -- than parsed hopefully. +-- +-- Carts ride the same feed additively at schema_version 1: `doc.carts` is a +-- second array beside `doc.mods`, and a feed without it lists no carts. local ModIndex = {} @@ -26,7 +29,7 @@ local ModIndex = {} -- single repo's releases; a whole index is heavier and changes more slowly. ModIndex.CACHE_TTL = 24 * 60 * 60 ModIndex.SCHEMA_VERSION = 1 -ModIndex.CACHE_VERSION = 2 +ModIndex.CACHE_VERSION = 3 -- ------- pure: source resolution @@ -225,8 +228,95 @@ local function parseEntry(raw) } end --- parse(jsonText [, Json]) -> { schemaVersion, generatedAt, categories, mods } --- | nil, err +local function numArray(v) + local out = {} + if type(v) == "table" then + for _, entry in ipairs(v) do + local n = tonumber(entry) + if n then out[#out + 1] = n end + end + end + return out +end + +-- One pinned mod out of a cart's `mods` array, in the bundle's own cart.json +-- shape: github pins carry repo/version/sha256, gamebanana pins mod/file/md5. +local function parseCartPin(raw) + if type(raw) ~= "table" or not str(raw.id) then return nil end + local source = str(raw.source) + if source ~= "github" and source ~= "gamebanana" then return nil end + local pin = { + id = raw.id, + source = source, + repo = str(raw.repo), + version = str(raw.version), + sha256 = str(raw.sha256), + mod = tonumber(raw.mod), + file = tonumber(raw.file), + md5 = str(raw.md5), + options = type(raw.options) == "table" and raw.options or nil, + } + if raw.enabled == false then pin.enabled = false end + return pin +end + +-- One cart listing. The eight fields the cart schema marks required are the +-- gate: a row missing any of them is dropped rather than half-listed, because +-- every one of them is load bearing for installing or naming the cart. +local function parseCartEntry(raw) + if type(raw) ~= "table" then return nil end + if not (str(raw.id) and str(raw.title) and str(raw.author) + and str(raw.version) and str(raw.base) and str(raw.seal) + and str(raw.repo) and type(raw.mods) == "table") then + return nil + end + local pins = {} + for _, pin in ipairs(raw.mods) do + local parsed = parseCartPin(pin) + if parsed then pins[#pins + 1] = parsed end + end + if #pins == 0 then return nil end + return { + kind = "cart", + folder = str(raw.folder), + id = raw.id, + title = raw.title, + author = raw.author, + version = raw.version, + base = raw.base, + seal = raw.seal, + summary = str(raw.summary) or "", + shell = str(raw.shell), + finish = str(raw.finish), + speeds = numArray(raw.speeds), + tags = strArray(raw.tags), + repo = raw.repo, + github = str(raw.github), + downloadURL = str(raw.downloadURL), + automatic_version_check = raw.automatic_version_check ~= false, + fixed_release_tag = str(raw.fixed_release_tag), + game_version = str(raw.game_version), + license = str(raw.license), + mods = pins, + load_order = strArray(raw.load_order), + thumbnail = str(raw.thumbnail), + description_url = str(raw.description_url), + downloads = parseDownloads(raw.downloads), + first_release = str(raw.first_release), + last_release = str(raw.last_release), + latest = parseLatest(raw.latest), + update_check = str(raw.update_check) or "pending", + } +end + +-- True for a listing that installs through CartStore, not the mod installer. +function ModIndex.isCart(entry) + return type(entry) == "table" and entry.kind == "cart" +end + +-- parse(jsonText [, Json]) +-- -> { schemaVersion, generatedAt, categories, baseGames, mods, carts } +-- | nil, err -- Never throws: a truncated download, an HTML error page, or a feed from a -- future schema all come back as a message the panel can print. function ModIndex.parse(jsonText, Json) @@ -254,11 +344,21 @@ function ModIndex.parse(jsonText, Json) local entry = parseEntry(raw) if entry then mods[#mods + 1] = entry end end + -- Absent carts is the old-feed case, not an error. + local carts = {} + if type(doc.carts) == "table" then + for _, raw in ipairs(doc.carts) do + local entry = parseCartEntry(raw) + if entry then carts[#carts + 1] = entry end + end + end return { schemaVersion = schema, generatedAt = str(doc.generated_at), categories = strArray(doc.categories), + baseGames = strArray(doc.base_games), mods = mods, + carts = carts, } end) if not ok then return nil, "could not read the index: " .. tostring(result) end @@ -421,12 +521,14 @@ function ModIndex.matches(entry, query) return true end --- filter(mods, opts) -> a new array. opts = { query, category, tag }. --- Category and tag compare case-insensitively; feed order (already sorted by --- title) is preserved. +-- filter(entries, opts) -> a new array. opts = { query, category, base, tag }. +-- Category, base and tag compare case-insensitively; feed order (already +-- sorted by title) is preserved. `base` is the cart-side equivalent of a +-- mod's category: a cart plays as exactly one game and has no categories. function ModIndex.filter(mods, opts) opts = opts or {} local want = opts.category and tostring(opts.category):lower() or nil + local wantBase = opts.base and tostring(opts.base):lower() or nil local wantTag = opts.tag and tostring(opts.tag):lower() or nil local out = {} for _, entry in ipairs(mods or {}) do @@ -437,6 +539,9 @@ function ModIndex.filter(mods, opts) if tostring(c):lower() == want then keep = true; break end end end + if keep and wantBase then + keep = tostring(entry.base or ""):lower() == wantBase + end if keep and wantTag then keep = false for _, t in ipairs(entry.tags or {}) do @@ -469,6 +574,32 @@ function ModIndex.categoriesIn(index) return out end +-- Every base game the feed's carts actually play as, in the feed's declared +-- base_games order, with anything a cart names that the header forgot +-- appended. The cart-side twin of categoriesIn. +function ModIndex.baseGamesIn(index) + local out, seen = {}, {} + if type(index) ~= "table" then return out end + local used = {} + for _, entry in ipairs(index.carts or {}) do + if entry.base then used[entry.base] = true end + end + for _, base in ipairs(index.baseGames or {}) do + if used[base] and not seen[base] then + seen[base] = true + out[#out + 1] = base + end + end + for _, entry in ipairs(index.carts or {}) do + local base = entry.base + if base and not seen[base] then + seen[base] = true + out[#out + 1] = base + end + end + return out +end + -- ------- sources (options.modIndexes) local function loadOptions() @@ -569,7 +700,9 @@ function ModIndex.writeCache(feed, index) version = ModIndex.CACHE_VERSION, generatedAt = index.generatedAt, categories = index.categories, + baseGames = index.baseGames, mods = index.mods, + carts = index.carts, } SaveData.saveOptions(opts) end) @@ -609,7 +742,9 @@ function ModIndex.fetch(source, opts) schemaVersion = ModIndex.SCHEMA_VERSION, generatedAt = entry.generatedAt, categories = entry.categories or {}, + baseGames = entry.baseGames or {}, mods = entry.mods or {}, + carts = entry.carts or {}, }, nil, { fromCache = true, stale = stale, checkedAt = entry.checkedAt } end @@ -674,7 +809,9 @@ local function cachedIndex(feed, stale) schemaVersion = ModIndex.SCHEMA_VERSION, generatedAt = entry.generatedAt, categories = entry.categories or {}, + baseGames = entry.baseGames or {}, mods = entry.mods or {}, + carts = entry.carts or {}, }, nil, { fromCache = true, stale = stale, checkedAt = entry.checkedAt } end diff --git a/src/net/Fetch.lua b/src/net/Fetch.lua index 9e8562cf..db7eb788 100644 --- a/src/net/Fetch.lua +++ b/src/net/Fetch.lua @@ -87,6 +87,7 @@ local function drain() j.status = msg.ok and "ok" or "error" j.body, j.err, j.path = msg.body, msg.err, msg.path j.code = msg.code + j.notModified = msg.notModified j.progress = msg.ok and 1 or j.progress end end @@ -154,12 +155,18 @@ end -- Download a URL to `saveRel`, a path relative to the LOVE save directory. -- Progress is reported as a 0..1 fraction when `size` is known. +-- opts.etagRel (optional, relative to the save directory like saveRel itself) +-- turns this into a conditional GET against a cached ETag at that path -- +-- see HostShell.httpDownload's own comment for the 304/notModified contract. +-- A caller checks Fetch.poll(job).notModified once status is "ok" to tell a +-- real download apart from a no-op cache hit (no file was written for the +-- latter). function Fetch.download(url, saveRel, opts) opts = opts or {} return submit({ kind = "download", url = url, dest = saveRel, size = opts.size, userAgent = opts.userAgent or "gen1recomp", - accept = opts.accept, maxSeconds = opts.maxSeconds }) + accept = opts.accept, maxSeconds = opts.maxSeconds, etagRel = opts.etagRel }) end -- Non-blocking status. Returns a table; never nil, even for an unknown id diff --git a/src/net/fetch_worker.lua b/src/net/fetch_worker.lua index 79006d62..def8d1fb 100644 --- a/src/net/fetch_worker.lua +++ b/src/net/fetch_worker.lua @@ -73,6 +73,12 @@ end -- inside the call. Where the caller knows the expected size we poll the -- growing file from a second pass instead; where it does not, the job simply -- reports indeterminate progress and the UI shows a spinner. +-- +-- job.etagRel (optional, relative to saveDir like job.dest) turns this into +-- a conditional GET -- see HostShell.httpDownload's own comment for the +-- 304/notModified contract. A notModified result never writes `rel` at all +-- (confirmed empirically, not assumed -- see TrueFX/etag-cache-repro/), so +-- the usual "did a file actually land" check below is skipped for it. local function doDownload(job) if not HostShell then post({ id = job.id, ok = false, err = "no transport" }) @@ -84,12 +90,17 @@ local function doDownload(job) if dir then love.filesystem.createDirectory(dir) end love.filesystem.remove(rel) - local ok, err = HostShell.httpDownload(job.url, abs, job.userAgent, - job.accept, tonumber(job.maxSeconds) or DOWNLOAD_MAX_SECONDS) + local etagAbs = job.etagRel and (saveDir .. "/" .. job.etagRel) or nil + local ok, err, notModified = HostShell.httpDownload(job.url, abs, job.userAgent, + job.accept, tonumber(job.maxSeconds) or DOWNLOAD_MAX_SECONDS, etagAbs) if not ok then post({ id = job.id, ok = false, err = err or "download failed" }) return end + if notModified then + post({ id = job.id, ok = true, path = rel, done = true, notModified = true }) + return + end local info = love.filesystem.getInfo(rel) if not info or (info.size or 0) == 0 then love.filesystem.remove(rel) diff --git a/src/render/GBCFX.lua b/src/render/GBCFX.lua deleted file mode 100644 index c2788a2a..00000000 --- a/src/render/GBCFX.lua +++ /dev/null @@ -1,310 +0,0 @@ --- GBC Effects post-process ("Pixel Transparency" style, see --- github.com/mattakins/Pixel_Transparency). A cumulative 4-level ladder --- applied after palette colorization and before the CRT pass: --- 1 reflective screen: bright pixels blend toward a procedurally --- grained warm backing (the unlit-GBC "transparent whites" look) --- 2 + LCD subpixel grid --- 3 + drop shadows (dark pixels float above the backing) --- 4 + sunlight: specular glare + rainbow QWP shimmer with a slowly --- drifting light source --- Levels OFF/1/2/3/4 persist as save.options.gbcfx; hotkey 5 cycles. --- Spec: docs/new-features.md (Custom Options / GBC FX) --- --- One shader for all levels: features are gated by the `level` uniform --- (float comparisons), so cycling never recompiles. All spatial effects --- key off `pixelScale` (screen pixels per GB pixel) so grid pitch, --- shadow offsets and grain stay window-size independent. - -local GBCFX = {} - -GBCFX.LABELS = { "OFF", "1", "2", "3", "4" } -GBCFX.level = 0 - -local shader -- false = unavailable (headless / no shader support) - --- Mobile GPUs often compile this pass but present a black frame, and the --- level persists in options.lua -- soft-bricking the APK until a manual --- edit (issue #136). Desktop is unchanged; Android/iOS refuse the effect. --- --- POKEPORT_GBCFX overrides the platform default either way ("1" force on, --- "0" force off), same tri-state as POKEPORT_TOUCH. Handheld packs whose --- GPU is in the same class as a phone's but whose getOS() says "Linux" set --- it to 0 in their launcher (build-rg34xxsp.sh); it also lets a desktop --- checkout exercise the unsupported path without stubbing love.system. -function GBCFX.isSupported() - local env = os.getenv("POKEPORT_GBCFX") - if env == "1" then return true end - if env == "0" then return false end - if not love or not love.system or not love.system.getOS then return true end - local osName = love.system.getOS() - return osName ~= "Android" and osName ~= "iOS" -end - --- GLSL 1.20-compatible (no array initializers; wavelength terms and the --- shadow blur are unrolled by hand). -local SHADER_SRC = [[ -extern number level; -extern number time; -extern number pixelScale; // screen pixels per GB pixel (integer fit scale) - -#define PI 3.14159265359 - -// ---- level thresholds (cumulative ladder) ---- -#define L_GRID 1.5 -#define L_SHADOW 2.5 -#define L_SUN 3.5 - -// ---- level 1: reflective backing ---- -#define BACK_BRIGHTNESS 0.48 -#define GRAIN_INTENSITY 0.065 -// #A6AC84 "Pocket" backing tint, normalized to unit mean brightness -#define POCKET_TINT vec3(1.0596, 1.0979, 0.8424) -#define BASE_ALPHA 0.20 -#define WHITE_EXTRA 0.75 -// front polarizer film tint -#define POLARIZER vec3(0.94, 1.0, 0.865) - -// ---- level 2: LCD grid (lcd1x style) ---- -#define BRIGHTEN_SCANLINES 16.0 -#define BRIGHTEN_LCD 4.0 - -// ---- level 3: drop shadow ---- -#define SHADOW_OFFSET 3.0 -#define SHADOW_OPACITY 0.5 - -// ---- level 4: sunlight ---- -#define GLARE_INTENSITY 0.15 -#define GLARE_SIGMA 0.25 -#define SHIMMER_INTENSITY 0.25 -// chroma amplification so the bands read on the already-desaturated, -// backing-blended image (reference applies 0.25 to raw film reflectance) -#define SHIMMER_CHROMA_GAIN 3.0 -#define SHIMMER_SPREAD 1.8 -#define LIGHT_RANGE 0.6 -#define FILM_NOISE_AMOUNT 0.5 -#define REFLECT_FLOOR 0.03 - -float hash21(vec2 p) -{ - return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); -} - -// smooth value noise, ~[0,1] -float vnoise(vec2 p) -{ - vec2 i = floor(p); - vec2 f = fract(p); - vec2 u = f * f * (3.0 - 2.0 * f); - float a = hash21(i); - float b = hash21(i + vec2(1.0, 0.0)); - float c = hash21(i + vec2(0.0, 1.0)); - float d = hash21(i + vec2(1.0, 1.0)); - return mix(mix(a, b, u.x), mix(c, d, u.x), u.y); -} - -float luma(vec3 c) -{ - return dot(c, vec3(0.2126, 0.7152, 0.0722)); -} - -vec4 effect(vec4 color, Image tex, vec2 tc, vec2 pc) -{ - vec4 src = Texel(tex, tc); - if (level < 0.5) { - return src * color; - } - - float ps = max(pixelScale, 1.0); - vec2 gbPix = pc / ps; // GB-pixel coordinates - vec2 texel = 1.0 / love_ScreenSize.xy; // one screen pixel in tc - vec2 gbTexel = texel * ps; // one GB pixel in tc - - // Drifting light position (normalized screen coords, upper area). - // Computed unconditionally: level 3 borrows it for shadow drift. - vec2 lightPos = vec2(0.5 + 0.35 * sin(time * 0.13), - 0.3 + 0.2 * sin(time * 0.07)); - - // ---- level 1: procedural backing material ---- - // flat gray + 3-octave paper grain, tinted warm - float grain = vnoise(gbPix * 0.9) * 0.5 - + vnoise(gbPix * 2.1 + vec2(17.0, 5.0)) * 0.3 - + vnoise(gbPix * 4.3 + vec2(3.0, 29.0)) * 0.2; - float backLum = BACK_BRIGHTNESS + (grain - 0.5) * (GRAIN_INTENSITY * 2.0); - vec3 back = backLum * POCKET_TINT; - - // ---- level 3: dark pixels cast a soft shadow onto the backing ---- - if (level >= L_SHADOW) { - vec2 shOff = vec2(SHADOW_OFFSET); - if (level >= L_SUN) { - // subtle drift opposite the light's wander - shOff += vec2((0.5 - lightPos.x) * 3.0, (0.3 - lightPos.y) * 3.0); - } - vec2 so = tc - shOff * gbTexel; - vec2 e = gbTexel; - // 9-tap gaussian blur of the offset sample's brightness (unrolled) - float s = 0.0; - s += luma(Texel(tex, so).rgb) * 4.0; - s += luma(Texel(tex, so + vec2( e.x, 0.0)).rgb) * 2.0; - s += luma(Texel(tex, so + vec2(-e.x, 0.0)).rgb) * 2.0; - s += luma(Texel(tex, so + vec2(0.0, e.y)).rgb) * 2.0; - s += luma(Texel(tex, so + vec2(0.0, -e.y)).rgb) * 2.0; - s += luma(Texel(tex, so + vec2( e.x, e.y)).rgb) * 1.0; - s += luma(Texel(tex, so + vec2( e.x, -e.y)).rgb) * 1.0; - s += luma(Texel(tex, so + vec2(-e.x, e.y)).rgb) * 1.0; - s += luma(Texel(tex, so + vec2(-e.x, -e.y)).rgb) * 1.0; - s /= 16.0; - float dark = 1.0 - s; - // deadzone: near-white pixels (dark ~ 0) cast no shadow at all - float shadow = dark * smoothstep(0.08, 0.30, dark) * SHADOW_OPACITY; - back = mix(back, back * 0.2, shadow); - } - - // ---- level 2: LCD subpixel grid on the lit image only ---- - vec3 lit = src.rgb; - if (level >= L_GRID) { - vec2 angle = 2.0 * PI * (gbPix - 0.25); - float yfac = (BRIGHTEN_SCANLINES + sin(angle.y)) - / (BRIGHTEN_SCANLINES + 1.0); - float xfac = (BRIGHTEN_LCD + sin(angle.x)) / (BRIGHTEN_LCD + 1.0); - lit *= yfac * xfac; - } - - // ---- level 1: brightness-proportional pixel transparency ---- - float lum = luma(src.rgb); - float a = BASE_ALPHA * lum; - // near-white pixels (luma > 0.90 AND min channel > 0.81) are nearly - // fully transparent -- narrow smoothsteps stand in for the hard AND - float mn = min(src.r, min(src.g, src.b)); - a += WHITE_EXTRA * smoothstep(0.88, 0.92, lum) * smoothstep(0.79, 0.83, mn); - vec3 col = mix(lit, back, clamp(a, 0.0, 1.0)); - - // ---- level 4: sunlight (glare + rainbow QWP shimmer) ---- - float glare = 0.0; - if (level >= L_SUN) { - float aspect = love_ScreenSize.x / love_ScreenSize.y; - vec2 p = vec2(tc.x * aspect, tc.y); - vec2 lp = vec2(lightPos.x * aspect, lightPos.y); - float d = distance(p, lp); - - // specular gaussian hotspot (added after the polarizer tint: - // it reflects off the front glass, not the LCD) - glare = GLARE_INTENSITY * exp(-d * d / (2.0 * GLARE_SIGMA * GLARE_SIGMA)); - - // quarter-wave-plate film: effective retardance (nm) grows with - // distance from the light point -> concentric interference bands; - // smooth "film thickness" noise makes the bands splotchy - float film = vnoise(gbPix * 0.06 + vec2(7.3, 2.9) + time * 0.01); - float gammaEff = (260.0 + 620.0 * SHIMMER_SPREAD * (d / LIGHT_RANGE)) - * (1.0 + FILM_NOISE_AMOUNT * (film - 0.5)); - float ph = 4.0 * PI * gammaEff; - - // 7 wavelength samples 400..700nm with approximate spectral RGB, - // unrolled (no const arrays in GLSL 1.20) - vec3 rb = vec3(0.0); - float cw; - cw = cos(ph / 400.0); rb += cw * cw * vec3(0.15, 0.00, 0.50); - cw = cos(ph / 450.0); rb += cw * cw * vec3(0.00, 0.10, 1.00); - cw = cos(ph / 500.0); rb += cw * cw * vec3(0.00, 0.80, 0.40); - cw = cos(ph / 550.0); rb += cw * cw * vec3(0.20, 1.00, 0.00); - cw = cos(ph / 600.0); rb += cw * cw * vec3(1.00, 0.60, 0.00); - cw = cos(ph / 650.0); rb += cw * cw * vec3(1.00, 0.10, 0.00); - cw = cos(ph / 700.0); rb += cw * cw * vec3(0.70, 0.00, 0.00); - rb /= vec3(3.05, 2.60, 1.90); // per-channel weight sums -> peak 1.0 - - // fade with distance from the light, kill on dark pixels, - // weight by pixel color squared - float att = 1.0 - smoothstep(0.0, LIGHT_RANGE, d); - float refl = max(lum, REFLECT_FLOOR); - // luminance-preserving tint: add only the chroma of the rainbow - vec3 shimmer = (rb - vec3(luma(rb))) * SHIMMER_CHROMA_GAIN - * src.rgb * src.rgb * refl * att; - col += shimmer * SHIMMER_INTENSITY; - } - - // front polarizer tint, then front-surface glare on top - col *= POLARIZER; - col += vec3(glare); - - return vec4(col, src.a) * color; -} -]] - -GBCFX.SHADER_SRC = SHADER_SRC -- exposed for the standalone compile check - -function GBCFX.shader() - if not GBCFX.isSupported() then return nil end - if shader == nil then - local ok, sh = pcall(love.graphics.newShader, SHADER_SRC) - shader = ok and sh or false - end - return shader or nil -end - -function GBCFX.setLevel(level) - if not GBCFX.isSupported() then - GBCFX.level = 0 - return - end - level = math.floor(tonumber(level) or 0) - if level < 0 then level = 0 end - if level > 4 then level = 4 end - GBCFX.level = level -end - --- Advance OFF → 1 → 2 → 3 → 4 → OFF. Returns the new level. -function GBCFX.cycle() - if not GBCFX.isSupported() then - GBCFX.level = 0 - return 0 - end - GBCFX.setLevel((GBCFX.level + 1) % 5) - return GBCFX.level -end - --- Apply opts.gbcfx. On unsupported platforms force OFF and clear a --- persisted non-zero value so boot recovers from a soft-brick. Returns --- true when opts was sanitized (caller should persist options.lua). -function GBCFX.applyOptions(opts) - if not GBCFX.isSupported() then - local had = opts and (tonumber(opts.gbcfx) or 0) ~= 0 - if opts then opts.gbcfx = 0 end - GBCFX.level = 0 - return had and true or false - end - GBCFX.setLevel(opts and opts.gbcfx or 0) - return false -end - -function GBCFX.levelLabel(level) - return GBCFX.LABELS[(level or GBCFX.level) + 1] or "OFF" -end - -function GBCFX.active() - return GBCFX.isSupported() and GBCFX.level > 0 and GBCFX.shader() ~= nil -end - --- Draw `canvas` fullscreen through the GBC FX shader into the current --- render target (or plain if the shader is unavailable). pixelScale is --- the integer screen-pixels-per-GB-pixel scale so grid/shadow offsets --- stay window-size independent. -function GBCFX.present(canvas, pixelScale) - local sh = GBCFX.shader() - if not sh or GBCFX.level <= 0 then - love.graphics.setColor(1, 1, 1, 1) - love.graphics.draw(canvas, 0, 0) - return - end - local t = 0 - if love.timer and love.timer.getTime then - t = love.timer.getTime() - end - sh:send("level", GBCFX.level) - sh:send("time", t) - sh:send("pixelScale", math.max(1, math.floor(tonumber(pixelScale) or 1))) - love.graphics.setShader(sh) - love.graphics.setColor(1, 1, 1, 1) - love.graphics.draw(canvas, 0, 0) - love.graphics.setShader() -end - -return GBCFX diff --git a/src/render/MonAnim.lua b/src/render/MonAnim.lua new file mode 100644 index 00000000..15c8b52e --- /dev/null +++ b/src/render/MonAnim.lua @@ -0,0 +1,164 @@ +-- ../pokecrystal/engine/gfx/pic_animation.asm:544 ConvertAndApplyBitmask +-- ../pokecrystal/engine/gfx/pic_animation.asm:356 PokeAnim_DoAnimScript + +local MonAnim = {} +MonAnim.__index = MonAnim + +-- .Sizes db 4, 5, 7: bitmask bytes for a 5x5, 6x6 and 7x7 pic. +-- ../pokecrystal/engine/gfx/pic_animation.asm:534 +MonAnim.BITMASK_BYTES = { [5] = 4, [6] = 5, [7] = 7 } + +-- ../pokecrystal/macros/scripts/pic_anims.asm:13-27 +MonAnim.END = 0xff +MonAnim.SETREPEAT = 0xfe +MonAnim.DOREPEAT = 0xfd + +-- .NextBit walks the row first, so bit i is row i % height, column i / height. +-- ../pokecrystal/engine/gfx/pic_animation.asm:746 +function MonAnim.tileMap(data, frame) + local tiles = data and data.tiles + if not tiles then return nil end + local count = tiles * tiles + local out = {} + for i = 1, count do out[i] = i - 1 end + if not frame or frame <= 0 then return out end + local row = data.frames and data.frames[frame] + local mask = row and data.bitmasks and data.bitmasks[row.bitmask] + if not (row and mask) then return nil end + local cursor = 1 + for i = 0, count - 1 do + local byte = mask[math.floor(i / 8) + 1] or 0 + if math.floor(byte / 2 ^ (i % 8)) % 2 == 1 then + local id = row.tiles[cursor] + if id == nil then return nil end + out[i + 1] = id + cursor = cursor + 1 + end + end + return out +end + +-- PokeAnim_GetDuration: a * (1 + [wPokeAnimSpeed] / 16), truncated to 8 bits. +-- ../pokecrystal/engine/gfx/pic_animation.asm:413 +function MonAnim.duration(param, speed) + param = param % 256 + local scaled = math.floor(param * (speed or 0) / 16) % 256 + return (scaled + param) % 256 +end + +-- The PokeAnims programs less their cry commands; "wait" is SetWait's 18. +-- ../pokecrystal/engine/gfx/pic_animation.asm:69-77, :150-164 +local SCENES = { + battle = { "setup", "play" }, + battleSlow = { "setup2", "play" }, + menu = { "setup", "play", "wait", "idle", "play" }, +} + +MonAnim.SCENE_WAIT = 18 + +function MonAnim.scenes() return SCENES end + +function MonAnim.new(data, scene) + local steps = SCENES[scene or "battle"] + if not (data and steps and data.play and #data.play > 0) then return nil end + return setmetatable({ + data = data, + steps = steps, + step = 1, + frame = 0, + speed = 0, + script = nil, + pc = 1, + repeatTimer = 0, + waiting = false, + waitCounter = 0, + sceneWait = nil, + done = false, + }, MonAnim) +end + +function MonAnim:finished() return self.done end + +-- PokeAnim_GetFrame's `and a / ret z`: command 0 is the base picture. +-- ../pokecrystal/engine/gfx/pic_animation.asm:431-435 +function MonAnim:currentFrame() return self.frame end + +function MonAnim:beginScript(rows, speed) + self.script = rows or {} + self.speed = speed + self.pc = 1 + self.repeatTimer = 0 + self.waiting = false + self.waitCounter = 0 +end + +-- One tick of PokeAnim_DoAnimScript; true once the script has hit endanim. +-- ../pokecrystal/engine/gfx/pic_animation.asm:370-411 +function MonAnim:runScript() + if self.waiting then + self.waitCounter = (self.waitCounter - 1) % 256 + if self.waitCounter == 0 then self.waiting = false end + return false + end + for _ = 1, 256 do + local row = self.script[self.pc] + self.pc = self.pc + 1 + if row == nil then return true end + local command = row[1] + if command == MonAnim.END then + return true + elseif command == MonAnim.SETREPEAT then + self.repeatTimer = row[2] + elseif command == MonAnim.DOREPEAT then + -- .DoRepeat returns on both `ret z` (:397-406). + if self.repeatTimer == 0 then return false end + self.repeatTimer = self.repeatTimer - 1 + if self.repeatTimer == 0 then return false end + self.pc = row[2] + 1 + else + self.frame = command + self.waiting = true + self.waitCounter = MonAnim.duration(row[2], self.speed) + -- StartWaitAnim falls through into .WaitAnim (:383-388), so the frame + -- it places is the frame the counter first ticks on. + self.waitCounter = (self.waitCounter - 1) % 256 + if self.waitCounter == 0 then self.waiting = false end + return false + end + end + return true +end + +-- One iteration of AnimateFrontpic's .loop: one scene command per frame. +-- ../pokecrystal/engine/gfx/pic_animation.asm:79-89 +function MonAnim:update() + if self.done then return end + local step = self.steps[self.step] + if step == nil then + -- PokeAnim_Finish's DeinitFrames puts the base picture back (:224-228). + self.frame = 0 + self.done = true + return + end + if step == "setup" or step == "setup2" or step == "idle" then + local rows = (step == "idle") and self.data.idle or self.data.play + self:beginScript(rows, step == "setup2" and 4 or 0) + self.step = self.step + 1 + return + end + if step == "wait" then + self.sceneWait = (self.sceneWait or MonAnim.SCENE_WAIT) - 1 + if self.sceneWait <= 0 then + self.sceneWait = nil + self.step = self.step + 1 + end + return + end + if self:runScript() then + -- PokeAnim_Play redraws the base picture as the script ends (:196-205). + self.frame = 0 + self.step = self.step + 1 + end +end + +return MonAnim diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index 08ddbe21..fa9250df 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -156,8 +156,8 @@ function Renderer:setWorldOverride(canvas) end -- Integer framebuffer pixels per GB pixel that fit the window. Zoom / --- GBCFX / callers treat this as the crisp scale; endFrame converts to LOVE --- units via / dpiX and / dpiY when drawing. +-- ShaderFX / callers treat this as the crisp scale; endFrame converts to +-- LOVE units via / dpiX and / dpiY when drawing. function Renderer:fitScale() local _, _, pw, ph = displayMetrics() local w, h = self:uiSize() @@ -596,7 +596,7 @@ function Renderer:drawTiltedWorld(zoneList, sx, sy, wox, woy, target, local zoneShader = zoneList and zoneList[1] and PaletteFX.shader() or nil if zoneShader then love.graphics.setShader(zoneShader) - -- same trueColor sentinel the flat blit honors (14 §trueColor) + -- same trueColor sentinel the flat blit below honors local bare = false for _, z in ipairs(zoneList) do local plain = z.colors == false @@ -787,8 +787,8 @@ end -- visible map area separately), applied to the world pass; the world -- pass falls back to the UI zones when absent. Each zone is drawn -- scissored through the shade-remap shader, later zones on top. --- When GBC FX is active the composite is drawn into presentCanvas and --- presented through the GBC FX shader as a final pass. +-- When ShaderFX is active the composite is drawn into presentCanvas and +-- presented through the shader chain as a final pass. function Renderer:frameRects() local ww, wh, pw, ph, dpiX, dpiY, vx, vy, cut = displayMetrics() local r = { @@ -852,7 +852,11 @@ function Renderer:endFrame(zones, worldZones) local vpw, vph, ox, oy = R.vpw, R.vph, R.ox, R.oy local Ux, Uy = R.Ux, R.Uy local uvpw, uvph, uox, uoy = R.uvpw, R.uvph, R.uox, R.uoy - local GBCFX = require("src.render.GBCFX") + local Up = R.Up + -- Physical-pixel numerators for ShaderFX's per-frame rect; derived from + -- the unit rects frameRects already lifted so both agree. + local uoxPx, uoyPx = uox * dpiX, uoy * dpiY + local ShaderFX = require("src.render.ShaderFX") -- Forced mono/Classic modes still need a whole-screen zone when a state -- exposes no SGB packets (raw DMG canvas), so sendColors can remap. zones = PaletteFX.ensureZones(zones) @@ -895,12 +899,12 @@ function Renderer:endFrame(zones, worldZones) end end - -- Post-process pipelines, GBC FX and an enabled final-output owner need the + -- Post-process pipelines, ShaderFX and an enabled final-output owner need the -- whole composite in a canvas. With none of them, the frame draws straight -- to the screen exactly as it always did. local hasOutputHook = Runtime.wantsHook("render.output") and Runtime.call("render.output_enabled", function() return false end) == true - local needPresent = GBCFX.active() or Pipelines.wantsPresent() or hasOutputHook + local needPresent = ShaderFX.active() or Pipelines.wantsPresent() or hasOutputHook local present = nil if needPresent then if not self.presentCanvas or self.presentCanvas:getWidth() ~= ww @@ -1005,6 +1009,17 @@ function Renderer:endFrame(zones, worldZones) return Renderer.clipToView(R, x, y, w, h) end + -- Real per-frame ShaderFX game rect + source content size, in PHYSICAL + -- framebuffer pixels -- what ShaderFX.render below actually draws through + -- the chain, instead of it reconstructing a fixed 160x144-at-base-Sp + -- approximation of its own. Defaults to this frame's real UI rect, which + -- is already correct whenever neither branch below overrides it + -- (title/menu/credits, no world active) -- uiFill is already folded into + -- Up above, so that case needs no extra handling here. + local fxRectPxX, fxRectPxY, fxRectPxW, fxRectPxH, fxScale = + uoxPx, uoyPx, uiw * Up, uih * Up, Up + local fxSrcW, fxSrcH = uiw, uih + if self.worldOverride then -- A render pipeline already produced the whole world -- terrain, -- characters and its own FX overlay -- as one window-resolution image, @@ -1012,6 +1027,13 @@ function Renderer:endFrame(zones, worldZones) -- skipped entirely (nothing drew into it). The UI blit below still -- runs, so dialogs, menus and the HUD sit on top as usual. love.graphics.setColor(1, 1, 1, 1) + -- worldOverride is already a window-resolution image drawn 1:1 at the + -- origin -- ShaderFX's real rect degenerates to the whole image, no + -- crop needed (source size == rect size). + fxRectPxX, fxRectPxY = 0, 0 + fxRectPxW, fxRectPxH = self.worldOverride:getPixelWidth(), self.worldOverride:getPixelHeight() + fxScale = 1 + fxSrcW, fxSrcH = fxRectPxW, fxRectPxH love.graphics.setScissor(vux, vuy, vuw, vuh) local loveMajor = love.getVersion() if love.system and love.system.getOS and love.system.getOS() == "iOS" and loveMajor >= 12 then @@ -1032,8 +1054,20 @@ function Renderer:endFrame(zones, worldZones) local sx, sy = sp / dpiX, sp / dpiY local wvw = self.worldCanvas:getWidth() local wvh = self.worldCanvas:getHeight() - local wox = (vx + math.floor((pw - wvw * sp) / 2)) / dpiX - local woy = (vy + math.floor((ph - wvh * sp) / 2) - R.lift) / dpiY + local woxPx = vx + math.floor((pw - wvw * sp) / 2) + local woyPx = vy + math.floor((ph - wvh * sp) / 2) - R.lift + local wox, woy = woxPx / dpiX, woyPx / dpiY + -- The real on-screen world rect at the CURRENT survey zoom -- can be + -- larger (zoomed out, more map revealed) or smaller than the UI's own + -- default rect above. ShaderFX.render below now shades this real rect + -- against this real wvw x wvh source, not a fixed 160x144 box, so a + -- grid/LCD-style effect's own math lines up with true on-screen pixels + -- at any zoom level. Covers both the flat blit below and the + -- Tilt-projected blit -- both share this wox/woy/sp/wvw/wvh. + fxRectPxX, fxRectPxY = woxPx, woyPx + fxRectPxW, fxRectPxH = wvw * sp, wvh * sp + fxScale = sp + fxSrcW, fxSrcH = wvw, wvh -- Tilt mode projects the ground world pass through the perspective mesh -- (SGB zones baked in beforehand -- see drawTiltedWorld -- so no zone -- scissoring here). drawTiltedWorld returns false when tilt is off or @@ -1245,9 +1279,9 @@ function Renderer:endFrame(zones, worldZones) if present then GameViewport.setTarget() -- Post-process pipelines run over the finished composite -- world, UI - -- and all -- and before GBC FX, so a blur or colour grade is what the - -- LCD grid is then drawn over rather than something that smears the - -- grid itself. Each pass hands back a canvas; with none registered + -- and all -- and before ShaderFX, so a blur or colour grade is what a + -- shader preset is then drawn over rather than something that smears + -- it. Each pass hands back a canvas; with none registered -- this returns `present` unchanged and the frame is byte-identical. local composed = Pipelines.present(present, { width = ww, height = wh, scale = Sp, dpi = dpiY, dpiX = dpiX, dpiY = dpiY }) or present @@ -1262,9 +1296,19 @@ function Renderer:endFrame(zones, worldZones) }) == true if not outputHandled then if cut then love.graphics.setScissor(vux, vuy, vuw, vuh) end - if GBCFX.active() then - -- shader grid/shadow math is in framebuffer pixels - GBCFX.present(composed, Sp) + if ShaderFX.active() then + -- The ShaderFX feature replaced GBCFX.lua's fixed level ladder + -- with a preset picker (GBCFX.lua itself removed). fxRectPx*/fxScale/fxSrc* + -- are this frame's REAL game rect + source size, set above by + -- whichever branch actually ran (worldOverride, worldActive at the + -- current survey zoom, or the UI-rect default for no-world/uiFill + -- states) -- not a fixed 160x144-at-base-Sp reconstruction, so the + -- chain sees true on-screen pixel geometry at any zoom/Faithful + -- Ratio state. + ShaderFX.render(composed, + { x = fxRectPxX, y = fxRectPxY, w = fxRectPxW, h = fxRectPxH, scale = fxScale }, + { w = fxSrcW, h = fxSrcH }, + dpiX, dpiY) else -- the present canvas only existed for the post-process, so put the -- result on the screen at the same 1:1 unit mapping it was built at diff --git a/src/render/ShaderFX.lua b/src/render/ShaderFX.lua new file mode 100644 index 00000000..9d41b833 --- /dev/null +++ b/src/render/ShaderFX.lua @@ -0,0 +1,1053 @@ +-- ShaderFX: runtime libretro slang-shader presets. Translated ahead of time +-- into a cached Lua artifact, then run as a LOVE canvas pass-graph over the +-- finished frame. Two independent slots, "main" and "secondary", chain in +-- that order. See docs/shaderfx.md. + +local ShaderFX = {} + +local Json = require("src.link.Json") +local SaveData = require("src.core.SaveData") +local Performance = require("src.core.Performance") + +-- ------- preset discovery: the local drop-in folder + +-- A real OS path, not a love.filesystem mount: the native bridge and the LUT +-- loads both read it directly. +function ShaderFX.presetDir() + local base = (SaveData.portableBaseDir and SaveData.portableBaseDir()) + or (love.filesystem and love.filesystem.getSaveDirectory and love.filesystem.getSaveDirectory()) + if not base then return nil end + local sep = package.config:sub(1, 1) + return base .. sep .. "shaders" +end + +function ShaderFX.list() + local dir = ShaderFX.presetDir() + if not dir then return {} end + love.filesystem.createDirectory("shaders") + local out = {} + local function scan(relPath) + local items = love.filesystem.getDirectoryItems(relPath) + for _, name in ipairs(items) do + local rel = relPath .. "/" .. name + local info = love.filesystem.getInfo(rel) + if info and info.type == "directory" then + scan(rel) + elseif name:match("%.slangp$") then + local entry = { name = name, relPath = rel, fullPath = dir .. rel:gsub("^shaders", "") } + entry.converted = ShaderFX.isConverted(entry) + out[#out + 1] = entry + end + end + end + local ok, err = pcall(scan, "shaders") + if not ok then + require("src.core.Logger").error("ShaderFX.list: scan failed: %s", tostring(err)) + return {} + end + table.sort(out, function(a, b) return a.name < b.name end) + return out +end + +function ShaderFX.artifactPath(entry) + return (entry.fullPath:gsub("%.slangp$", ".lua")) +end + +-- A real OS path outside any love.filesystem mount, so io.open, not getInfo. +function ShaderFX.isConverted(entry) + local f = io.open(ShaderFX.artifactPath(entry), "rb") + if not f then return false end + f:close() + return true +end + +-- ------- buildbot download: the second acquisition path, alongside the +-- local drop-in folder above. Downloaded presets arrive unconverted. +ShaderFX.BUILDBOT_URL = "https://buildbot.libretro.com/assets/frontend/shaders_slang.zip" + +local DOWNLOAD_ZIP_REL = "shaderfx_buildbot.zip" +local DOWNLOAD_MOUNT = "shaderfx_buildbot_mount" +-- The buildbot's own ETag, replayed as If-None-Match so a repeat download +-- that changed nothing costs one small request instead of the full transfer. +local DOWNLOAD_ETAG_REL = "shaderfx_buildbot.etag" + +-- Returns a Fetch job id; poll it with ShaderFX.downloadStatus(). A status of +-- "ok" with .notModified true means nothing was re-downloaded. +function ShaderFX.downloadPresets() + return require("src.net.Fetch").download(ShaderFX.BUILDBOT_URL, DOWNLOAD_ZIP_REL, + { etagRel = DOWNLOAD_ETAG_REL }) +end + +function ShaderFX.downloadStatus(jobId) + return require("src.net.Fetch").poll(jobId) +end + +-- Collapses "." and ".." segments; Lua has no stdlib equivalent. +local function normalizePath(path) + local parts = {} + for part in path:gmatch("[^/]+") do + if part == ".." then + if #parts > 0 and parts[#parts] ~= ".." then + parts[#parts] = nil + else + parts[#parts + 1] = ".." + end + elseif part ~= "." and part ~= "" then + parts[#parts + 1] = part + end + end + return table.concat(parts, "/") +end + +local function dirname(path) + return path:match("^(.*)/[^/]+$") or "" +end + +-- .png (a LUT) is a real dependency but never itself a source of references. +local TEXT_EXT = { slang = true, slangp = true, inc = true, h = true } +local REF_EXT = { slang = true, slangp = true, inc = true, h = true, png = true } + +-- Quoted assignments are matched first: the closing quote is an unambiguous +-- delimiter, so a path with spaces or parens survives (real packs ship them). +local function extractRefs(text) + local refs, seen = {}, {} + local function addPathRef(path) + local ext = path:match("%.(%a+)$") + if ext and REF_EXT[ext:lower()] and not seen[path] then + seen[path] = true + refs[#refs + 1] = path + end + end + for line in text:gmatch("[^\r\n]+") do + local quoted = line:match('=%s*"([^"]+)"') + if quoted then + addPathRef(quoted) + else + local bare = line:match('=%s*([%.%w/_%-]+)') + if bare then addPathRef(bare) end + end + end + for path in text:gmatch('#include%s+"([^"]+)"') do + if not seen[path] then seen[path] = true; refs[#refs + 1] = path end + end + for path in text:gmatch('#reference%s+"([^"]+)"') do + if not seen[path] then seen[path] = true; refs[#refs + 1] = path end + end + return refs +end + +local function findAll(dir, pattern) + local out = {} + local function scan(rel) + for _, name in ipairs(love.filesystem.getDirectoryItems(rel)) do + local sub = rel .. "/" .. name + local info = love.filesystem.getInfo(sub) + if info and info.type == "directory" then + scan(sub) + elseif name:match(pattern) then + out[#out + 1] = sub + end + end + end + scan(dir) + return out +end + +-- Curated shortlist of handheld/'s own 78 presets -- a temporary trim, expect +-- this list to change (see docs/shaderfx.md). +local KEPT_PRESETS = { + ["gameboy.slangp"] = true, + ["gameboy-advance-dot-matrix.slangp"] = true, + ["gameboy-color-dot-matrix.slangp"] = true, + ["gameboy-color-dot-matrix-white-bg.slangp"] = true, + ["gameboy-dark-mode.slangp"] = true, + ["gameboy-light.slangp"] = true, + ["gameboy-light-mode.slangp"] = true, + ["gameboy-pocket.slangp"] = true, + ["gameboy-pocket-high-contrast.slangp"] = true, + ["gb-palette-dmg.slangp"] = true, + ["gb-palette-light.slangp"] = true, + ["gb-palette-pocket.slangp"] = true, + ["sameboy-lcd.slangp"] = true, + ["dot.slangp"] = true, + ["lcd1x.slangp"] = true, + ["lcd1x_nds.slangp"] = true, + ["lcd1x_psp.slangp"] = true, + ["lcd3x.slangp"] = true, + ["simpletex_lcd.slangp"] = true, + ["simpletex_lcd_720p.slangp"] = true, + ["simpletex_lcd-4k.slangp"] = true, + ["zfast-lcd.slangp"] = true, + ["bevel.slangp"] = true, + ["sunlight_shimmer.slangp"] = true, + ["pixel_transparency.slangp"] = true, + ["retro-v3.slangp"] = true, +} + +-- BFS over the real file-level dependency closure of every KEPT_PRESETS entry +-- inside the mounted zip, copied preserving each file's zip-relative path. +local function extractClosure(mountRoot) + local queue = {} + for _, s in ipairs(findAll(mountRoot .. "/handheld", "%.slangp$")) do + if KEPT_PRESETS[s:match("([^/]+)$")] then queue[#queue + 1] = s end + end + local closure = {} + for _, s in ipairs(queue) do closure[s] = true end + + local head = 1 + while queue[head] do + local rel = queue[head] + head = head + 1 + local zipRel = rel:sub(#mountRoot + 2) -- strip "/" + local ext = zipRel:match("%.(%a+)$") + if ext and TEXT_EXT[ext:lower()] then + local text = love.filesystem.read(rel) + if text then + local base = dirname(zipRel) + for _, ref in ipairs(extractRefs(text)) do + local resolved = normalizePath(base == "" and ref or (base .. "/" .. ref)) + local mounted = mountRoot .. "/" .. resolved + if not closure[mounted] and love.filesystem.getInfo(mounted) then + closure[mounted] = true + queue[#queue + 1] = mounted + end + end + end + end + end + + -- love.filesystem.write does not create intermediate directories. + local madeDirs = {} + local function ensureDir(destPath) + local dir = dirname(destPath) + if dir == "" or madeDirs[dir] then return end + madeDirs[dir] = true + love.filesystem.createDirectory(dir) + end + + local copied = 0 + for rel in pairs(closure) do + local destPath = "shaders/" .. rel:sub(#mountRoot + 2) + local bytes = love.filesystem.read(rel) + if bytes then + ensureDir(destPath) + if love.filesystem.write(destPath, bytes) then copied = copied + 1 end + end + end + return copied +end + +-- Returns the number of files copied, or nil + an error string; a third +-- `unchanged` return means the ETag matched and no zip was ever written. +function ShaderFX.installDownloaded(notModified) + if notModified then + return 0, nil, true + end + love.filesystem.createDirectory("shaders") + if not love.filesystem.mount(DOWNLOAD_ZIP_REL, DOWNLOAD_MOUNT) then + return nil, "could not open the downloaded archive" + end + local ok, copiedOrErr = pcall(extractClosure, DOWNLOAD_MOUNT) + -- unmount() takes the ARCHIVE path passed to mount(), not the mountpoint. + love.filesystem.unmount(DOWNLOAD_ZIP_REL) + love.filesystem.remove(DOWNLOAD_ZIP_REL) + if not ok then return nil, tostring(copiedOrErr) end + return copiedOrErr +end + +-- ------- the native bridge (translation only, called by convert()) + +-- Where the vendored Rust bridge crate builds to, relative to the source dir. +ShaderFX.BRIDGE_DIR = "tools/shaderfx-bridge" + +-- Candidate names per OS, same shape as src/net/Gen1Tls.lua's libNames. +local function libNames() + local osName = (love and love.system and love.system.getOS + and love.system.getOS()) or "" + if osName == "Windows" then + return { "librashader_bridge.dll" } + elseif osName == "OS X" then + return { "liblibrashader_bridge.dylib", "librashader_bridge.dylib" } + end + -- Android resolves the bare name through jniLibs, so try it last. + return { "liblibrashader_bridge.so", "librashader_bridge.so" } +end + +-- getSource is the game directory (where the vendored crate sits); the base +-- directory is its parent (where a packaged build puts the library). +local function sourceDirs() + local out, seen = {}, {} + local function add(dir) + if type(dir) == "string" and dir ~= "" and not seen[dir] then + seen[dir] = true + out[#out + 1] = dir + end + end + local fs = love and love.filesystem + if fs then + if fs.getSource then add(fs.getSource()) end + if fs.getSourceBaseDirectory then add(fs.getSourceBaseDirectory()) end + if fs.getWorkingDirectory then add(fs.getWorkingDirectory()) end + end + if type(arg) == "table" and type(arg[0]) == "string" then + add(arg[0]:match("^(.*)[/\\]")) + end + add(".") + return out +end + +local function saveDir() + if love and love.filesystem and love.filesystem.getSaveDirectory then + local dir = love.filesystem.getSaveDirectory() + if type(dir) == "string" and dir ~= "" then return dir end + end + return nil +end + +local function fileReadable(path) + local f = io.open(path, "rb") + if not f then return false end + f:close() + return true +end + +-- Every place the bridge may sit, most specific first. +local function libCandidates() + local out = {} + local override = os.getenv("LIBRASHADER_BRIDGE_DLL") + if override and override ~= "" then out[#out + 1] = override end + local dirs, save = sourceDirs(), saveDir() + for _, name in ipairs(libNames()) do + for _, dir in ipairs(dirs) do + out[#out + 1] = dir .. "/" .. name + out[#out + 1] = dir .. "/" .. ShaderFX.BRIDGE_DIR .. "/target/release/" .. name + end + if save then out[#out + 1] = save .. "/" .. name end + out[#out + 1] = name + end + return out +end + +local lib +local libError + +local function ensureLib() + if lib then return lib end + if libError then return nil, libError end + local okFfi, ffi = pcall(require, "ffi") + if not okFfi or type(ffi) ~= "table" then + libError = "this build has no ffi, so presets cannot be converted here" + return nil, libError + end + pcall(ffi.cdef, [[ + char* librashader_translate_preset(const char* preset_path, int es); + void librashader_free_string(char* s); + ]]) + local tried = {} + for _, path in ipairs(libCandidates()) do + -- A bare name goes to the system loader; a path only when a file is there. + local bare = not path:find("[/\\]") + if bare or fileReadable(path) then + local ok, loaded = pcall(ffi.load, path) + if ok then lib = loaded; return lib end + end + tried[#tried + 1] = path + end + libError = "librashader bridge not found; looked in " .. table.concat(tried, ", ") + return nil, libError +end + +-- True when a preset can be converted on this machine. Activating an +-- already-converted preset never needs the bridge. +function ShaderFX.canConvert() + local l = ensureLib() + return l ~= nil +end + +function ShaderFX.bridgeError() + return libError +end + +-- `es`: true for GLSL ES 1.00 (mobile), false for GLSL 1.20 (desktop). Only +-- ever called from ShaderFX.convert(). +function ShaderFX.translate(fullPath, es) + local ffi = require("ffi") + local ok, l = pcall(ensureLib) + if not ok then return nil, "ffi.load failed: " .. tostring(l) end + local ptr = l.librashader_translate_preset(fullPath, es and 1 or 0) + if ptr == nil then return nil, "librashader_translate_preset returned NULL" end + local json = ffi.string(ptr) + l.librashader_free_string(ptr) + local preset, decodeErr = Json.decode(json) + if not preset then return nil, "JSON decode failed: " .. tostring(decodeErr) end + if preset.error then return nil, "translate_preset reported an error: " .. tostring(preset.error) end + if preset.pass_count ~= #preset.passes then + return nil, ("pass_count metadata (%s) disagrees with passes array length (%d)") + :format(tostring(preset.pass_count), #preset.passes) + end + return preset +end + +-- love.graphics.newImage cannot open an absolute path outside LOVE's own +-- mounts, which is where a preset's LUTs live, so read the bytes by hand. +function ShaderFX.loadImageFromPath(path) + local f, err = io.open(path, "rb") + if not f then return nil, "io.open failed: " .. tostring(err) end + local bytes = f:read("*a") + f:close() + local byteData = love.data.newByteData(bytes) + local imgData = love.image.newImageData(byteData) + return love.graphics.newImage(imgData) +end + +-- librashader's wrap_mode names are not LOVE's; ES3-legal set only. +local function loveWrapMode(w) + if w == "clamp_to_border" then return "clampzero" + elseif w == "clamp_to_edge" then return "clamp" + elseif w == "repeat" then return "repeat" + elseif w == "mirrored_repeat" then return "mirroredrepeat" + else error("loveWrapMode: unknown wrap_mode " .. tostring(w)) end +end + +-- Called once per activate(). A LUT that fails to load is logged and left nil; +-- runPass's own sampler assert is the fail-loud point, not this. +local function loadLuts(preset) + local byName = {} + for _, t in ipairs(preset.textures) do + local img, err = ShaderFX.loadImageFromPath(t.path) + if img then + img:setFilter(t.filter_mode, t.filter_mode) + img:setWrap(loveWrapMode(t.wrap_mode)) + else + require("src.core.Logger").error("ShaderFX: LUT %s (%s) failed to load: %s", + t.name, tostring(t.path), tostring(err)) + end + byName[t.name] = img + end + return byName +end + +-- ------- the pass-graph runtime + +local Fixup = require("src.render.ShaderFixup") +local Sensors = require("src.core.Sensors") +local ShaderSourcePatches = require("src.render.ShaderSourcePatches") + +-- Glsl100Es (mobile) vs Glsl120 (desktop). Convert and render always run on +-- the same device, so the dialect emitted and the one validated always agree. +local function defaultEs() + if not love or not love.system or not love.system.getOS then return false end + local osName = love.system.getOS() + return osName == "Android" or osName == "iOS" +end + +local function roundDim(x) return math.max(1, math.floor(x + 0.5)) end + +local function resolveScale(scaling, viewportDim, inputDim, originalDim) + local st = scaling.scale_type + if st == "absolute" then return roundDim(scaling.factor) + elseif st == "viewport" then return roundDim(viewportDim * scaling.factor) + elseif st == "source" then return roundDim(inputDim * scaling.factor) + elseif st == "original" then return roundDim(originalDim * scaling.factor) + else error("resolveScale: unknown scale_type " .. tostring(st)) end +end + +local function sizeVec(dims) return { dims.w, dims.h, 1 / dims.w, 1 / dims.h } end + +-- One instance per loaded preset, never module-global, so switching presets +-- cannot leak a previous preset's canvases or dims. +local function newChainState(preset) + local state = { preset = preset, outputDims = {}, ALL_DEFAULTS = {}, + shaderCache = {}, canvasCache = {} } + for _, pass in ipairs(preset.passes) do + for _, p in ipairs(pass.parameters) do state.ALL_DEFAULTS[p.id] = p.initial end + end + for _, o in ipairs(preset.parameter_overrides) do state.ALL_DEFAULTS[o.name] = o.value end + return state +end + +local function passInputDims(state, i, original) + return (i == 0) and original or assert(state.outputDims[i - 1], + ("pass%d: input dims requested before pass%d ran"):format(i, i - 1)) +end + +local function computePassDims(state, i, pass, viewport, original) + local inputDims = passInputDims(state, i, original) + return { + w = resolveScale(pass.scale_x, viewport.w, inputDims.w, original.w), + h = resolveScale(pass.scale_y, viewport.h, inputDims.h, original.h), + } +end + +-- love.sensor.SensorType each built-in motion semantic reads from. +-- AccelerometerRest is librashader's calibration reference, not a real kind. +local SENSOR_KIND = { Accelerometer = "accelerometer", Gyroscope = "gyroscope" } + +-- Yaw integration: deliberately a decaying spring, not a true heading -- +-- gyro-only integration drifts without a magnetometer. Tuned on-device. +local YAW_SHADER_NAME = "sunlight_shimmer.slangp" -- the only twist-reactive preset +local YAW_GAIN = 0.6 +local YAW_TWIST_CLAMP = 2 +local YAW_DECAY_PER_SEC = 2.0 -- higher = snaps back to neutral faster + +-- Throttled on-device diagnostic (~1/sec per slot), tagged for logcat. +local debugLastLog = {} +local function debugThrottle(key) + local now = love.timer and love.timer.getTime() or os.clock() + if not debugLastLog[key] or now - debugLastLog[key] >= 1.0 then + debugLastLog[key] = now + return true + end + return false +end + +local function updateYawTwist(state, dt) + if not state or state.entryName ~= YAW_SHADER_NAME then return end + local gx, yawRate, gz = Sensors.read("gyroscope") + local twist = (state.yawTwist or 0) + yawRate * dt * YAW_GAIN + twist = twist * math.exp(-YAW_DECAY_PER_SEC * dt) + twist = math.max(-YAW_TWIST_CLAMP, math.min(YAW_TWIST_CLAMP, twist)) + state.yawTwist = twist + if debugThrottle("yaw:" .. tostring(state.entryName)) then + require("src.core.Logger").info( + "[DEBUG-tfxsns2] yaw entry=%s gyro=(%.3f,%.3f,%.3f) dt=%.4f twist=%.4f", + tostring(state.entryName), gx, yawRate, gz, dt, twist) + end +end + +local function sizeTable(state, i, dims, sizeUniforms, viewport, original) + local values = { + OutputSize = sizeVec(dims), + SourceSize = sizeVec(passInputDims(state, i, original)), + OriginalSize = sizeVec(original), + } + for _, entry in ipairs(sizeUniforms) do + if entry.kind == "unique" and entry.semantic == "Output" then + values[entry.name] = sizeVec(dims) + elseif entry.kind == "unique" and SENSOR_KIND[entry.semantic] then + local x, y, z = Sensors.read(SENSOR_KIND[entry.semantic]) + local dbgRawX, dbgRawY, dbgRawZ = x, y, z + if entry.semantic == "Accelerometer" then + -- Calibrate against the pose captured at activate(), then swap gravity + -- onto the axis getOrientedTilt ignores (this engine rests upright). + if state.accelRest then + x = x - state.accelRest[1] + y = y - state.accelRest[2] + z = z - state.accelRest[3] + end + y, z = z, y + -- Keep getOrientedTilt's normalize denominator stable, but only against + -- a live reading: an all-zero raw read is Sensors.lua's no-hardware sentinel. + if state.accelRest and (dbgRawX ~= 0 or dbgRawY ~= 0 or dbgRawZ ~= 0) then + z = 9.8 + end + if state.entryName == YAW_SHADER_NAME and state.yawTwist then + -- The only already-compiled channel this preset's stock math reads. + x = x + state.yawTwist + end + end + values[entry.name] = { x, y, z } + if entry.semantic == "Accelerometer" then + -- Test seam: the values that actually reached the packed uniform. + state.lastAccelPacked = { x, y, z } + if debugThrottle("accel:" .. tostring(state.entryName)) then + local d = state.ALL_DEFAULTS or {} + require("src.core.Logger").info( + "[DEBUG-tfxsns2] accel entry=%s raw=(%.3f,%.3f,%.3f) packed=(%.3f,%.3f,%.3f) mag=%.3f twist=%s " .. + "ACCEL_ENABLE=%s SHADOW_MOTION=%s YAW_ENABLE=%s SENSITIVITY=%s", + tostring(state.entryName), dbgRawX, dbgRawY, dbgRawZ, x, y, z, + math.sqrt(x * x + y * y + z * z), tostring(state.yawTwist), + tostring(d.PT_ACCEL_ENABLE), tostring(d.PT_SHADOW_MOTION), + tostring(d.PT_YAW_ENABLE), tostring(d.PT_ACCEL_SENSITIVITY)) + end + end + elseif entry.kind == "unique" and entry.semantic == "AccelerometerRest" then + values[entry.name] = { 0, 0, 0 } + elseif entry.kind == "unique" and entry.semantic == "Rotation" then + -- retroarch_get_rotation(): the CONTENT's rotation, not the device's. + -- Nothing here rotates GB content, so 0 is correct, not a placeholder. + values[entry.name] = 0 + elseif entry.kind == "texture" then + if entry.semantic == "Original" then + values[entry.name] = sizeVec(original) + elseif entry.semantic == "Source" then + values[entry.name] = sizeVec(passInputDims(state, i, original)) + elseif entry.semantic == "OriginalHistory" then + -- Steady-state fallback: every history slot reads the current frame. + values[entry.name] = sizeVec(original) + elseif entry.semantic == "PassOutput" then + values[entry.name] = sizeVec(assert(state.outputDims[entry.index], + ("pass%d: %s references pass%d's output, not yet computed") + :format(i, entry.name, entry.index))) + elseif entry.semantic == "PassFeedback" or entry.semantic == "User" then + error(("pass%d: %s is a %s size uniform -- not yet supported") + :format(i, entry.name, entry.semantic)) + end + end + end + return values +end + +-- Runs pass `i`, drawing `srcImg` into a freshly sized canvas. `lutByName` +-- resolves a User-semantic sampler by the name the translation reported. +local function runPass(state, i, pass, outputs, frameSource, lutByName, viewport, original) + local dims = computePassDims(state, i, pass, viewport, original) + state.outputDims[i] = dims + + -- Compiled once per (state, pass): a pass's GLSL and manifest depend only on + -- the preset, never on per-frame input. fragManifest is cached alongside it. + local cached = state.shaderCache[i] + local shader, fragManifest + if cached then + shader, fragManifest = cached.shader, cached.fragManifest + else + local fixedFragBody + fixedFragBody, fragManifest = Fixup.fragment(pass.fragment) + local fixedVert, vertManifest = Fixup.vertex(pass.vertex) + assert(#fragManifest == #vertManifest, ("pass%d: PUSH struct member count differs"):format(i)) + + local validateErrs = {} + local isEs = defaultEs() + for headIdx, head in ipairs(Fixup.PREC_HEADS) do + local fixedFrag = head .. fixedFragBody + local okShader, err = love.graphics.validateShader(isEs, fixedFrag, fixedVert) + if okShader then + shader = love.graphics.newShader(fixedFrag, fixedVert) + break + else + validateErrs[#validateErrs + 1] = ("variant %d: %s"):format(headIdx, tostring(err)) + end + end + if not shader then + require("src.core.Logger").error("ShaderFX: pass%d shader failed to validate -- %s", + i, table.concat(validateErrs, " | ")) + end + assert(shader, ("pass%d: no PREC_HEADS variant validated (%s)") + :format(i, table.concat(validateErrs, " | "))) + state.shaderCache[i] = { shader = shader, fragManifest = fragManifest } + end + + for _, s in ipairs(pass.samplers) do + local img + if s.semantic == "Source" then img = (i == 0) and frameSource or outputs[i - 1] + elseif s.semantic == "Original" then img = frameSource + elseif s.semantic == "OriginalHistory" then img = frameSource + elseif s.semantic == "PassOutput" then img = outputs[s.index] + elseif s.semantic == "User" then img = lutByName and lutByName[s.user_name] end + assert(img, ("pass%d: no binding resolved for sampler %s (semantic=%s)") + :format(i, s.name, tostring(s.semantic))) + pcall(shader.send, shader, "LIBRA_TEXTURE_" .. s.name, img) + end + + local values = sizeTable(state, i, dims, pass.size_uniforms, viewport, original) + for name, value in pairs(state.ALL_DEFAULTS) do values[name] = value end + local packed = Fixup.packValues(fragManifest, values) + for name, value in pairs(packed) do pcall(shader.send, shader, name, value) end + + local srcImg = (i == 0) and frameSource or outputs[i - 1] + local srcDims = passInputDims(state, i, original) + local drawScaleX, drawScaleY = dims.w / srcDims.w, dims.h / srcDims.h + + -- Reallocated only when this pass's resolved size actually changes. + local cc = state.canvasCache[i] + local canvas + if cc and cc.w == dims.w and cc.h == dims.h then + canvas = cc.canvas + else + canvas = love.graphics.newCanvas(dims.w, dims.h) + canvas:setFilter("nearest", "nearest") + state.canvasCache[i] = { canvas = canvas, w = dims.w, h = dims.h } + end + + love.graphics.push("all") + love.graphics.setCanvas(canvas) + love.graphics.clear(0, 0, 0, 0) + love.graphics.setShader(shader) + if i == state.preset.pass_count - 1 then + love.graphics.setBlendMode("replace", "premultiplied") + else + love.graphics.setBlendMode("replace") + end + love.graphics.draw(srcImg, 0, 0, 0, drawScaleX, drawScaleY) + love.graphics.pop() + + outputs[i] = canvas +end + +-- Runs the whole chain once over `frameSource` (sized `original`), with +-- `viewport` the size "viewport" scale_type passes resolve against. +function ShaderFX.runChain(state, frameSource, lutByName, viewport, original) + local outputs = {} + state.outputDims = {} + for i = 0, state.preset.pass_count - 1 do + runPass(state, i, state.preset.passes[i + 1], outputs, frameSource, lutByName, viewport, original) + end + return outputs[state.preset.pass_count - 1] +end + +-- ------- public, minimal load/render entry points + +-- Loads one entry from its cached Lua artifact; never calls the bridge. +-- Returns a chain state, or nil + an error when it has not been converted. +function ShaderFX.load(entry) + local path = ShaderFX.artifactPath(entry) + local chunk, err = loadfile(path) + if not chunk then + return nil, "no cached artifact, convert this preset first: " .. tostring(err) + end + local ok, preset = pcall(chunk) + if not ok then return nil, "cached artifact failed to run: " .. tostring(preset) end + if type(preset) ~= "table" then return nil, "cached artifact did not return a table" end + local state = newChainState(preset) + state.entryName = entry.name -- see updateYawTwist/sizeTable's Accelerometer branch + return state +end + +-- Every #pragma parameter the entry's artifact declares, deduped by id, each +-- with its currently-effective default. Reads the artifact only. +function ShaderFX.listParams(entry) + local state, err = ShaderFX.load(entry) + if not state then return nil, err end + local order, byId = {}, {} + for _, pass in ipairs(state.preset.passes) do + for _, p in ipairs(pass.parameters) do + if not byId[p.id] then + byId[p.id] = p + order[#order + 1] = p.id + end + end + end + local out = {} + for _, id in ipairs(order) do + local p = byId[id] + -- "!"-prefixed descriptions are engine-owned, not a player preference, so + -- they never become an editable row. No preset uses this today. + if not (p.description and p.description:sub(1, 1) == "!") then + out[#out + 1] = { + id = p.id, description = p.description, + minimum = p.minimum, maximum = p.maximum, step = p.step, + initial = state.ALL_DEFAULTS[p.id], + } + end + end + return out +end + +-- ------- active-preset tracking and the real endFrame render entry point + +-- ------- AOT convert: the one function that calls the bridge, writing its +-- decoded result to disk as a plain Lua artifact. + +-- #t counts a trailing nil as absent, so walk every key instead. +local function arrayLen(t) + local n = 0 + for _ in pairs(t) do n = n + 1 end + for i = 1, n do + if t[i] == nil then return nil end + end + return n +end + +-- Serializes the string/number/boolean/nested-table shape Json.decode +-- produces; array-shaped tables (arrayLen) serialize positionally. +local function serializeLua(v, buf) + local t = type(v) + if t == "string" then + buf[#buf + 1] = string.format("%q", v) + elseif t == "number" or t == "boolean" then + buf[#buf + 1] = tostring(v) + elseif t == "table" then + buf[#buf + 1] = "{" + local n = arrayLen(v) + if n then + for i = 1, n do + serializeLua(v[i], buf) + buf[#buf + 1] = "," + end + else + for k, item in pairs(v) do + assert(type(k) == "string", "serializeLua: non-string, non-array-index table key") + buf[#buf + 1] = "[" .. string.format("%q", k) .. "]=" + serializeLua(item, buf) + buf[#buf + 1] = "," + end + end + buf[#buf + 1] = "}" + else + error("serializeLua: unsupported value type " .. t) + end +end + +-- Translates `entry` via the bridge and writes ShaderFX.artifactPath(entry). +-- Sets entry.converted on success; an existing artifact survives a failure. +function ShaderFX.convert(entry, es) + ShaderSourcePatches.apply(entry) + local preset, err = ShaderFX.translate(entry.fullPath, es == nil and defaultEs() or es) + if not preset then return false, err end + local buf = {} + serializeLua(preset, buf) + local path = ShaderFX.artifactPath(entry) + local f, ferr = io.open(path, "wb") + if not f then return false, "io.open failed: " .. tostring(ferr) end + f:write("return ") + f:write(table.concat(buf)) + f:write("\n") + f:close() + entry.converted = true + return true +end + +-- Two independent slots. "shaderfx" stays main's option key, so existing +-- saves keep meaning what they already meant. +ShaderFX.SLOTS = { "main", "secondary" } +ShaderFX.OPTION_KEY = { main = "shaderfx", secondary = "shaderfxSecondary" } + +local slots = { main = {}, secondary = {} } -- slots[s] = { state=, entry= } +local autoActivateTried = false -- see applyOptions()/tryAutoActivateFromEnv() below + +-- Loads `entry`'s cached artifact into `slot` and makes it that slot's active +-- preset. `paramOverrides` layers a player's pragma edits over ALL_DEFAULTS. +function ShaderFX.activate(slot, entry, paramOverrides) + assert(slots[slot], "ShaderFX.activate: unknown slot " .. tostring(slot)) + local state, err = ShaderFX.load(entry) + if not state then return false, err end + if paramOverrides then + for id, value in pairs(paramOverrides) do state.ALL_DEFAULTS[id] = value end + end + state.luts = loadLuts(state.preset) + -- Snapshot whatever pose the player is actually holding right now as neutral, + -- subtracted from every later reading. Recalibrates on every activate(). + state.accelRest = { Sensors.read("accelerometer") } + require("src.core.Logger").info( + "[DEBUG-tfxsns2] activate entry=%s accelRest=(%.3f,%.3f,%.3f)", + tostring(entry and entry.name), state.accelRest[1], state.accelRest[2], state.accelRest[3]) + slots[slot].state, slots[slot].entry = state, entry + return true +end + +-- `slot` nil deactivates both. +function ShaderFX.deactivate(slot) + if slot then + slots[slot].state, slots[slot].entry = nil, nil + else + for _, s in pairs(slots) do s.state, s.entry = nil, nil end + end +end + +-- `slot` nil answers "is anything active". +function ShaderFX.active(slot) + if slot then return slots[slot].state ~= nil end + return slots.main.state ~= nil or slots.secondary.state ~= nil +end + +function ShaderFX.activeEntry(slot) + return slots[slot] and slots[slot].entry +end + +-- Finds a ShaderFX.list() entry by its `name` field, or nil (e.g. the preset +-- was deleted from the drop-in folder since it was selected). +function ShaderFX.findEntry(name) + if not name or name == "" then return nil end + for _, entry in ipairs(ShaderFX.list()) do + if entry.name == name then return entry end + end + return nil +end + +-- Under AUTO, a tier that caps shaderfx off would force-deactivate a persisted +-- preset choice every boot; pin "high" instead. Never touches an explicit +-- performance choice, and never un-escalates on deactivate. +local function escalatePerformanceIfNeeded(opts) + if not opts then return false end + local wantsShaderfx = false + for _, slot in ipairs(ShaderFX.SLOTS) do + local want = opts[ShaderFX.OPTION_KEY[slot]] + if want and want ~= "" then wantsShaderfx = true end + end + if not wantsShaderfx then return false end + if opts.performance ~= nil and opts.performance ~= "auto" then return false end + local Performance = require("src.core.Performance") + if Performance.caps("auto").shaderfx then return false end + opts.performance = "high" + return true +end + +function ShaderFX.applyOptions(opts) + autoActivateTried = true + local cleared = false + for _, slot in ipairs(ShaderFX.SLOTS) do + local key = ShaderFX.OPTION_KEY[slot] + local want = opts and opts[key] + if not want or want == "" then + ShaderFX.deactivate(slot) + else + local entry = ShaderFX.findEntry(want) + if not entry then + if opts then opts[key] = nil end + ShaderFX.deactivate(slot) + cleared = true + else + -- isConverted() is existence-only with no staleness check, so a saved + -- choice reconverts here too -- at boot/options-save, never per frame. + local convOk, convErr = ShaderFX.convert(entry) + if not convOk then + require("src.core.Logger").error("ShaderFX.applyOptions: reconvert failed for %s (%s): %s", + want, slot, tostring(convErr)) + end + local paramOverrides = opts.shaderfxParams and opts.shaderfxParams[entry.name] + local ok, err = ShaderFX.activate(slot, entry, paramOverrides) + if not ok then + require("src.core.Logger").error("ShaderFX.applyOptions: %s (%s) failed to activate: %s", + want, slot, tostring(err)) + if opts then opts[key] = nil end + ShaderFX.deactivate(slot) + cleared = true + end + end + end + end + if escalatePerformanceIfNeeded(opts) then cleared = true end + return cleared +end + +-- Stand-in from before the real OPTIONS row existed; only still fires for +-- harnesses that call ShaderFX.render() without ever calling applyOptions(). +local function tryAutoActivateFromEnv() + autoActivateTried = true + local want = os.getenv("POKEPORT_SHADERFX") + if not want or want == "" then return end + local entry = ShaderFX.findEntry(want) + if not entry then + require("src.core.Logger").error("ShaderFX: POKEPORT_SHADERFX=%s not found under %s", + want, tostring(ShaderFX.presetDir())) + return + end + local ok, err = ShaderFX.activate("main", entry) + if not ok then + require("src.core.Logger").error("ShaderFX: POKEPORT_SHADERFX=%s failed to activate: %s", + want, tostring(err)) + end +end + +-- Crops the playfield rect out of `canvas` at `renderScale`. The output canvas +-- is cached module-wide and reallocated only on a real size change. +local cropCanvasCache +local function cropToGbSource(canvas, rect, srcW, srcH, renderScale) + -- Cheap insurance against a read-after-write hazard between this draw and + -- whatever last rendered into `canvas`. + love.graphics.flushBatch() + local outW, outH = roundDim(srcW * renderScale), roundDim(srcH * renderScale) + local quad = love.graphics.newQuad(rect.x, rect.y, rect.w, rect.h, + canvas:getPixelWidth(), canvas:getPixelHeight()) + local out + if cropCanvasCache and cropCanvasCache.w == outW and cropCanvasCache.h == outH then + out = cropCanvasCache.canvas + else + out = love.graphics.newCanvas(outW, outH) + out:setFilter("nearest", "nearest") + cropCanvasCache = { canvas = out, w = outW, h = outH } + end + local invScale = renderScale / rect.scale + love.graphics.push("all") + love.graphics.setCanvas(out) + love.graphics.clear(0, 0, 0, 0) + love.graphics.setBlendMode("replace") + -- push("all") saves the caller's draw color but does not reset it: a menu can + -- leave it black, which would multiply the whole crop to black. + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(canvas, quad, 0, 0, 0, invScale, invScale) + love.graphics.pop() + return out +end + +-- The tier's SHADER FX chain-resolution multiplier; 1.0 when the tier's cap is +-- not a number. +local function chainRenderScale() + local caps = Performance.CAPS[Performance.tier] + return (caps and tonumber(caps.shaderfx)) or 1.0 +end + +-- Renderer.lua's endFrame entry point. `canvas` is the finished window-size +-- composite; `rect` is this frame's real game rect in PHYSICAL framebuffer +-- pixels and `source` the content size it frames. See docs/shaderfx.md. +function ShaderFX.render(canvas, rect, source, dpiX, dpiY) + if not autoActivateTried then tryAutoActivateFromEnv() end + if not slots.main.state and not slots.secondary.state then + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(canvas, 0, 0) + return + end + + dpiX, dpiY = tonumber(dpiX) or 1, tonumber(dpiY) or 1 + -- Same physical location as `rect`, in the logical units LOVE draws in. + local uRect = { + x = math.floor(rect.x / dpiX), y = math.floor(rect.y / dpiY), + w = math.floor(rect.w / dpiX), h = math.floor(rect.h / dpiY), + } + -- Runs the chain at a reduced internal resolution on tiers that ask for one. + local scale = chainRenderScale() + local viewport = { w = roundDim(uRect.w * scale), h = roundDim(uRect.h * scale) } + + -- exposed for tests only (tests/drivers/gold_shaderfx_zoom_sizing_test.lua) + ShaderFX._lastRect, ShaderFX._lastSource = rect, source + + local dt = love.timer and love.timer.getDelta() or 0 + updateYawTwist(slots.main.state, dt) + updateYawTwist(slots.secondary.state, dt) + -- Test seam: the live per-frame integration, before it rides into the shader. + ShaderFX._lastYawTwist = { + main = slots.main.state and slots.main.state.yawTwist, + secondary = slots.secondary.state and slots.secondary.state.yawTwist, + } + + local ok, chainOut, chainPreset = pcall(function() + local frameSource = cropToGbSource(canvas, rect, source.w, source.h, scale) + -- exposed for tests only (gold_shaderfx_menu_black_crop_test.lua) + ShaderFX._lastCrop = frameSource + local dims = { w = frameSource:getWidth(), h = frameSource:getHeight() } + local img, preset = frameSource, nil + if slots.main.state then + img = ShaderFX.runChain(slots.main.state, img, slots.main.state.luts, viewport, dims) + dims = { w = img:getWidth(), h = img:getHeight() } + preset = slots.main.state.preset + end + if slots.secondary.state then + img = ShaderFX.runChain(slots.secondary.state, img, slots.secondary.state.luts, viewport, dims) + dims = { w = img:getWidth(), h = img:getHeight() } + preset = slots.secondary.state.preset + end + return img, preset + end) + + if not ok or not chainOut then + require("src.core.Logger").error( + "ShaderFX.render chain failed ok=%s chainOut=%s err=%s", + tostring(ok), tostring(chainOut), tostring(not ok and chainOut or nil)) + end + + love.graphics.setColor(1, 1, 1, 1) + love.graphics.setBlendMode("replace") + love.graphics.draw(canvas, 0, 0) + if ok and chainOut then + -- A chain's final pass need not land on the viewport size (most presets end + -- at scale_type="source"), so stretch, using that pass's own filter. + local cw, ch = chainOut:getWidth(), chainOut:getHeight() + local lastPass = chainPreset.passes[chainPreset.pass_count] + local mode = (lastPass.filter == "linear") and "linear" or "nearest" + chainOut:setFilter(mode, mode) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.setBlendMode("alpha") + love.graphics.draw(chainOut, uRect.x, uRect.y, 0, uRect.w / cw, uRect.h / ch) + elseif not ok then + require("src.core.Logger").error("ShaderFX.render: chain failed, showing unprocessed frame: %s", + tostring(chainOut)) + end + love.graphics.setBlendMode("alpha") + -- Test seam; set only after the real chain ran. + ShaderFX._lastAccelPacked = { + main = slots.main.state and slots.main.state.lastAccelPacked, + secondary = slots.secondary.state and slots.secondary.state.lastAccelPacked, + } +end + +return ShaderFX diff --git a/src/render/ShaderFixup.lua b/src/render/ShaderFixup.lua new file mode 100644 index 00000000..ff0293db --- /dev/null +++ b/src/render/ShaderFixup.lua @@ -0,0 +1,295 @@ +-- Mechanical GLSL rewrites over librashader's emitted output: it emits a +-- standalone void main()/gl_FragData[0]/gl_Position shape, LOVE requires the +-- effect()/position() convention. Not a GLSL parser -- every rule here exists +-- because a real preset failed without it. See docs/shaderfx.md. +local Fixup = {} + +local function stripVersion(src) + return (src:gsub("#version%s+%d+%s*\n", "")) +end + +-- ES 1.00 has no array-constructor syntax at all; SPIRV-Cross emits it anyway. +-- Splitting needs paren-depth awareness, since an element can be vec2(a, b). +local function splitTopLevelCommas(s) + local parts, depth, start = {}, 0, 1 + for i = 1, #s do + local c = s:sub(i, i) + if c == "(" then + depth = depth + 1 + elseif c == ")" then + depth = depth - 1 + elseif c == "," and depth == 0 then + parts[#parts + 1] = s:sub(start, i - 1) + start = i + 1 + end + end + parts[#parts + 1] = s:sub(start) + return parts +end + +local function elementAssignLines(name, elemsStr) + local lines = {} + for i, elem in ipairs(splitTopLevelCommas(elemsStr)) do + lines[#lines + 1] = ("%s[%d] = %s;"):format(name, i - 1, elem:match("^%s*(.-)%s*$")) + end + return lines +end + +local function rewriteArrayLiterals(src) + local injections = {} + -- const, global scope: relocate the per-element assignments into main(). + src = src:gsub( + "const%s+([%w_]+)%s+([%w_]+)%s*%[%s*(%d+)%s*%]%s*=%s*[%w_]+%s*%[%s*%]%s*(%b())%s*;", + function(typ, name, size, parenElems) + for _, line in ipairs(elementAssignLines(name, parenElems:sub(2, -2))) do + injections[#injections + 1] = " " .. line + end + return ("%s %s[%s];"):format(typ, name, size) + end) + if #injections > 0 then + src = src:gsub("(void%s+main%s*%(%s*%)%s*{)", + "%1\n" .. table.concat(injections, "\n"), 1) + end + -- non-const, local declaration+initializer: rewrite in place. + src = src:gsub( + "([%w_]+)%s+([%w_]+)%s*%[%s*(%d+)%s*%]%s*=%s*[%w_]+%s*%[%s*%]%s*(%b())%s*;", + function(typ, name, size, parenElems) + local lines = elementAssignLines(name, parenElems:sub(2, -2)) + return ("%s %s[%s];\n "):format(typ, name, size) .. table.concat(lines, "\n ") + end) + -- bare reassignment of an already-declared array: rewrite in place. + src = src:gsub( + "([%w_]+)%s*=%s*[%w_]+%s*%[%s*%]%s*(%b())%s*;", + function(name, parenElems) + return table.concat(elementAssignLines(name, parenElems:sub(2, -2)), "\n ") + end) + -- array-to-array copy (`float param_1[7] = coeffs;`): ES 1.00 has no + -- whole-array assignment either, so copy element by element. + src = src:gsub( + "([%w_]+)%s+([%w_]+)%s*%[%s*(%d+)%s*%]%s*=%s*([%w_]+)%s*;", + function(typ, name, size, srcName) + local lines = {} + for i = 0, tonumber(size) - 1 do + lines[#lines + 1] = ("%s[%d] = %s[%d];"):format(name, i, srcName, i) + end + return ("%s %s[%s];\n "):format(typ, name, size) .. table.concat(lines, "\n ") + end) + return src +end +Fixup.rewriteArrayLiterals = rewriteArrayLiterals + +-- ES 1.00 has no `%` operator. `%` is only ever defined for integer operands, +-- so float mod() is exact here; balanced-paren operand shapes are tried first. +local function rewriteIntegerModulo(src) + local function convert(l, r) + return ("int(mod(float(%s), float(%s)))"):format(l, r) + end + src = src:gsub("(%b())%s*%%%s*(%b())", convert) + src = src:gsub("(%b())%s*%%%s*([%w_]+)", convert) + src = src:gsub("([%w_]+)%s*%%%s*(%b())", convert) + src = src:gsub("([%w_]+)%s*%%%s*([%w_]+)", convert) + return src +end +Fixup.rewriteIntegerModulo = rewriteIntegerModulo + +-- SPIRV-Cross hardcodes an unguarded `precision highp` pair with no toggle; +-- claim highp only where the driver actually offers fragment highp. +local function guardPrecision(src) + src = src:gsub("precision%s+highp%s+float%s*;%s*\nprecision%s+highp%s+int%s*;%s*\n", "") + local guard = [[ +#ifdef GL_ES +#ifdef GL_FRAGMENT_PRECISION_HIGH +precision highp float; +precision highp int; +#endif +#endif +]] + return guard .. src +end + +-- LOVE's Shader:send cannot address a member of a struct-typed uniform, so the +-- struct is deleted and its members re-declared: scalars packed 4-at-a-time +-- into vec4 slots (ES 1.00 guarantees only 16 fragment uniform vectors), +-- vectors kept whole. `specialMembers` substitutes a member to fixed text +-- instead of declaring it at all (see UBO_SPECIAL_MEMBERS). +local SCALAR_TYPES = { float = true, int = true, bool = true } +local COMPONENTS = { "x", "y", "z", "w" } + +local function flattenStruct(src, structName, packedPrefix, specialMembers) + packedPrefix = packedPrefix or "LIBRA_PACKED_" + specialMembers = specialMembers or {} + local body = src:match("struct%s+" .. structName .. "%s*{(.-)}%s*;") + if not body then return src, {} end + local instance = src:match("uniform%s+" .. structName .. "%s+([%w_]+)%s*;") + assert(instance, "flattenStruct: found struct " .. structName .. " but no instance uniform") + + src = src:gsub("struct%s+" .. structName .. "%s*{.-}%s*;%s*\n", "") + src = src:gsub("uniform%s+" .. structName .. "%s+" .. instance .. "%s*;%s*\n", "") + + local members = {} + for typ, name in body:gmatch("([%w_]+)%s+([%w_]+)%s*;") do + members[#members + 1] = { type = typ, name = name } + end + -- Declaration order matters here: packing scalars 4-at-a-time only minimizes + -- group count against the struct's own vec4-then-scalars layout. + + local decls, manifest, specialSubs = {}, {}, {} + local scalarGroup, packIndex = {}, 0 + + local function flushGroup() + if #scalarGroup == 0 then return end + local uniformName = packedPrefix .. packIndex + decls[#decls + 1] = ("uniform vec4 %s;"):format(uniformName) + for i, m in ipairs(scalarGroup) do + manifest[#manifest + 1] = { name = m.name, uniform = uniformName, component = COMPONENTS[i], type = m.type } + end + packIndex, scalarGroup = packIndex + 1, {} + end + + for _, m in ipairs(members) do + if specialMembers[m.name] then + specialSubs[#specialSubs + 1] = { name = m.name, replacement = specialMembers[m.name] } + elseif SCALAR_TYPES[m.type] then + scalarGroup[#scalarGroup + 1] = m + if #scalarGroup == 4 then flushGroup() end + else + flushGroup() + decls[#decls + 1] = ("uniform %s %s;"):format(m.type, m.name) + manifest[#manifest + 1] = { name = m.name, uniform = m.name } + end + end + flushGroup() + + -- longest-name-first so a substitution can't land inside a longer member name + -- sharing a prefix; a separate order from the packing above. A packed slot + -- only stores floats, so an int/bool member needs a cast back on every read. + local subs = {} + for _, entry in ipairs(manifest) do + local replacement = entry.component and (entry.uniform .. "." .. entry.component) or entry.uniform + if entry.component and (entry.type == "int" or entry.type == "bool") then + replacement = ("%s(%s)"):format(entry.type, replacement) + end + subs[#subs + 1] = { name = entry.name, replacement = replacement } + end + for _, entry in ipairs(specialSubs) do subs[#subs + 1] = entry end + table.sort(subs, function(a, b) return #a.name > #b.name end) + for _, entry in ipairs(subs) do + src = src:gsub(instance .. "%." .. entry.name, entry.replacement) + end + + return table.concat(decls, "\n") .. "\n" .. src, manifest +end +Fixup.flattenStruct = flattenStruct + +-- Turns a flat {member = value} table into the {uniform = value_or_vec4} shape +-- the packed shader expects. A missing value packs as 0 in its component. +function Fixup.packValues(manifest, values) + local packed = {} + local slot = { x = 1, y = 2, z = 3, w = 4 } + for _, entry in ipairs(manifest) do + if entry.component then + packed[entry.uniform] = packed[entry.uniform] or { 0, 0, 0, 0 } + packed[entry.uniform][slot[entry.component]] = values[entry.name] or 0 + else + packed[entry.uniform] = values[entry.name] + end + end + return packed +end + +-- GLSL ES 1.00 guarantees only 16 fragment uniform *vectors* and every scalar +-- costs a whole one; samplers do not come out of that budget. +Fixup.SLOTS = { float = 1, int = 1, bool = 1, vec2 = 1, vec3 = 1, vec4 = 1, mat3 = 3, mat4 = 4 } + +function Fixup.countUniformSlots(src) + -- Lua's %w does NOT match underscore, unlike regex \w, and every name here is + -- full of them, so this must be [%w_]+ or whole declarations go uncounted. + local total = 0 + for typ in src:gmatch("uniform%s+([%w_]+)%s+[%w_]+%s*;") do + if typ ~= "sampler2D" and typ ~= "Image" then + total = total + (Fixup.SLOTS[typ] or 1) + end + end + return total +end + +-- LIBRA_UBO_* (the uniform-buffer block) has the same +-- LOVE-can't-address-a-struct-member problem as LIBRA_PUSH_*, so it is +-- flattened the same way, with MVP alone special-cased. Deleting the block +-- outright instead left every other member reference dangling. + +-- LOVE forward-declares effect()'s prototype under its own header's precision +-- default, which can mismatch guardPrecision's; pin the parameter list to a +-- #define so the caller can try each variant in order. +Fixup.PREC_HEADS = { "#define EFFECT_PREC mediump\n", "#define EFFECT_PREC\n" } + +-- MVP is meaningless in the fragment stage but gets declared anyway; special- +-- casing it keeps a dead field from becoming a uniform nothing would send. +local UBO_SPECIAL_MEMBERS = { MVP = "transform_projection" } + +function Fixup.fragment(src) + src = stripVersion(src) + src = rewriteArrayLiterals(src) + src = rewriteIntegerModulo(src) + local pushManifest, uboManifest + src, pushManifest = flattenStruct(src, "LIBRA_PUSH_FRAGMENT") + src, uboManifest = flattenStruct(src, "LIBRA_UBO_FRAGMENT", "LIBRA_UBO_PACKED_", UBO_SPECIAL_MEMBERS) + local manifest = pushManifest + for _, entry in ipairs(uboManifest) do manifest[#manifest + 1] = entry end + src = guardPrecision(src) + src = src:gsub("void%s+main%s*%(%s*%)", + "vec4 effect(EFFECT_PREC vec4 love_UnusedColor, Image love_UnusedTex, " + .. "EFFECT_PREC vec2 love_UnusedTc, EFFECT_PREC vec2 love_UnusedSc)") + -- gl_FragData isn't available in LOVE's effect() convention. Rewrite EVERY + -- occurrence, whatever operator follows: real presets (ds-hybrid-sabr) write + -- it once with `=` and later accumulate with `+=`. + src = src:gsub("gl_FragData%[0%]", "gbFragColor") + src = src:gsub( + "(vec4 effect%(EFFECT_PREC vec4 love_UnusedColor, Image love_UnusedTex, " + .. "EFFECT_PREC vec2 love_UnusedTc, EFFECT_PREC vec2 love_UnusedSc%)%s*\n{)", + "%1\n vec4 gbFragColor;") + -- a bare early-exit `return;` needs a value now -- gbFragColor already + -- holds whatever was assigned just before it on every real shape seen. + src = src:gsub("return%s*;", "return gbFragColor;") + local lastBrace = src:match("()}%s*$") + assert(lastBrace, "fragment fixup: could not find effect()'s closing brace") + src = src:sub(1, lastBrace - 1) .. " return gbFragColor;\n" .. src:sub(lastBrace) + return src, manifest +end + +function Fixup.vertex(src) + src = stripVersion(src) + src = rewriteArrayLiterals(src) + src = rewriteIntegerModulo(src) + local pushManifest, uboManifest + src, pushManifest = flattenStruct(src, "LIBRA_PUSH_VERTEX") + -- MVP means "multiply the incoming vertex by it", which on an ordinary + -- full-screen LOVE draw is transform_projection, so it substitutes rather + -- than being packed. A distinct prefix keeps the UBO's packed groups off + -- LIBRA_PUSH_*'s own numbering. + src, uboManifest = flattenStruct(src, "LIBRA_UBO_VERTEX", "LIBRA_UBO_PACKED_", UBO_SPECIAL_MEMBERS) + local manifest = pushManifest + for _, entry in ipairs(uboManifest) do manifest[#manifest + 1] = entry end + -- LOVE supplies VertexPosition/VertexTexCoord itself. + src = src:gsub("attribute%s+vec4%s+Position%s*;%s*\n", "") + src = src:gsub("attribute%s+vec2%s+TexCoord%s*;%s*\n", "") + src = src:gsub("void%s+main%s*%(%s*%)", + "vec4 position(mat4 transform_projection, vec4 vertex_position)") + -- gl_Position isn't available in LOVE's position() convention; carry it in a + -- local named to share no substring with "Position". + src = src:gsub("gl_Position%s*=%s*([^;]+);", "gbClipPos = %1;") + -- Whole-identifier frontier match, not a blind gsub: a preset's own local + -- `vec2 vTexCoord;` would otherwise be mangled (dot.slangp pass0). + src = src:gsub("%f[%w]Position%f[%W]", "vertex_position") + src = src:gsub("%f[%w]TexCoord%f[%W]", "VertexTexCoord%.xy") + src = src:gsub( + "(vec4 position%(mat4 transform_projection, vec4 vertex_position%)%s*\n{)", + "%1\n vec4 gbClipPos;") + -- append the return just before the function's final closing brace + local lastBrace = src:match("()}%s*$") + assert(lastBrace, "vertex fixup: could not find main()'s closing brace") + src = src:sub(1, lastBrace - 1) .. " return gbClipPos;\n" .. src:sub(lastBrace) + return src, manifest +end + +return Fixup diff --git a/src/render/ShaderSourcePatches.lua b/src/render/ShaderSourcePatches.lua new file mode 100644 index 00000000..e5b10337 --- /dev/null +++ b/src/render/ShaderSourcePatches.lua @@ -0,0 +1,67 @@ +-- Local find/replace patches applied to a preset's raw .slang/.inc source on +-- disk, before ShaderFX.convert() hands it to the bridge -- only librashader's +-- own parser can discover a new #pragma parameter, so nothing downstream can +-- add one. Ships with an empty table on purpose; see docs/shaderfx.md. + +local Logger = require("src.core.Logger") + +local M = {} + +-- entry.name -> { { relPath = , +-- patches = { { old = , new = }, ... } } } +M.PATCHES = {} + +local function dirname(path) + return path:match("^(.*)[/\\][^/\\]*$") or "." +end + +-- Plain (non-pattern) substring replace: GLSL source is full of Lua pattern +-- magic characters, so gsub on literal source text is not safe here. +local function replaceOnce(text, old, new) + local s, e = text:find(old, 1, true) + if not s then return text, false end + return text:sub(1, s - 1) .. new .. text:sub(e + 1), true +end + +-- Applies every registered patch for `entry` to the real files on disk. A patch +-- whose anchor is gone (already applied, or upstream changed) is skipped. +function M.apply(entry) + local list = M.PATCHES[entry.name] + if not list then return end + local base = dirname(entry.fullPath) + for _, file in ipairs(list) do + local path = base .. "/" .. file.relPath + local f = io.open(path, "rb") + if not f then + Logger.warn("ShaderSourcePatches: %s: cannot open %s", entry.name, path) + else + local text = f:read("*a") + f:close() + local changed = false + for _, p in ipairs(file.patches) do + local already = text:find(p.new, 1, true) ~= nil + if not already then + local newText, ok = replaceOnce(text, p.old, p.new) + if ok then + text = newText + changed = true + else + Logger.warn("ShaderSourcePatches: %s: patch anchor not found in %s (upstream changed?)", + entry.name, file.relPath) + end + end + end + if changed then + local wf, werr = io.open(path, "wb") + if not wf then + Logger.warn("ShaderSourcePatches: %s: cannot write %s: %s", entry.name, path, tostring(werr)) + else + wf:write(text) + wf:close() + end + end + end + end +end + +return M diff --git a/src/render/Zoom.lua b/src/render/Zoom.lua index 00484251..a448870f 100644 --- a/src/render/Zoom.lua +++ b/src/render/Zoom.lua @@ -17,6 +17,25 @@ Zoom.offset = 0 -- close-up. Nil/true keeps the historical full range. Zoom.allowSurvey = true +-- The deepest legal survey step (offset = 1-S, effective scale s' = S+lo = +-- 1 px/world px) plus an active SHADER FX chain crashes the app on real +-- hardware (Pixel 10 Pro XL, "OUT7" == this exact step at that device's own +-- fit scale of 8 -- reported by the user, 2026-08-21). A quantifying repro +-- (TrueFX/zoom-mem-scan/) ruled out ShaderFX's own per-pass canvases as the +-- driver (worst real-corpus case at that step: ~31MB, not crash-sized) -- +-- the actual cost is almost certainly the world canvas/tile-render path +-- itself at that resolution (a preexisting cost of deep survey zoom, +-- unrelated to ShaderFX), which a shader chain reading that same +-- full-resolution canvas as its own input then pushes over some real +-- device/driver ceiling this project cannot measure without the device +-- in hand. Removing just the single deepest step while a preset is active +-- is the smallest change that directly targets what was actually reported +-- ("OUT7 + any shader", not "OUT7 alone" and not "OUT6 or shallower") -- +-- not a guessed-at general zoom restriction. Needs the user's own on-device +-- confirmation; extend the margin (currently 1 step) if OUT6 turns out to +-- be risky too. +local ShaderFX + -- legal offset range for a given fit scale (vanilla: survey at 1 px/world -- through 2× fit). zoom.range may widen or shrink the window. -- When the window only fits 1×, 1-S is 0 and there would be no OUT @@ -37,6 +56,14 @@ function Zoom.offsetRange(S) elseif lo > -3 then lo = -3 end + -- SHADER FX + the single deepest survey step: see the comment above. + -- The floor is one step above the deepest this call would otherwise + -- allow, so the three-step minimum above still keeps a zoom-out. + ShaderFX = ShaderFX or require("src.render.ShaderFX") + if ShaderFX.active() then + local floor = math.min(2 - S, lo + 1) + if lo < floor then lo = floor end + end return lo, hi end diff --git a/src/save_convert/SaveConvert.lua b/src/save_convert/SaveConvert.lua index e022faba..e9d11bd0 100644 --- a/src/save_convert/SaveConvert.lua +++ b/src/save_convert/SaveConvert.lua @@ -21,6 +21,7 @@ -- require alone cannot see them there (#420). local GenSave = require("src.save_convert.GenSave") +local GameVersion = require("src.core.GameVersion") local SaveConvert = {} @@ -192,7 +193,7 @@ local function defaultsSave() battleLayout = "og", ruleset = "gen1_faithful", musicVol = 7, sfxVol = 7, pikaVol = 7, musicFilter = 0, - speed = 1, colors = "gbc", tilt = 0, gbcfx = 0, + speed = 1, colors = "gbc", tilt = 0, videoMode = "windowed", mods = {}, }, } @@ -228,10 +229,11 @@ SaveConvert.mergeDefaults = mergeDefaults -- codec exists yet. Both directions answer with a plain message the -- launcher's save card renders as-is, instead of pushing a Gen 2 save -- table through Gen 1 offsets and surfacing a codec traceback. -local GEN2_SAV_UNSUPPORTED = { - gold = "Pokemon Gold", - silver = "Pokemon Silver", -} +local function gen2CartName(gameVersion) + if not GameVersion.VERSIONS[gameVersion] then return nil end + if GameVersion.generation(gameVersion) ~= 2 then return nil end + return GameVersion.info(gameVersion).displayName +end -- importSav(bytes, version, gameVersion) -> saveTable, err -- bytes: the raw 32768-byte SRAM string. Validates size and the main-data @@ -245,7 +247,7 @@ function SaveConvert.importSav(bytes, version, gameVersion) if type(bytes) ~= "string" then return nil, "expected raw save bytes as a string" end - local gen2Name = GEN2_SAV_UNSUPPORTED[gameVersion] + local gen2Name = gen2CartName(gameVersion) if gen2Name then return nil, gen2Name .. " uses a Gen 2 cart save; importing one is not supported yet." end @@ -281,7 +283,7 @@ function SaveConvert.exportSav(saveTable, gameVersion) if type(saveTable) ~= "table" then return nil, "expected a save table" end - local gen2Name = GEN2_SAV_UNSUPPORTED[gameVersion] + local gen2Name = gen2CartName(gameVersion) if gen2Name then return nil, gen2Name .. " uses a Gen 2 cart save; exporting one is not supported yet." end diff --git a/src/script/Commands.lua b/src/script/Commands.lua index c3520a34..3646939a 100644 --- a/src/script/Commands.lua +++ b/src/script/Commands.lua @@ -340,10 +340,8 @@ function Commands.start_battle(ctx, kind, a, b) ctx.lastBattleResult = result ctx.lastCheck = result == "win" if ctx.overworld then - -- A map script often follows a trainer battle with its own text. - -- Keep a level evolution behind that text: otherwise afterBattle - -- pushes the evolution screen, then this runner resumes and pushes - -- the trainer's text on top of it. + -- The map-side follow-up (stampClosedDoors, #372) rides behind the + -- script's own text; the evolution now runs in BattleState:finish. if result == "win" then ctx.afterScript = ctx.afterScript or {} table.insert(ctx.afterScript, function() @@ -1211,6 +1209,16 @@ function Commands.replace_block(ctx, bx, by, blockId) if ctx.overworld then ctx.overworld:replaceBlock(bx, by, blockId) end end +-- ss_anne_departs: scripts/VermilionDock.asm:80 .shift_columns_up, blocking +-- until she has cleared her own width +function Commands.ss_anne_departs(ctx) + local ow = ctx.overworld + if not ow or not ow.startSsAnneDeparture then return end + local runner = ctx.runner + ow:startSsAnneDeparture(function() runner:resume() end) + runner:yield() +end + -- set_tile_anim : override the current tileset's animation -- ("TILEANIM_WATER"; false stops it) until the next map change restores -- the record (setMap) @@ -1422,7 +1430,7 @@ Commands.meta = {} for _, verb in ipairs({ "show_text", "ask", "choice", "start_battle", "warp", "open_mart", "trade", "push_screen", "record_hall_of_fame", "old_man_demo", "static_battle", "rival_battle", "give_item", - "give_pokemon", "fade", "pan_camera" }) do + "give_pokemon", "fade", "pan_camera", "ss_anne_departs" }) do Commands.meta[verb] = { foreground = true } end for _, verb in ipairs({ "show_text", "ask", "choice", "start_battle", "warp", @@ -1430,7 +1438,8 @@ for _, verb in ipairs({ "show_text", "ask", "choice", "start_battle", "warp", "old_man_demo", "static_battle", "rival_battle", "give_item", "give_pokemon", "wait", "wait_flag", "move_player", "move_npc", "move_npc_to", "walk_npc", - "emote", "fade", "pan_camera", "play_once", "pikachu_make_way" }) do + "emote", "fade", "pan_camera", "play_once", "pikachu_make_way", + "ss_anne_departs" }) do local meta = Commands.meta[verb] or {} Commands.meta[verb] = meta meta.blocking = true diff --git a/src/script/gen2/CallAsm.lua b/src/script/gen2/CallAsm.lua index d2f9f7fd..4feb0155 100644 --- a/src/script/gen2/CallAsm.lua +++ b/src/script/gen2/CallAsm.lua @@ -390,9 +390,12 @@ function H.CutDownTreeOrGrass(ctx) end -- DisappearWhirlpool is CutDownTreeOrGrass with PlayWhirlpoolSound in place of --- OWCutAnimation: the same block write, the same redraw. +-- OWCutAnimation: the same block write, the same redraw, then the surf wash the +-- port owns as World:playWhirlpoolSound. #1717 function H.DisappearWhirlpool(ctx) - return H.CutDownTreeOrGrass(ctx) + local ret = H.CutDownTreeOrGrass(ctx) + call(ctx, "playWhirlpoolSound") + return ret end -- BlindingFlash sets STATUSFLAGS_FLASH_F and reloads the palettes. Setting diff --git a/src/script/gen2/Movement.lua b/src/script/gen2/Movement.lua index 24cece57..413d5390 100644 --- a/src/script/gen2/Movement.lua +++ b/src/script/gen2/Movement.lua @@ -32,6 +32,12 @@ local FIX_FACING = 0x3b -- STEP_TYPE_SLEEP with OBJECT_ACTION set to OBJECT_ACTION_WEIRD_TREE. local TREE_SHAKE = 0x56 local TREE_SHAKE_FRAMES = 24 +-- engine/events/forced_movement.asm:25-51; step_dig's frame count is the +-- byte after it (macros/scripts/movement.asm:163-167) +local TURN_HEAD = 0x00 +local TURN_IN = 0x24 +local STEP_DIG = 0x4f +local STEP_DIG_FRAMES = 16 function Movement.dir(nibble) return DIR[nibble % 4] or "down" @@ -57,8 +63,10 @@ function Movement.decodeByte(b) return { kind = "step", dir = dir } elseif family == 0x14 or family == 0x18 or family == 0x1c then -- slides return { kind = "step", dir = dir } - elseif family == 0x20 or family == 0x24 or family == 0x28 then -- turn_away/in/waterfall - return { kind = "turn", dir = dir } + elseif family == 0x20 or family == 0x24 or family == 0x28 then + -- turn_away / turn_in / turn_waterfall all `jp TurningStep`, which steps a + -- cell under OBJECT_ACTION_SPIN -- movement.asm:483-513, :693-715 (#1716) + return { kind = "step", dir = dir, spin = true } elseif family == 0x2c or family == 0x30 or family == 0x34 then -- slow_jump_step / jump_step / fast_jump_step, all of which reach -- JumpStep (engine/overworld/movement.asm:741) and so run under @@ -141,6 +149,8 @@ Movement.REMOVE_FIXED_FACING = REMOVE_FIXED_FACING Movement.FIX_FACING = FIX_FACING Movement.TREE_SHAKE = TREE_SHAKE Movement.TREE_SHAKE_FRAMES = TREE_SHAKE_FRAMES +Movement.STEP_DIG = STEP_DIG +Movement.STEP_DIG_FRAMES = STEP_DIG_FRAMES -- SetFacingWeirdTree's own index: it increments OBJECT_STEP_FRAME BEFORE -- masking (`inc a / maskbits NUM_DIRECTIONS, 2 / rrca / rrca`), so the count @@ -177,4 +187,15 @@ function Movement.stepByte(dir) return 0x0c + (DIR_BYTE[dir] or 0) end +-- Script_ForcedMovement's stream, `back` being the direction thrown in +-- -- engine/events/forced_movement.asm:25-51 +function Movement.forcedMovementBytes(back) + local d = DIR_BYTE[back] or 0 + return { + STEP_DIG, STEP_DIG_FRAMES, TURN_IN + d, + STEP_DIG, STEP_DIG_FRAMES, TURN_HEAD + d, + STEP_END, + } +end + return Movement diff --git a/src/script/gen2/Opcodes.lua b/src/script/gen2/Opcodes.lua index 80c34eb2..0972ecc3 100644 --- a/src/script/gen2/Opcodes.lua +++ b/src/script/gen2/Opcodes.lua @@ -173,6 +173,113 @@ local Opcodes = { [0xa1] = { name = "warpfacing", size = 5 }, } +-- pokegold/macros/scripts/events.asm:1015 DEF NUM_EVENT_COMMANDS EQU $a2 +Opcodes.NUM_EVENT_COMMANDS = 0xa2 + +-- pokecrystal/macros/scripts/events.asm:1-539 is identical to pokegold's, so +-- $00..$51 is copied; :541 inserts farjumptext and shifts everything after it. +local CRYSTAL = {} +for byte = 0x00, 0x51 do + local row = Opcodes[byte] + CRYSTAL[byte] = { name = row.name, size = row.size } +end + +-- pokecrystal/macros/scripts/events.asm:541-1066, cross-checked against +-- ScriptCommandTable at pokecrystal/engine/overworld/scripting.asm:64-237. +CRYSTAL[0x52] = { name = "farjumptext", size = 3 } +CRYSTAL[0x53] = { name = "jumptext", size = 2 } +CRYSTAL[0x54] = { name = "waitbutton", size = 0 } +CRYSTAL[0x55] = { name = "promptbutton", size = 0 } +CRYSTAL[0x56] = { name = "pokepic", size = 1 } +CRYSTAL[0x57] = { name = "closepokepic", size = 0 } +CRYSTAL[0x58] = { name = "_2dmenu", size = 0 } +CRYSTAL[0x59] = { name = "verticalmenu", size = 0 } +CRYSTAL[0x5a] = { name = "loadpikachudata", size = 0 } +CRYSTAL[0x5b] = { name = "randomwildmon", size = 0 } +CRYSTAL[0x5c] = { name = "loadtemptrainer", size = 0 } +CRYSTAL[0x5d] = { name = "loadwildmon", size = 2 } +CRYSTAL[0x5e] = { name = "loadtrainer", size = 2 } +CRYSTAL[0x5f] = { name = "startbattle", size = 0 } +CRYSTAL[0x60] = { name = "reloadmapafterbattle", size = 0 } +CRYSTAL[0x61] = { name = "catchtutorial", size = 1 } +CRYSTAL[0x62] = { name = "trainertext", size = 1 } +CRYSTAL[0x63] = { name = "trainerflagaction", size = 1 } +CRYSTAL[0x64] = { name = "winlosstext", size = 4 } +CRYSTAL[0x65] = { name = "scripttalkafter", size = 0 } +CRYSTAL[0x66] = { name = "endifjustbattled", size = 0 } +CRYSTAL[0x67] = { name = "checkjustbattled", size = 0 } +CRYSTAL[0x68] = { name = "setlasttalked", size = 1 } +CRYSTAL[0x69] = { name = "applymovement", size = 3 } +CRYSTAL[0x6a] = { name = "applymovementlasttalked", size = 2 } +CRYSTAL[0x6b] = { name = "faceplayer", size = 0 } +CRYSTAL[0x6c] = { name = "faceobject", size = 2 } +CRYSTAL[0x6d] = { name = "variablesprite", size = 2 } +CRYSTAL[0x6e] = { name = "disappear", size = 1 } +CRYSTAL[0x6f] = { name = "appear", size = 1 } +CRYSTAL[0x70] = { name = "follow", size = 2 } +CRYSTAL[0x71] = { name = "stopfollow", size = 0 } +CRYSTAL[0x72] = { name = "moveobject", size = 3 } +CRYSTAL[0x73] = { name = "writeobjectxy", size = 1 } +CRYSTAL[0x74] = { name = "loademote", size = 1 } +CRYSTAL[0x75] = { name = "showemote", size = 3 } +CRYSTAL[0x76] = { name = "turnobject", size = 2 } +CRYSTAL[0x77] = { name = "follownotexact", size = 2 } +CRYSTAL[0x78] = { name = "earthquake", size = 1 } +CRYSTAL[0x79] = { name = "changemapblocks", size = 3 } +CRYSTAL[0x7a] = { name = "changeblock", size = 3 } +CRYSTAL[0x7b] = { name = "reloadmap", size = 0 } +CRYSTAL[0x7c] = { name = "refreshmap", size = 0 } +CRYSTAL[0x7d] = { name = "writecmdqueue", size = 2 } +CRYSTAL[0x7e] = { name = "delcmdqueue", size = 1 } +CRYSTAL[0x7f] = { name = "playmusic", size = 2 } +CRYSTAL[0x80] = { name = "encountermusic", size = 0 } +CRYSTAL[0x81] = { name = "musicfadeout", size = 3 } +CRYSTAL[0x82] = { name = "playmapmusic", size = 0 } +CRYSTAL[0x83] = { name = "dontrestartmapmusic", size = 0 } +CRYSTAL[0x84] = { name = "cry", size = 2 } +CRYSTAL[0x85] = { name = "playsound", size = 2 } +CRYSTAL[0x86] = { name = "waitsfx", size = 0 } +CRYSTAL[0x87] = { name = "warpsound", size = 0 } +CRYSTAL[0x88] = { name = "specialsound", size = 0 } +CRYSTAL[0x89] = { name = "autoinput", size = 3 } +CRYSTAL[0x8a] = { name = "newloadmap", size = 1 } +CRYSTAL[0x8b] = { name = "pause", size = 1 } +CRYSTAL[0x8c] = { name = "deactivatefacing", size = 1 } +CRYSTAL[0x8d] = { name = "sdefer", size = 2 } +CRYSTAL[0x8e] = { name = "warpcheck", size = 0 } +CRYSTAL[0x8f] = { name = "stopandsjump", size = 2 } +CRYSTAL[0x90] = { name = "endcallback", size = 0 } +CRYSTAL[0x91] = { name = "end", size = 0 } +CRYSTAL[0x92] = { name = "reloadend", size = 1 } +CRYSTAL[0x93] = { name = "endall", size = 0 } +CRYSTAL[0x94] = { name = "pokemart", size = 3 } +CRYSTAL[0x95] = { name = "elevator", size = 2 } +CRYSTAL[0x96] = { name = "trade", size = 1 } +CRYSTAL[0x97] = { name = "askforphonenumber", size = 1 } +CRYSTAL[0x98] = { name = "phonecall", size = 2 } +CRYSTAL[0x99] = { name = "hangup", size = 0 } +CRYSTAL[0x9a] = { name = "describedecoration", size = 1 } +CRYSTAL[0x9b] = { name = "fruittree", size = 1 } +CRYSTAL[0x9c] = { name = "specialphonecall", size = 2 } +CRYSTAL[0x9d] = { name = "checkphonecall", size = 0 } +CRYSTAL[0x9e] = { name = "verbosegiveitem", size = 2 } +CRYSTAL[0x9f] = { name = "verbosegiveitemvar", size = 2 } +-- events.asm:1003-1008: Crystal's swarm is `db flag` then `map_id`, not a bare +-- map_id; Script_swarm makes three GetScriptByte calls (scripting.asm:654-662). +CRYSTAL[0xa0] = { name = "swarm", size = 3 } +CRYSTAL[0xa1] = { name = "halloffame", size = 0 } +CRYSTAL[0xa2] = { name = "credits", size = 0 } +CRYSTAL[0xa3] = { name = "warpfacing", size = 5 } +CRYSTAL[0xa4] = { name = "battletowertext", size = 1 } +CRYSTAL[0xa5] = { name = "getlandmarkname", size = 2 } +CRYSTAL[0xa6] = { name = "gettrainerclassname", size = 2 } +CRYSTAL[0xa7] = { name = "getname", size = 3 } +CRYSTAL[0xa8] = { name = "wait", size = 1 } +CRYSTAL[0xa9] = { name = "checksave", size = 0 } + +-- pokecrystal/macros/scripts/events.asm:1068 DEF NUM_EVENT_COMMANDS EQU $aa +CRYSTAL.NUM_EVENT_COMMANDS = 0xaa + -- Commands that end the current linear path (jumps transfer control). -- -- `fruittree` and `describedecoration` are ScriptJumps (Script_fruittree does @@ -185,9 +292,13 @@ local Opcodes = { -- these four regardless. `catchtutorial` is deliberately NOT here: it ends on -- `jp Script_reloadmap` and the script really does continue afterwards, the -- same way `reloadmap` itself does. +-- +-- `farjumptext` is Crystal-only and joins them for the same reason `jumptext` +-- is here: pokecrystal/engine/overworld/scripting.asm:318-327 ends on +-- `jp ScriptJump` into JumpTextScript. Opcodes.TERMINATORS = { sjump = true, farsjump = true, memjump = true, jumpstd = true, - jumptext = true, jumptextfaceplayer = true, + jumptext = true, farjumptext = true, jumptextfaceplayer = true, stopandsjump = true, ["end"] = true, endall = true, endcallback = true, reloadend = true, fruittree = true, describedecoration = true, @@ -198,10 +309,12 @@ Opcodes.TERMINATORS = { -- behind it. Every row above is keyed by the opcode byte the cart carries, and -- src/import/RomExtractorGen2.lua resolves a command as Opcodes[byte]: a byte -- that is not in that table breaks the pointer walk into an `unknown` row. Give --- a mod verb one of the free bytes ($a2..$ff) and ROM data that happens to start --- with it would decode as a mod call instead of ending the walk, so the free --- space stays free and the extension op is reachable only by NAME -- which is to --- say, only from a row a mod wrote, never from one the extractor did. +-- a mod verb one of the free bytes and ROM data that happens to start with it +-- would decode as a mod call instead of ending the walk, so the free space stays +-- free and the extension op is reachable only by NAME -- which is to say, only +-- from a row a mod wrote, never from one the extractor did. The free space is +-- per dialect and the intersection is what matters: Gold leaves $a2..$ff +-- (events.asm:1015), Crystal only $aa..$ff (events.asm:1068). -- -- src/script/gen2/Vm.lua:runModCommand is the only reader; the contract for the -- row shapes and the verb table is documented there. @@ -211,4 +324,15 @@ function Opcodes.key(bank, address) return string.format("%02x:%04x", bank, address) end +CRYSTAL.TERMINATORS = Opcodes.TERMINATORS +CRYSTAL.MOD_COMMAND = Opcodes.MOD_COMMAND +CRYSTAL.key = Opcodes.key + +-- Gold and Silver share one dialect (pokegold/macros/scripts/events.asm:540); +-- Crystal renumbers from $52 (pokecrystal/macros/scripts/events.asm:541). +function Opcodes.forEdition(edition) + if edition == "crystal" then return CRYSTAL end + return Opcodes +end + return Opcodes diff --git a/src/script/gen2/Specials.lua b/src/script/gen2/Specials.lua index 6a29da98..c0240a48 100644 --- a/src/script/gen2/Specials.lua +++ b/src/script/gen2/Specials.lua @@ -1,7 +1,7 @@ -- The `special` command's handlers (data/events/special_pointers.asm). -- -- Lifted out of src/script/gen2/Vm.lua because the two are different kinds of --- code: the VM is one interpreter with a shared control flow, and this is 112 +-- code: the VM is one interpreter with a shared control flow, and this is 169 -- INDEPENDENT routines that happen to share a dispatch table. Growing them -- inside runList's else-chain would have buried the interpreter. -- @@ -9,9 +9,9 @@ -- which the extractor turns into constants.specialOrder, and Vm:specialName -- resolves the one into the other; keying on the label rather than on the -- number means a repointed table cannot silently call the wrong routine, and --- it means a test can assert the mapping against the cache. This cache's --- order has 112 rows -- the asm file's 113 `add_special` matches include the --- MACRO line itself. +-- it means a test can assert the mapping against the cache. A Gold cache's +-- order has 112 rows and a Crystal one 169 -- the asm files' `add_special` +-- match counts are one higher each, because they include the MACRO line. -- -- Three kinds of entry live here: -- @@ -52,6 +52,7 @@ -- it, plus the fruit trees and the daily rollover Kurt waits on. local Apricorns = require("src.core.gen2.Apricorns") local BugContest = require("src.core.gen2.BugContest") +local GameVersion = require("src.core.GameVersion") local Happiness = require("src.core.gen2.Happiness") local Phone = require("src.core.gen2.Phone") local Pokerus = require("src.core.gen2.Pokerus") @@ -175,6 +176,20 @@ local function selectMon(vm, prompt) return picked.index, picked.mon end +Specials.shared = { + TRUE = TRUE, + FALSE = FALSE, + block = Specials.block, + hooks = hooks, + party = party, + save = save, + data = data, + answer = answer, + nameMon = nameMon, + selectMon = selectMon, + showRawHeld = showRawHeld, +} + -------------------------------------------------------------------------- -- Magikarp lengths (engine/events/magikarp.asm) -------------------------------------------------------------------------- @@ -1058,7 +1073,7 @@ end -- -- is drawn on the cartridge itself, and only the A press farcalls -- PrintUnownStamp. src/ui/gen2/UnownPrinter.lua is that viewer and takes the -- A press nowhere, which is what a cartridge with nothing in its link port --- does; PrintDiploma next door has no viewer half at all and stays a stub. +-- does; PrintDiploma next door shows its page and prints nothing either. -- -- `ld a, [wUnownDex] / and a / ret z` is the gate: with no Unown caught the -- special returns before it draws anything. The routine never writes @@ -1117,6 +1132,10 @@ H.FadeOutToBlack = function(vm) fade(vm, "outBlack") end H.FadeInFromWhite = function(vm) fade(vm, "inWhite") end H.FadeInFromBlack = function(vm) fade(vm, "inBlack") end +-- engine/tilesets/timeofday_pals.asm:130: FillWhiteBGColor, then the same +-- c=$9 / b=4 time-pal walk FadeInFromWhite runs, stepped by hand. +H.BattleTowerFade = function(vm) fade(vm, "inWhite") end + -- ClearBGPalettes / ClearBGPalettesBufferScreen / ClearTilemap: the screen is -- blanked to the background colour under a fade that is already down. The -- port fades with a flat sheet, so the sheet IS the cleared screen. @@ -1152,6 +1171,20 @@ H.LoadUsedSpritesGFX = function(vm) if h.reloadSprites then h.reloadSprites(false) end end +-- ../pokecrystal/engine/overworld/warp_connection.asm:311, `ld b, SCGB_MAPPALS / jp +-- GetSGBLayout`: the map's own palette layout, reapplied and nothing else. +H.LoadMapPalettes = function(vm) + local h = hooks(vm) + if h.reloadSprites then h.reloadSprites(true) end +end + +-- engine/overworld/overworld.asm:40: the used-sprite list rebuilt and its +-- VRAM pack reloaded, with no palette pass -- LoadUsedSpritesGFX's arm. +H.RefreshSprites = function(vm) + local h = hooks(vm) + if h.reloadSprites then h.reloadSprites(false) end +end + -- UpdatePlayerSprite: the player's sheet is a pure function of wPlayerState -- (data/sprites/player_sprites.asm ChrisStateSprites), which is what makes -- getting on and off a Lapras a one-byte change rather than an animation. @@ -1160,6 +1193,16 @@ H.UpdatePlayerSprite = function(vm) if h.updatePlayerSprite then h.updatePlayerSprite() end end +-- engine/events/specials.asm:21 -> engine/overworld/map_objects.asm:2515: +-- bit 7 of wScriptVar gates the routine, bits 6-4 are the OBJ palette. +H.SetPlayerPalette = function(vm) + local value = (vm.scriptVar or 0) % 0x100 + if value < 0x80 then return end + vm.playerPalette = math.floor(value / 0x10) % 8 + local h = hooks(vm) + if h.setPlayerPalette then h.setPlayerPalette(vm.playerPalette) end +end + -- ---- 58-62 sound and the water -------------------------------------------- -- WaitSFX: hold until the sound effect that is playing finishes. The VM has @@ -1332,6 +1375,13 @@ H.GiveShuckle = function(vm) mon.ot = Specials.MANIA_OT mon.otId = Specials.MANIA_OT_ID list[#list + 1] = mon + -- TryAddMonToParty's .registerpokedex (engine/pokemon/move_mon.asm:188-196) #1719 + local record = save(vm) + if record then + record.pokedex = record.pokedex or { seen = {}, caught = {} } + record.pokedex.seen[mon.species] = true + record.pokedex.caught[mon.species] = true + end answer(vm, TRUE) end @@ -1445,6 +1495,22 @@ local function trailingDigitsShared(a, b) end Specials.trailingDigitsShared = trailingDigitsShared +-- pokegold/constants/pokemon_data_constants.asm:122-123 +local NUM_BOXES, NUM_BOXES_JP = 14, 9 + +-- pokegold/engine/events/lucky_number.asm:22-102: sBox (the OPEN box) is walked +-- before .BoxesLoop skips it, and .BoxesLoop stops at NUM_BOXES_JP not NUM_BOXES. +local function luckyNumberBoxOrder(record) + local current = math.floor(tonumber(record and record.currentBox) or 1) + local last = GameVersion.fixes().luckyNumberBoxes and NUM_BOXES or NUM_BOXES_JP + local order = { current } + for index = 1, last do + if index ~= current then order[#order + 1] = index end + end + return order +end +Specials.luckyNumberBoxOrder = luckyNumberBoxOrder + local function luckyPrizeFor(shared) if shared >= 5 then return 1 end if shared >= 3 then return 2 end @@ -1508,8 +1574,9 @@ H.CheckForLuckyNumberWinners = function(vm) end end for _, mon in ipairs(party(vm)) do consider(mon, false) end - for _, box in pairs((record and record.boxes) or {}) do - for _, mon in ipairs(box or {}) do consider(mon, true) end + local boxes = (record and record.boxes) or {} + for _, index in ipairs(luckyNumberBoxOrder(record)) do + for _, mon in ipairs(boxes[index] or {}) do consider(mon, true) end end answer(vm, best) if bestMon then @@ -1586,9 +1653,17 @@ local KURT_MENU_FLAGS = 0x80 + 0x20 -- CANCEL. Both refusals -- FindApricornsInBag's `scf` for a pack with no -- apricorn in it at all, and the `jr c, .nope` for pressing B -- answer -- `xor a`, which is the FALSE `.Cancel` waits on. +-- +-- ../pokecrystal/engine/events/kurt.asm:19-45 is the same routine with a +-- quantity menu bolted on, and its `xor a / ld [wKurtApricornQuantity], a` +-- (:24) and `ld a, [wItemQuantityChange] / ld [...], a` (:45) are what +-- ../pokecrystal/maps/KurtsHouse.asm:197's `verbosegiveitemvar LEVEL_BALL, +-- VAR_KURT_APRICORNS` counts out. Kurt_SelectQuantity is not ported, so the +-- count written here is the one apricorn Apricorns.takeApricorn tosses. H.SelectApricornForKurt = function(vm) local h = hooks(vm) local record = save(vm) + if h.setKurtApricornQuantity then h.setKurtApricornQuantity(0) end local list = Apricorns.bagList(record and record.inventory) if list.empty then return answer(vm, FALSE) end @@ -1612,7 +1687,10 @@ H.SelectApricornForKurt = function(vm) local item = h.itemIndex and h.itemIndex(apricorn) if not item or item == 0 then return answer(vm, FALSE) end answer(vm, item) - Apricorns.takeApricorn(record, apricorn) + if Apricorns.takeApricorn(record, apricorn) + and h.setKurtApricornQuantity then + h.setKurtApricornQuantity(1) + end end -- ---- 88-89 the first party slot ------------------------------------------- @@ -1917,7 +1995,7 @@ local OAK_RATINGS = { -- Celadon Mansion 3F reads VAR_DEXCAUGHT for itself and is what runs -- `special Diploma` (H.Diploma below, src/ui/gen2/Diploma.lua) and -- then sets EVENT_ENABLE_DIPLOMA_PRINTING for the Graphic Artist's - -- `special PrintDiploma` (also stubbed: "printer: no Game Boy Printer"). + -- `special PrintDiploma` (H.PrintDiploma, specials/crystal_extras.lua). -- A finished #DEX here just means every rating after this one is this -- same line. { max = 255, sfx = "Sfx_DexFanfare230Plus", text = Strings.source( @@ -2027,7 +2105,7 @@ Specials.ROAMERS = Roamers.SPECIES H.InitRoamMons = function(vm) local record = save(vm) if not record then return end - Roamers.init(record, { force = true }) + Roamers.init(record, { force = true, data = data(vm) }) end -- ---- the #DEX-completion diploma ------------------------------------------- @@ -2328,7 +2406,7 @@ end -- layout, PrintPartyMonPage1) and then SendScreenToPrinter walks it out the -- serial port to a physical Game Boy Printer. There is no peripheral for it -- to reach here -- the same reason H.UnownPrinter's A press goes nowhere and --- PrintDiploma stays stubbed below -- so `ldh a, [hPrinter] / and a / jr nz, +-- PrintDiploma's own print goes nowhere -- so `ldh a, [hPrinter] / and a / jr nz, -- .cancel` is -- hardwired to the nz arm below: the portrait shows, then the print always -- comes back as though the printer errored, which is the honest answer for @@ -2370,11 +2448,137 @@ H.PhotoStudio = function(vm) vm:showRaw(Strings(PHOTO_STUDIO_TEXT.noPhoto)) end +-- ---- 21 the quick save ----------------------------------------------------- +-- +-- TryQuickSave (engine/link/link.asm:2356) is filed with the cable club but is +-- not a link routine: it is `farcall Link_SaveGame`, TRUE on carry clear and +-- FALSE on carry, then `ld c, 30 / call DelayFrames`. Link_SaveGame +-- (engine/menus/save.asm:63) is AskOverwriteSaveFile (:169) plus the ordinary +-- write, so the FALSE arm is a refusal at the overwrite prompt -- which is +-- what ../pokecrystal/maps/BattleTower1F.asm:84-85 backs a challenge out on, +-- and what the four PokeCenter2F cable rows share. +-- +-- Two differences from the cart, neither observable in the answer. +-- AskOverwriteSaveFile's mismatched-ID arm runs ErasePreviousSave (:333) before +-- saving; Save.save replaces the whole file anyway. And SFX_SAVE rings at the +-- write here, where src/ui/gen2/SaveMenu.lua:107 already puts it, rather than +-- after the saved page has typed. + +-- data/text/common_2.asm:1279-1299. +local SAVE_TEXT = { + already = Strings.source( + "There is already a\nsave file. Is it\vOK to overwrite?"), + another = Strings.source( + "There is another\nsave file. Is it\vOK to overwrite?"), + saving = Strings.source("SAVING… DON'T TURN\nOFF THE POWER."), + saved = Strings.source("%s saved\nthe game."), +} + +-- engine/menus/save.asm:247 the 16 frames under SAVING and :251 the 32 the +-- write is followed by; :269 the 30 after the saved page, and link.asm:2367 the +-- 30 TryQuickSave adds on top. Both pages are `hold`s rather than `wait`s +-- because the world does not tick while a box owns the stack (Vm:showRaw). +local SAVING_HOLD = 16 + 32 +local SAVED_HOLD = 30 + 30 + +H.TryQuickSave = function(vm) + local h = hooks(vm) + -- `ld a, [wSaveFileExists] / and a / jr z, .erase`, then + -- CompareLoadedAndSavedPlayerID (:212) picking which question is asked. + local exists, sameId = false, false + if h.saveFileState then exists, sameId = h.saveFileState() end + if exists then + showRawHeld(vm, Strings(sameId and SAVE_TEXT.already or SAVE_TEXT.another)) + if not coroutine.yield({ kind = "yesorno" }) then + return answer(vm, FALSE) + end + end + vm:showRaw(Strings(SAVE_TEXT.saving), true, SAVING_HOLD) + -- _SaveGameData (:273). A veto from the save.write mod hook is the one way + -- this port can refuse a write the cart always completes, and a refusal is + -- the same FALSE the overwrite prompt's NO gives. + if not (h.writeSave and h.writeSave() ~= false) then + return answer(vm, FALSE) + end + if h.playSfxNamed then h.playSfxNamed("Sfx_Save") end + local record = save(vm) + local name = (record and record.player and record.player.name) or "" + vm:showRaw(Strings(SAVE_TEXT.saved, name), true, SAVED_HOLD) + answer(vm, TRUE) +end + -- ---- 111 the dummy -------------------------------------------------------- -- UnusedDummySpecial is a bare `ret`. Listed so the name resolves to a -- handler rather than to the unimplemented ledger. H.UnusedDummySpecial = function() end +-- ---- 109-165 the Crystal rows --------------------------------------------- +-- data/events/special_pointers.asm:124-181, the rows only Crystal has. + +-- ../pokecrystal/engine/pokemon/search_owned.asm:48 CheckOwnMonAnywhere: party then boxes, +-- matching species, OT id and OT name; `ld a, [wPartyCount] / and a / ret z`. +local function ownsMonAnywhere(vm, wanted) + local h = hooks(vm) + local list = party(vm) + if #list == 0 then return false end + local record = save(vm) + local player = record and record.player + local function owns(mon) + if not mon then return false end + if not (h.monIndex and h.monIndex(mon.species) == wanted) then return false end + if player and player.id and mon.otId and mon.otId ~= player.id then + return false + end + if player and player.name and mon.ot and mon.ot ~= player.name then + return false + end + return true + end + for _, mon in ipairs(list) do + if owns(mon) then return true end + end + for _, box in pairs((record and record.boxes) or {}) do + for _, mon in ipairs(box or {}) do + if owns(mon) then return true end + end + end + return false +end + +-- ../pokecrystal/engine/pokemon/search_owned.asm:31 +H.MonCheck = function(vm) + answer(vm, ownsMonAnywhere(vm, vm.scriptVar) and TRUE or FALSE) +end + +-- home/init.asm:1 falls into Init (home/init.asm:35) and on to the copyright +-- splash, which is Game2:softReset rather than Game2:returnToTitle. +H.Reset = function(vm) + local h = hooks(vm) + if h.softReset then h.softReset() end +end + +-- ../pokecrystal/mobile/mobile_41.asm:320 is a bare `ret` with its SRAM counter left behind +-- it as dead code, so a no-op is the whole routine. +H.StubbedTrainerRankings_Healings = function() end + +-- ../pokecrystal/mobile/mobile_41.asm:792: the international ROM answers 0 outright, which +-- is what sends every Pokecenter 2F mobile branch down its cable arm. +H.CheckMobileAdapterStatusSpecial = function(vm) + answer(vm, FALSE) +end + +-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:187 and :1580, both bare `ret`. +H.UnusedBattleTowerDummySpecial1 = function() end +H.UnusedBattleTowerDummySpecial2 = function() end + +-- ../pokecrystal/engine/overworld/time.asm:136 SampleKenjiBreakCountdown: +-- `call Random / and %11 / add 3`, three to six days into wKenjiBreakTimer, +-- which ../pokecrystal/maps/Route45.asm:50 reads back through VAR_KENJI_BREAK. +H.SampleKenjiBreakCountdown = function(vm) + local h = hooks(vm) + if h.setKenjiBreak then h.setKenjiBreak(Specials.random(4) - 1 + 3) end +end + -------------------------------------------------------------------------- -- The deliberate stubs -------------------------------------------------------------------------- @@ -2394,7 +2598,6 @@ local STUB_ROWS = { { "SetBitsForLinkTradeRequest", nil, "link cable: no Gen 2 cable club" }, { "WaitForLinkedFriend", 0, "link cable: nobody ever connects" }, { "CheckLinkTimeout_Receptionist", 1, "link cable: always times out" }, - { "TryQuickSave", 0, "link cable: the cable club's own save is not ported" }, { "CheckBothSelectedSameRoom", 0, "link cable: no second player" }, { "FailedLinkToPast", 1, "link cable: the Time Capsule is not ported" }, { "CloseLink", nil, "link cable: nothing to close" }, @@ -2415,45 +2618,149 @@ local STUB_ROWS = { { "CheckMysteryGift", 0, "Mystery Gift: no infrared, so no gift is waiting" }, { "GetMysteryGiftItem", 0, "Mystery Gift: nothing to hand over" }, { "UnlockMysteryGift", nil, "Mystery Gift: nothing to unlock" }, - -- The Game Boy Printer. A second peripheral again, and two of the three - -- specials that want it are not in this table: PhotoStudio ports the - -- conversation and the portrait screen (H.PhotoStudio above) and - -- UnownPrinter ports the stamp viewer (H.UnownPrinter above), with only the - -- print itself stubbed inside each. PrintDiploma is the one that is - -- nothing BUT the print -- the diploma's own page is `special Diploma`, a - -- separate row -- so it is all that is left here. + -- The Game Boy Printer. A second peripheral again, and none of the three + -- specials that want it is stubbed any more: PhotoStudio ports the + -- conversation and the portrait screen (H.PhotoStudio above), UnownPrinter + -- ports the stamp viewer (H.UnownPrinter above) and PrintDiploma opens + -- PlaceDiplomaOnScreen (specials/crystal_extras.lua), with only the print + -- itself going nowhere inside each. The row below is superseded by that + -- handler and survives only as the reason. { "PrintDiploma", nil, "printer: no Game Boy Printer" }, - -- Screens the port has not built. Each names what it needs; the value is - -- the arm a cancel takes, so the script backs out rather than proceeding - -- through a transaction that never happened. + -- OverworldTownMap's row is superseded by H.OverworldTownMap + -- (specials/crystal_extras.lua) and survives only as the reason. + -- ../pokecrystal/data/events/special_pointers.asm:58 and pokegold's :63 both + -- carry `add_special UnusedMemoryGame ; unused`, and no script names it. { "OverworldTownMap", nil, "needs the POKeGEAR map card in view mode" }, - { "UnusedMemoryGame", nil, "unused in Gold; needs the memory game screen" }, + { "UnusedMemoryGame", nil, "unused on both carts; no script reaches _MemoryGame" }, + -- data/events/special_pointers.asm:124-181, the rows only Crystal has. + { "BattleTowerRoomMenu", 10, "Battle Tower: $a is the menu's back-out arm" }, + { "BattleTowerBattle", nil, "Battle Tower: no tower battle to run" }, + { "BattleTowerAction", 0, "Battle Tower: 0 is sGSBallFlag clear" }, + { "CheckForBattleTowerRules", 0, "Battle Tower: no challenge in progress" }, + { "Menu_ChallengeExplanationCancel", 0, "Battle Tower: 0 ends the talk" }, + { "LoadOpponentTrainerAndPokemonWithOTSprite", 0, "Battle Tower: no roster" }, + { "BattleTowerMobileError", nil, "Battle Tower: no mobile error to report" }, + { "Function1700ba", nil, "Battle Tower: mobile challenge setup" }, + { "Function170114", nil, "Battle Tower: mobile challenge setup" }, + { "Function1704e1", nil, "Battle Tower: mobile challenge setup" }, + { "AskMobileOrCable", 0, "Mobile System GB: 0 is a B press off the menu" }, + { "Mobile_SelectThreeMons", 0, "Mobile System GB: no three mons picked" }, + { "Function1011f1", nil, "Mobile System GB: enters LINK_MOBILE" }, + { "Function101220", nil, "Mobile System GB: leaves LINK_MOBILE" }, + { "Function101225", 0, "Mobile System GB: mobile trade room teardown" }, + { "Function101231", 0, "Mobile System GB: mobile battle room teardown" }, + { "Function102142", nil, "Mobile System GB: mobile news feed" }, + { "Function103780", 0, "Mobile System GB: the mobile save never happens" }, + { "Function1037c2", 0, "Mobile System GB: no rematch on same settings" }, + { "Function1037eb", 0, "Mobile System GB: no battle time is left" }, + { "Function10383c", 0, "Mobile System GB: the three-mon pick cancels" }, + { "Function10387b", nil, "Mobile System GB: adapter status readback" }, + { "TradeCornerHoldMon", nil, "Mobile System GB: no mobile trade corner" }, + { "Function11ac3e", nil, "Mobile System GB: trade corner submenu" }, + { "Function11b5e8", nil, "Mobile System GB: trade corner submenu" }, + { "Function11b7e5", nil, "Mobile System GB: trade corner submenu" }, + { "Function11b879", 0, "Mobile System GB: trade corner submenu" }, + { "Function11b920", nil, "Mobile System GB: trade corner submenu" }, + { "Function11b93b", nil, "Mobile System GB: trade corner submenu" }, + { "Function11ba38", 0, "Mobile System GB: trade corner submenu" }, + { "Function17d2b6", nil, "Mobile System GB: mobile menu chrome" }, + { "Function17d2ce", 0, "Mobile System GB: mobile menu chrome" }, + { "Function11c1ab", nil, "Mobile System GB: the fixed-word entry screen" }, + { "UnusedFindItemInPCOrBag", 0, "Mobile System GB: unreferenced" }, + { "GiveOddEgg", nil, "the Odd Egg roster is not ported; Route 34 is later" }, + { "DisplayUnownWords", nil, "needs the Unown wall word box" }, + { "HoOhChamber", nil, "the Ruins of Alph secret chambers are not ported" }, + { "OmanyteChamber", nil, "the Ruins of Alph secret chambers are not ported" }, + { "PokeSeer", nil, "needs the Seer's caught-data page" }, + { "BeastsCheck", 0, "the three beasts cannot all be owned this early" }, + { "BuenasPassword", 0, "Buena's show is not ported; 0 is a wrong guess" }, + { "BuenaPrize", nil, "Buena's prize counter is not ported" }, + { "AskRememberPassword", 0, "Buena's show is not ported; 0 declines" }, + { "CelebiShrineEvent", nil, "the GS Ball event needs the mobile stadium" }, + { "CheckCaughtCelebi", 0, "the GS Ball event never runs, so Celebi is free" }, + { "GiveDratini", nil, "the Dragon Shrine moveset swap is past Phase 1" }, + { "MoveTutor", 255, "the tutor is past Phase 1; -1 is its cancel arm" }, } Specials.HANDLERS = H + +-- data/events/special_pointers.asm:124-181, the "; Crystal only" block: one +-- module per owner under src/script/gen2/specials/, merged into HANDLERS here. +Specials.MODULES = { + "crystal_story", + "battle_tower", + "crystal_extras", + "unown_words", +} + +Specials.HANDLER_SOURCE = {} +for name in pairs(H) do Specials.HANDLER_SOURCE[name] = "Specials.lua" end + Specials.STUBS = {} Specials.STUB_REASONS = {} - -for _, row in ipairs(STUB_ROWS) do - local name, value, reason = row[1], row[2], row[3] - Specials.STUB_REASONS[name] = reason - Specials.STUBS[name] = function(vm) - if value ~= nil then vm.scriptVar = value end - end -end +Specials.SUPERSEDED_STUBS = {} -- The dispatch table Vm.SPECIALS is. Built rather than written out so the two -- sets cannot drift, and so a name that ends up in both is a hard error here -- rather than a silent shadow at runtime. Specials.ALL = {} -for name, fn in pairs(Specials.HANDLERS) do - Specials.ALL[name] = fn -end -for name, fn in pairs(Specials.STUBS) do - if Specials.ALL[name] then - error("gen2 special '" .. name .. "' is both implemented and stubbed", 0) - end - Specials.ALL[name] = fn + +local function clear(t) + for key in pairs(t) do t[key] = nil end end +local function rebuild() + clear(Specials.STUBS) + clear(Specials.STUB_REASONS) + clear(Specials.SUPERSEDED_STUBS) + clear(Specials.ALL) + for _, row in ipairs(STUB_ROWS) do + local name, value, reason = row[1], row[2], row[3] + if H[name] then + if Specials.HANDLER_SOURCE[name] == "Specials.lua" then + error("gen2 special '" .. name .. "' is both implemented and stubbed", 0) + end + Specials.SUPERSEDED_STUBS[name] = reason + else + Specials.STUB_REASONS[name] = reason + Specials.STUBS[name] = function(vm) + if value ~= nil then vm.scriptVar = value end + end + end + end + for name, fn in pairs(H) do Specials.ALL[name] = fn end + for name, fn in pairs(Specials.STUBS) do Specials.ALL[name] = fn end +end + +function Specials.merge(handlers, source) + if type(handlers) ~= "table" then + error("gen2 specials module '" .. tostring(source) .. "' returned " + .. type(handlers) .. ", expected a table of name -> function", 0) + end + for name, fn in pairs(handlers) do + if type(name) ~= "string" or type(fn) ~= "function" then + error("gen2 specials module '" .. tostring(source) + .. "' entry [" .. tostring(name) .. "] is not name -> function", 0) + end + local owner = Specials.HANDLER_SOURCE[name] + if owner then + error("gen2 special '" .. name .. "' is defined twice: " + .. owner .. " and " .. tostring(source), 0) + end + H[name] = fn + Specials.HANDLER_SOURCE[name] = source + end + rebuild() + return handlers +end + +package.loaded["src.script.gen2.Specials"] = Specials + +for _, name in ipairs(Specials.MODULES) do + Specials.merge(require("src.script.gen2.specials." .. name), + "specials/" .. name .. ".lua") +end + +rebuild() + return Specials diff --git a/src/script/gen2/Vm.lua b/src/script/gen2/Vm.lua index 4fe546fa..6652153c 100644 --- a/src/script/gen2/Vm.lua +++ b/src/script/gen2/Vm.lua @@ -49,6 +49,14 @@ local MAX_MONEY, MAX_COINS = 999999, 9999 local SFX_ITEM, SFX_HANG_UP = 0x01, 0x6b -- constants/script_constants.asm: EMOTE_FROM_MEM is -1, i.e. the byte $ff. local EMOTE_FROM_MEM = 0xff +-- constants/item_constants.asm:300 DEF ITEM_FROM_MEM EQU $ff +local ITEM_FROM_MEM = 0xff +-- pokecrystal/constants/script_constants.asm:254-257 StoreSwarmMapIndices args. +local SWARM_DUNSPARCE = 0 +-- pokecrystal/constants/text_constants.asm:13-19 wNamedObjectType values. +local NAMED_MON, NAMED_ITEM, NAMED_TRAINER = 1, 4, 7 +-- pokecrystal/engine/overworld/scripting.asm:2336-2347 Script_wait +local WAIT_FRAMES_PER_UNIT = 6 -- constants/misc_constants.asm GS_VERSION: 0 Gold, 1 Silver. local GS_VERSION_GOLD = 0 -- engine/overworld/variables.asm .VarActionTable rows for wMapGroup and @@ -204,7 +212,9 @@ local function runCmd(self, cmd, op) local value = self.callAsmFn(cmd.label, arg1(cmd) or 0, wordArg(cmd, 2)) if value ~= nil then self.scriptVar = value % 256 end end - elseif op == "jumptext" then + elseif op == "jumptext" or op == "farjumptext" then + -- pokecrystal/engine/overworld/scripting.asm:318-327 Script_farjumptext: + -- Script_jumptext with a `dba` for the `dw`, same JumpTextScript. self:emitFace(false) self:showText(cmd.text) return "end" @@ -519,6 +529,36 @@ local function runCmd(self, cmd, op) -- town map draws them two rows deep). local name = self.getLandmarkNameFn and self.getLandmarkNameFn() if name then self:setStringBuffer(name) end + elseif op == "getlandmarkname" then + -- pokecrystal/engine/overworld/scripting.asm:1615-1623: the landmark id + -- comes off the script, then ConvertLandmarkToText and a buffer byte. + local id = cmd.landmark or arg1(cmd) or 0 + local name = self.getLandmarkNameFn and self.getLandmarkNameFn(id) + if name then self:setStringBuffer(name) end + elseif op == "gettrainerclassname" then + -- pokecrystal/engine/overworld/scripting.asm:1644-1647: TRAINER_NAME is + -- preset, so the one id byte ContinueToGetName reads is a trainer group. + if self.getTrainerClassNameFn then + local name = self.getTrainerClassNameFn(cmd.class or arg1(cmd) or 0) + if name then self:setStringBuffer(name) end + end + elseif op == "getname" then + -- pokecrystal/engine/overworld/scripting.asm:1633-1641: a + -- wNamedObjectType byte, an id byte, then GetStringBuffer's buffer byte. + local args = cmd.args or {} + local kind = cmd.kind or args[1] or 0 + local id = cmd.id or args[2] or 0 + local name + if kind == NAMED_MON and self.getMonNameFn then + name = self.getMonNameFn(id) + elseif kind == NAMED_ITEM and self.getItemNameFn then + name = self.getItemNameFn(id) + elseif kind == NAMED_TRAINER and self.getTrainerClassNameFn then + name = self.getTrainerClassNameFn(id) + elseif self.getNameFn then + name = self.getNameFn(kind, id) + end + if name then self:setStringBuffer(name) end elseif op == "getnum" then -- Script_getnum: PrintNum of wScriptVar (PRINTNUM_LEFTALIGN | 1 byte, -- 3 chars) into wStringBuffer1, then GetStringBuffer copies that into @@ -599,9 +639,17 @@ local function runCmd(self, cmd, op) else self.scriptVar = POKEMAIL_REFUSED end - elseif op == "giveitem" or op == "verbosegiveitem" then + elseif op == "giveitem" or op == "verbosegiveitem" + or op == "verbosegiveitemvar" then local item = cmd.item or arg1(cmd) or 0 local qty = cmd.quantity or (cmd.args and cmd.args[2]) or 1 + if op == "verbosegiveitemvar" then + -- pokecrystal/engine/overworld/scripting.asm:486-510: ITEM_FROM_MEM + -- takes the item from wScriptVar, and byte two is a VAR_* id. + if item == ITEM_FROM_MEM then item = (self.scriptVar or 0) % 256 end + local varId = cmd.var or (cmd.args and cmd.args[2]) or 0 + qty = self.readVarFn and self.readVarFn(varId) or 0 + end -- Script_giveitem's own `ld [wCurItem], a` (scripting.asm:1612). It is -- what the standalone `specialsound` inside GiveItemScript reads back: -- CheckItemPocket runs on wCurItem, not on anything the opcode carries. @@ -611,7 +659,7 @@ local function runCmd(self, cmd, op) ok = self.giveItemFn(item, qty) ~= false end self.scriptVar = ok and 1 or 0 - if op == "verbosegiveitem" then + if op == "verbosegiveitem" or op == "verbosegiveitemvar" then local name = self.getItemNameFn and self.getItemNameFn(item) or "?" self:setStringBuffer(name) -- GiveItemScript (engine/overworld/scripting.asm:441-449), command for @@ -1048,10 +1096,20 @@ local function runCmd(self, cmd, op) -- SetSwarmFlag -> DAILYFLAGS1_SWARM. Both halves matter: CheckSwarmFlag -- is what makes the swarm expire, so a port that only stores the map -- leaves the Dunsparce call permanently live. + -- + -- pokecrystal/macros/scripts/events.asm:1003-1008 adds a leading flag byte + -- and specials.asm:290-298 picks the index pair off it, so three operand + -- bytes means the map_id has moved along one. local args = cmd.args or {} - local group = cmd.group or args[1] - local mapNum = cmd.map or args[2] - if self.setSwarmFn then self.setSwarmFn(group, mapNum) end + local kind, group, mapNum + if #args >= 3 then + kind, group, mapNum = args[1], args[2], args[3] + else + kind = SWARM_DUNSPARCE + group = cmd.group or args[1] + mapNum = cmd.map or args[2] + end + if self.setSwarmFn then self.setSwarmFn(group, mapNum, kind) end elseif op == "reloadmapafterbattle" or op == "reloadmap" or op == "refreshmap" then -- Losing ENDS the script. Script_reloadmapafterbattle reads wBattleResult @@ -1617,6 +1675,21 @@ local function runCmd(self, cmd, op) coroutine.yield({ kind = "credits" }) end return "end" + -- ---- Crystal-only verbs ------------------------------------------------ + elseif op == "wait" then + -- pokecrystal/engine/overworld/scripting.asm:2336-2347 Script_wait: SIX + -- frames of DelayFrames per operand unit, not Script_pause's two. + self:waitFrames((cmd.frames or arg1(cmd) or 0) * WAIT_FRAMES_PER_UNIT) + elseif op == "checksave" then + -- pokecrystal/engine/overworld/scripting.asm:2349-2353 writes CheckSave's + -- c: 1 when both sCheckValue bytes match (events/checksave.asm:1-20). + local ok = true + if self.checkSaveFn then ok = self.checkSaveFn() and true or false end + self.scriptVar = ok and 1 or 0 + elseif op == "battletowertext" then + -- pokecrystal/engine/overworld/scripting.asm:447-452 BattleTowerText. + -- Unported: the table consumes the operand and the verb warns once. + self:noteUnknownOp(op) -- ---- commands with no engine behind them yet --------------------------- elseif op == "deactivatefacing" then -- Script_deactivatefacing: wScriptDelay = the byte (left ALONE when the @@ -1960,6 +2033,11 @@ function Vm.new(scripts, text, events, hooks) givePokeMailFn = hooks.givePokeMail, checkPokeMailFn = hooks.checkPokeMail, getLandmarkNameFn = hooks.getLandmarkName, + -- pokecrystal/engine/overworld/scripting.asm:1633-1647, and 2349-2353. + -- Absent on a Gold boot, where no opcode reaches them. + getTrainerClassNameFn = hooks.getTrainerClassName, + getNameFn = hooks.getName, + checkSaveFn = hooks.checkSave, -- loadmenu stashes a header for the verticalmenu / _2dmenu that follows; -- openMenu is the blocking half, modelled on yesorno. openMenuFn = hooks.openMenu, diff --git a/src/script/gen2/specials/battle_tower.lua b/src/script/gen2/specials/battle_tower.lua new file mode 100644 index 00000000..c2b86b9a --- /dev/null +++ b/src/script/gen2/specials/battle_tower.lua @@ -0,0 +1,397 @@ +-- The Battle Tower specials: ../pokecrystal/data/events/special_pointers.asm:132-140 and +-- :150 BattleTowerAction, :152 Menu_ChallengeExplanationCancel. + +local Specials = require("src.script.gen2.Specials") +local S = Specials.shared + +local Bag = require("src.inventory.Bag") +local BattleTower = require("src.core.gen2.BattleTower") +local RomText = require("src.core.RomText") +local Save = require("src.core.gen2.Save") +local Strings = require("src.core.Strings") + +local M = {} + +local TRUE, FALSE = S.TRUE, S.FALSE +local A = BattleTower.ACTIONS + +-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:190-196 +-- InitBattleTowerChallengeRAM. wNrOfBeatenBattleTowerTrainers is the byte the +-- room script `readmem`s, so it is zeroed in the VM's own memory store. +local function initChallengeRam(vm) + vm.btBattleEnded = 0 + vm.btBeaten = 0 + if vm.mem then vm.mem[BattleTower.WRAM_NR_BEATEN] = 0 end +end + +-- ../pokecrystal/engine/events/battle_tower/rules.asm:57-92, the far labels +-- data/generated/rom_text.lua carries. +local RULE_FALLBACKS = { + _ExcuseMeYoureNotReadyText = + Strings.source("Excuse me.\nYou're not ready."), + _OnlyThreeMonMayBeEnteredText = + Strings.source("Only three POKéMON\nmay be entered."), + _TheMonMustAllBeDifferentKindsText = + Strings.source("The {STRBUF} POKéMON\nmust all be different kinds."), + _TheMonMustNotHoldTheSameItemsText = + Strings.source("The {STRBUF} POKéMON\nmust not hold the same items."), + _YouCantTakeAnEggText = Strings.source("You can't take an\nEGG!"), + _BattleTowerReturnWhenReadyText = + Strings.source("Please return when\nyou're ready."), +} + +-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1157-1171 +-- BattleTower_CheckSaveFileExistsAndIsYours; the running game IS the loaded +-- save here, so CompareLoadedAndSavedPlayerID can only match. +local function saveFileIsYours(vm) + local record = S.save(vm) + if not (record and record.version) then return FALSE end + return Save.exists(record.version) and TRUE or FALSE +end + +-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1024-1127 and +-- :1187-1242, :1314-1447: SRAM bank 5 and WRAM bank 3 belong to the Mobile +-- System GB adapter. A cartridge that never linked reads them as zero, and +-- these are the values that zero produces. +local MOBILE_ARMS = { + [A.ACTION_05] = { value = 0, why = "s5_be46 is 0 until a mobile challenge" }, + [A.ACTION_06] = { why = "clears the mobile challenge bytes" }, + [A.ACTION_0C] = { why = "stamps the mobile challenge day" }, + [A.ACTION_0D] = { value = FALSE, why = "s5_aa47 is 0, so `and a / ret z`" }, + [A.ACTION_0F] = { value = 0, why = "w3_d090 is the adapter's status byte" }, + [A.ACTION_10] = { value = FALSE, why = "s5_a800 is 0, the .NoAction row" }, + [A.ACTION_16] = { why = "stamps the mobile news day" }, + [A.ACTION_17] = { value = FALSE, why = "s5_b2f9 is 0, so `and a / ret z`" }, + [A.LEVEL_CHECK] = { value = 0, why = "s5_b2fb is the stadium's max level" }, + [A.UBERS_CHECK] = { value = 0, why = "s5_b2fb is the stadium's max level" }, +} + +-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:951-953 +-- BattleTower_SaveOptions writes options.lua, which no `special` hook reaches. +local UNHOOKED_ARMS = { + [A.SAVEOPTIONS] = "needs a saveOptions hook in World:specialHooks", +} + +local ACTIONS = {} + +-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:978-990 +ACTIONS[A.CHECK_EXPLANATION_READ] = function(vm, tower, record) + local yours = saveFileIsYours(vm) + S.answer(vm, yours) + if yours == 0 then return end + S.answer(vm, BattleTower.saveFileFlag(record, + BattleTower.SAVEFILE_EXPLANATION)) +end + +-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1001-1008 +ACTIONS[A.SET_EXPLANATION_READ] = function(_vm, _tower, record) + BattleTower.setSaveFileFlag(record, BattleTower.SAVEFILE_EXPLANATION) +end + +-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:992-999 +ACTIONS[A.GET_CHALLENGE_STATE] = function(vm, tower) + S.answer(vm, tower.challenge) +end + +-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1010-1012 +ACTIONS[A.SAVE_AND_QUIT] = function(_vm, _tower, record) + BattleTower.setChallengeState(record, BattleTower.SAVED_AND_LEFT) +end + +-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1014-1022 +ACTIONS[A.CHALLENGECANCELED] = function(_vm, _tower, record) + BattleTower.setChallengeState(record, BattleTower.NO_CHALLENGE) +end + +-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1129-1141 +ACTIONS[A.SAVELEVELGROUP] = function(vm, tower) + tower.levelGroup = math.max(0, math.floor(tonumber(vm.btLevelGroup) or 0)) +end + +-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1143-1155 +ACTIONS[A.LOADLEVELGROUP] = function(vm, tower) + vm.btLevelGroup = tower.levelGroup +end + +-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1157-1171 +ACTIONS[A.CHECKSAVEFILEISYOURS] = function(vm) + S.answer(vm, saveFileIsYours(vm)) +end + +-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1173-1177: the +-- pending fade is dropped and the volume goes back to full, which at both +-- call sites follows a `musicfadeout MUSIC_NONE`. +ACTIONS[A.ACTION_0A] = function(vm) + local h = S.hooks(vm) + if h.stopMusic then h.stopMusic() end +end + +-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1179-1185: +-- sGSBallFlag reads back as GS_BALL_AVAILABLE once the ball has been offered, +-- and the Goldenrod scene gates itself on its own event flag afterwards. +ACTIONS[A.GSBALL] = function(vm, _tower, record) + local crystal = record and Save.crystalState(record) + local flag = crystal and crystal.gsBall + S.answer(vm, Save.GS_BALL_STATES[flag] and BattleTower.GS_BALL_AVAILABLE or 0) +end + +-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1311-1312 +-- String_MysteryJP, the OT the Mobile Stadium stamps on an Odd Egg. +local MYSTERY_OT = "\227\129\170\227\129\158\227\131\138\227\131\142" + +-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1244-1309: the +-- EGG_TICKET is spent only on a party egg carrying String_MysteryJP. +ACTIONS[A.EGGTICKET] = function(vm, _tower, record) + S.answer(vm, FALSE) + local held = record and record.inventory and record.inventory.EGG_TICKET + if not held or held <= 0 then return end + for _, mon in ipairs(S.party(vm)) do + if mon.isEgg and mon.otName == MYSTERY_OT then + mon.otName = "" + Bag.remove(record, "EGG_TICKET", 1) + return S.answer(vm, TRUE) + end + end +end + +-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1449-1461 +ACTIONS[A.ACTION_11] = function(_vm, tower) tower.reentry = false end +ACTIONS[A.ACTION_12] = function(_vm, tower) tower.reentry = true end + +-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1463-1469 +ACTIONS[A.ACTION_13] = function(vm, tower) + S.answer(vm, tower.reentry and TRUE or FALSE) +end + +-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1471-1483 +ACTIONS[A.ACTION_14] = function(vm, _tower, record) + local yours = saveFileIsYours(vm) + S.answer(vm, yours) + if yours == 0 then return end + S.answer(vm, BattleTower.saveFileFlag(record, + BattleTower.SAVEFILE_REGISTERED)) +end + +-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1485-1492 +ACTIONS[A.ACTION_15] = function(_vm, _tower, record) + BattleTower.setSaveFileFlag(record, BattleTower.SAVEFILE_REGISTERED) +end + +-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:890-904 +ACTIONS[A.RESETDATA] = function(_vm, _tower, record) + BattleTower.resetTrainers(record) +end + +-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:906-933 +ACTIONS[A.GIVEREWARD] = function(vm, tower, record) + local h = S.hooks(vm) + local reward = tower.reward or BattleTower.FALLBACK_REWARD + local data = S.data(vm) + local fits = false + if record then + record.inventory = record.inventory or {} + fits = BattleTower.rewardFits(Bag.slots(record, data, "ITEM"), + Bag.capacity(data, "ITEM"), record.inventory[reward]) + end + if not fits then reward = BattleTower.FALLBACK_REWARD end + S.answer(vm, (h.itemIndex and h.itemIndex(reward)) or 0) +end + +-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:935-949 +ACTIONS[A.ACTION_1C] = function(_vm, _tower, record) + BattleTower.setChallengeState(record, BattleTower.WON_CHALLENGE) +end +ACTIONS[A.ACTION_1D] = function(_vm, _tower, record) + BattleTower.setChallengeState(record, BattleTower.RECEIVED_REWARD) +end + +-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:955-976. +-- Specials.random answers 1..n, and `% mask` turns that into the 0..mask-1 +-- byte `maskbits` leaves in a. +ACTIONS[A.CHOOSEREWARD] = function(vm, tower) + local data = S.data(vm) + local order = data and data.gen2Constants and data.gen2Constants.itemOrder + tower.reward = BattleTower.rollReward(order, Specials.random) + or BattleTower.FALLBACK_REWARD +end + +-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:852-887, the +-- jumptable `special BattleTowerAction` dispatches on wScriptVar. +M.BattleTowerAction = function(vm) + local id = math.floor(tonumber(vm.scriptVar) or 0) + -- ../pokecrystal/ram/sram.asm:147-172, read as the zeroes a fresh cart holds. + local record = S.save(vm) or {} + local tower = BattleTower.state(record) + local run = ACTIONS[id] + if run then return run(vm, tower, record) end + local mobile = MOBILE_ARMS[id] + if mobile then + if mobile.value ~= nil then S.answer(vm, mobile.value) end + return + end + if UNHOOKED_ARMS[id] then return end +end + +-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1583-1594: the +-- carry _CheckForBattleTowerRules returns means the party FAILED, and TRUE is +-- what the script's `ifnotequal FALSE` reads as "stop here". +M.CheckForBattleTowerRules = function(vm) + local lines, failed = BattleTower.checkRules(S.party(vm)) + local data = S.data(vm) + -- ../pokecrystal/engine/events/battle_tower/rules.asm:28-31 wStringBuffer2 + vm:setStringBuffer(BattleTower.RULE_PARTY_COUNT_TEXT) + for _, label in ipairs(lines) do + vm:showRaw(RomText(data, label, RULE_FALLBACKS[label] or label)) + end + S.answer(vm, failed and TRUE or FALSE) +end + +-- ../pokecrystal/mobile/mobile_5f.asm:425-468 and its MenuData at :490-495. +-- wScriptVar comes in TRUE for the English rows and leaves holding the row +-- number, or 4 for a B press. +local CHALLENGE_MENU_ROWS = { + Strings.source("Challenge"), + Strings.source("Explanation"), + Strings.source("Cancel"), +} +local CHALLENGE_MENU_CANCEL = 4 + +-- ../pokecrystal/mobile/mobile_5f.asm:484-488 MenuHeader, `menu_coords 0, 0, +-- 14, 7` with STATICMENU_CURSOR | STATICMENU_WRAP. +local CHALLENGE_MENU_FLAGS = 0x80 + 0x20 + +M.Menu_ChallengeExplanationCancel = function(vm) + local h = S.hooks(vm) + local choice = Specials.block(vm, function(done) + if not h.scriptMenu then return done(0) end + h.scriptMenu({ items = CHALLENGE_MENU_ROWS, left = 0, top = 0, + right = 14, bottom = 7, dataFlags = CHALLENGE_MENU_FLAGS, cursor = 1 }, + done) + end) + choice = math.floor(tonumber(choice) or 0) + if choice < 1 or choice > #CHALLENGE_MENU_ROWS then + return S.answer(vm, CHALLENGE_MENU_CANCEL) + end + S.answer(vm, choice) +end + +-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1534-1550, whose +-- first act is `farcall LoadOpponentTrainerAndPokemon`: the trainer row, the +-- three mons and the two SRAM tables that stop either repeating. wScriptVar +-- comes in holding the object the opponent walks in as and is NOT written. +M.LoadOpponentTrainerAndPokemonWithOTSprite = function(vm) + local record = S.save(vm) + if not record then return end + -- wBT_OTTrainer is WRAM bank 3, so the drawn opponent rides the VM and not + -- the save; only the sBTTrainers slot and the two previous teams persist. + local opponent = BattleTower.drawOpponent( + S.data(vm), record, Specials.random) + vm.btOpponent = opponent + if not opponent then return end + -- :1552-1575: BTTrainerClassSprites[class - 1] goes into the map object + -- wScriptVar names, and GetUsedSprite loads the sheet. + local h = S.hooks(vm) + if h.setObjectSprite and opponent.sprite then + h.setObjectSprite(math.floor(tonumber(vm.scriptVar) or 0), opponent.sprite) + end +end + +-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:181-185 and the +-- RunBattleTowerTrainer arm its jumptable runs at :214-259. +-- +-- The order there is: SET battle mode forced on, wInBattleTowerBattle set, +-- HealParty, ReadBTTrainerParty (which is what steps the streak counter and +-- arms the challenge), StartBattle, HealParty again, and wBattleResult into +-- wScriptVar. A win then copies sNrOfBeatenBattleTowerTrainers into +-- wNrOfBeatenBattleTowerTrainers -- the byte the room script `readmem`s to +-- decide whether all seven are done -- and leaves `count + 1` as the digit +-- Text_NextUpOpponentNo prints. +M.BattleTowerBattle = function(vm) + vm.btBattleEnded = 0 + local record = S.save(vm) + local data = S.data(vm) + local h = S.hooks(vm) + local opponent = vm.btOpponent + if not opponent and record then + opponent = BattleTower.drawOpponent(data, record, Specials.random) + vm.btOpponent = opponent + end + -- A cache with no `battleTower` block on trainers.lua has nobody to send + -- out. LOSE is the room script's own back-out arm + -- (../pokecrystal/maps/BattleTowerBattleRoom.asm:34-37), so the challenge + -- ends rather than looping on an opponent that never appears. + if not (opponent and record) then + vm.btBattleEnded = 1 + return S.answer(vm, 1) + end + -- :229 ReadBTTrainerParty -> CopyBTTrainer_FromBT_OT_TowBT_OTTemp (:549-570) + local streak = BattleTower.beginBattle(record) + -- :228 farcall HealParty, before the send-out. + if h.healParty then h.healParty() end + local classes = data and data.trainers and data.trainers.classes + local class = classes and classes[opponent.classId] + local className = (class and class.name) or opponent.classId + local party = BattleTower.battleParty(data, opponent.rows) + local outcome = Specials.block(vm, function(done) + if not h.startTowerBattle then return done("lose") end + local started = h.startTowerBattle({ + class = opponent.class, + classId = opponent.classId, + className = className, + -- PlaceEnemysName prints the class then the trainer's own name, the + -- same pair World:startScriptedBattle builds for an overworld trainer. + name = className and (className .. " " .. opponent.name) or opponent.name, + trainerName = opponent.name, + party = party, + -- wOtherTrainerClass is a real class here, so the AI weights, the two + -- item slots and the payout all come off its own attributes row. + attributes = class and class.attributes, + baseMoney = class and class.baseMoney, + items = class and class.items, + }, done) + if not started then done("lose") end + end) + -- :235 farcall HealParty, on the way out whichever way it went. + if h.healParty then h.healParty() end + -- :236-237 wBattleResult: WIN is 0. + local won = outcome ~= "lose" + S.answer(vm, won and 0 or 1) + if won and vm.mem then + -- :240-250 + vm.mem[BattleTower.WRAM_NR_BEATEN] = streak % 256 + vm:setStringBuffer(tostring(streak + 1)) + end + -- :257-258 wBattleTowerBattleEnded = TRUE, which ends _BattleTowerBattle. + vm.btBattleEnded = 1 + vm.btOpponent = nil +end + +-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1-5, which is +-- InitBattleTowerChallengeRAM plus _BattleTowerRoomMenu +-- (../pokecrystal/mobile/mobile_46.asm:137-177). wScriptVar leaves 0 for a +-- chosen room and $a for the cancel the desk loops back on. +M.BattleTowerRoomMenu = function(vm) + initChallengeRam(vm) + local h = S.hooks(vm) + local record = S.save(vm) + local result = Specials.block(vm, function(done) + if not h.pushScreen then return done(nil) end + local ok = h.pushScreen("Gen2BattleTowerMenu", { + save = record, + party = S.party(vm), + rows = BattleTower.levelGroupRows(record), + monName = h.monName, + onDone = done, + }) + if not ok then done(nil) end + end) + local group = math.floor(tonumber(result) or 0) + if group < 1 or group > BattleTower.MAX_LEVEL_GROUP then + -- ../pokecrystal/mobile/mobile_46.asm:4609-4610 + return S.answer(vm, 0x0a) + end + vm.btLevelGroup = group + S.answer(vm, 0) +end + +return M diff --git a/src/script/gen2/specials/crystal_extras.lua b/src/script/gen2/specials/crystal_extras.lua new file mode 100644 index 00000000..72a08354 --- /dev/null +++ b/src/script/gen2/specials/crystal_extras.lua @@ -0,0 +1,508 @@ +-- ../pokecrystal/data/events/special_pointers.asm:147 MoveTutor, :161 PokeSeer, +-- :162 BuenasPassword, :163 BuenaPrize, :179 AskRememberPassword, +-- :181 UnusedFindItemInPCOrBag, plus the two rows both carts share: +-- :57 OverworldTownMap and :126 PrintDiploma. + +local Bag = require("src.inventory.Bag") +local BugContest = require("src.core.gen2.BugContest") +local Mon = require("src.battle.gen2.Mon") +local Nests = require("src.core.gen2.Nests") +local Save = require("src.core.gen2.Save") +local Specials = require("src.script.gen2.Specials") +local Strings = require("src.core.Strings") + +local S = Specials.shared + +local M = {} + +-- engine/overworld/variables.asm:65 wBlueCardBalance, :66 wBuenasPassword. +local VAR_BLUECARDBALANCE = 0x18 +local VAR_BUENASPASSWORD = 0x19 + +-- maps/RadioTower2F.asm:1 BLUE_CARD_POINT_CAP. +local BLUE_CARD_POINT_CAP = 30 + +local function hooks(vm) + return (vm and vm.specials) or {} +end + +-------------------------------------------------------------------------- +-- MoveTutor -- ../pokecrystal/engine/events/move_tutor.asm:1 +-------------------------------------------------------------------------- + +-- .GetMoveTutorMove (../pokecrystal/engine/events/move_tutor.asm:36) maps +-- MOVETUTOR_FLAMETHROWER..ICE_BEAM onto MT01..MT03, i.e. pokemon.tutorMoves. +local function tutorMove(vm, index) + local d = S.data(vm) + local list = d and d.pokemon and d.pokemon.tutorMoves + if type(list) ~= "table" then return nil end + if index ~= 1 and index ~= 2 then index = 3 end + return list[index] +end + +M.MoveTutor = function(vm) + local h = hooks(vm) + local moveId = tutorMove(vm, vm.scriptVar or 0) + -- ../pokecrystal/engine/events/move_tutor.asm:29 .cancel, which is + -- maps/GoldenrodCity.asm:72 .Incompatible. + if not (moveId and h.pushScreen) then + vm.scriptVar = 255 + return + end + local d = S.data(vm) + local moveDef = d and d.moves and d.moves[moveId] + local learned = Specials.block(vm, function(done) + local ok = h.pushScreen("Gen2MoveTutor", { + move = moveId, + moveName = (moveDef and moveDef.name) or moveId, + onDone = function(taught) done(taught and true or false) end, + }) + if not ok then done(false) end + end) + -- ../pokecrystal/engine/events/move_tutor.asm:25 `xor a ; FALSE` on the learn arm. + vm.scriptVar = learned and 0 or 255 +end + +-------------------------------------------------------------------------- +-- Buena -- ../pokecrystal/engine/events/buena.asm:1, ../pokecrystal/engine/events/buena_menu.asm:1 +-------------------------------------------------------------------------- + +-- data/radio/buenas_passwords.asm, in table order. `kind` is the BUENA_* +-- function of ../pokecrystal/constants/radio_constants.asm:128-131; `points` is both the +-- Blue Card value and the menu width ../pokecrystal/engine/events/buena.asm:9 adds. +local BUENA_PASSWORDS = { + { kind = "mon", points = 10, + words = { "CYNDAQUIL", "TOTODILE", "CHIKORITA" } }, + { kind = "item", points = 12, + words = { "FRESH_WATER", "SODA_POP", "LEMONADE" } }, + { kind = "item", points = 12, + words = { "POTION", "ANTIDOTE", "PARLYZ_HEAL" } }, + { kind = "item", points = 12, + words = { "POKE_BALL", "GREAT_BALL", "ULTRA_BALL" } }, + { kind = "mon", points = 10, + words = { "PIKACHU", "RATTATA", "GEODUDE" } }, + { kind = "mon", points = 10, + words = { "HOOTHOOT", "SPINARAK", "DROWZEE" } }, + { kind = "string", points = 16, + words = { Strings.source("NEW BARK TOWN"), Strings.source("CHERRYGROVE CITY"), + Strings.source("AZALEA TOWN") } }, + { kind = "string", points = 6, + words = { Strings.source("FLYING"), Strings.source("BUG"), + Strings.source("GRASS") } }, + { kind = "move", points = 12, + words = { "TACKLE", "GROWL", "MUD_SLAP" } }, + { kind = "item", points = 12, + words = { "X_ATTACK", "X_DEFEND", "X_SPEED" } }, + { kind = "string", points = 13, + words = { Strings.source("#MON Talk"), Strings.source("#MON Music"), + Strings.source("Lucky Channel") } }, +} + +-- ../pokecrystal/constants/radio_constants.asm:120-121. +local NUM_PASSWORD_CATEGORIES = #BUENA_PASSWORDS +local NUM_PASSWORDS_PER_CATEGORY = 3 + +-- GetBuenasPassword.StringFunctionJumptable (../pokecrystal/engine/pokegear/radio.asm:1534). +local function passwordWord(vm, group, index) + local row = BUENA_PASSWORDS[group + 1] + if not row then return "?" end + local word = row.words[index + 1] + if not word then return "?" end + if row.kind == "string" then return Strings(word) end + local d = S.data(vm) + if row.kind == "mon" then + local def = d and d.pokemon and d.pokemon[word] + return (def and def.name) or word + end + if row.kind == "item" then + local def = d and d.items and d.items[word] + return (def and def.name) or word + end + local def = d and d.moves and d.moves[word] + return (def and def.name) or word +end + +-- BuenasPassword4's two rejection rolls, packed group-high over word-low +-- (../pokecrystal/engine/pokegear/radio.asm:1470-1487). +local function rollPassword(save, day) + local buena = Save.crystalState(save).buenaPassword + buena.word = (Specials.random(NUM_PASSWORD_CATEGORIES) - 1) * 16 + + (Specials.random(NUM_PASSWORDS_PER_CATEGORY) - 1) + buena.day = day + return buena.word +end + +-- DAILYFLAGS2_BUENAS_PASSWORD_F is what makes the roll once a day +-- (../pokecrystal/engine/pokegear/radio.asm:1467, :1489); the day stamp stands in for it. +local function currentPassword(vm) + local record = S.save(vm) + if not record then return 0 end + local buena = Save.crystalState(record).buenaPassword + local today = BugContest.now().day + if buena.word == nil or buena.day ~= today then rollPassword(record, today) end + if vm.writeVarFn then vm.writeVarFn(VAR_BUENASPASSWORD, buena.word % 256) end + return buena.word % 256 +end + +-- ../pokecrystal/engine/events/buena_menu.asm:1-9: carry (NO or B) is 0 and a YES is 1. The +-- question is the script's own writetext at maps/RadioTower2F.asm:119. +M.AskRememberPassword = function(vm) + local yes = coroutine.yield({ kind = "yesorno" }) + S.answer(vm, yes and 1 or 0) +end + +-- ../pokecrystal/engine/events/buena.asm:19-23 `ld a, [wBuenasPassword] / maskbits 3 / cp c`, +-- and :44-49 .PasswordIndices, which makes the menu's answer zero based. +M.BuenasPassword = function(vm) + local h = hooks(vm) + local packed = currentPassword(vm) + local group = math.floor(packed / 16) % 16 + if group >= NUM_PASSWORD_CATEGORIES then group = 0 end + local answer = packed % 4 + local words = {} + for row = 0, NUM_PASSWORDS_PER_CATEGORY - 1 do + words[row + 1] = passwordWord(vm, group, row) + end + if not h.pushScreen then + S.answer(vm, 0) + return + end + local picked = Specials.block(vm, function(done) + local ok = h.pushScreen("Gen2BuenaPassword", { + mode = "password", + words = words, + width = BUENA_PASSWORDS[group + 1].points, + onDone = function(index) done(index or -1) end, + }) + if not ok then done(-1) end + end) + S.answer(vm, (picked == answer) and 1 or 0) +end + +-- data/items/buena_prizes.asm. +local BUENA_PRIZES = { + { item = "ULTRA_BALL", cost = 2 }, + { item = "FULL_RESTORE", cost = 2 }, + { item = "NUGGET", cost = 3 }, + { item = "RARE_CANDY", cost = 3 }, + { item = "PROTEIN", cost = 5 }, + { item = "IRON", cost = 5 }, + { item = "CARBOS", cost = 5 }, + { item = "CALCIUM", cost = 5 }, + { item = "HP_UP", cost = 5 }, +} + +-- ../pokecrystal/data/text/common_3.asm:1082-1116, the six pages BuenaPrintText cycles. +local PRIZE_TEXT = { + which = Strings.source("Which prize would\nyou like?"), + confirm = Strings.source("{STRBUF}?\nIs that right?"), + hereYouGo = Strings.source("Here you go!"), + notEnough = Strings.source("You don't have\nenough points."), + noRoom = Strings.source("You have no room\nfor it."), + comeAgain = Strings.source("Oh. Please come\nback again!"), +} + +-- wBlueCardBalance is a RETVAR_ADDR_DE var, so the point the script awards at +-- maps/RadioTower2F.asm:144-146 and this counter read one store. +local function blueCardBalance(vm) + if not vm.readVarFn then return 0 end + local value = math.floor(tonumber(vm.readVarFn(VAR_BLUECARDBALANCE)) or 0) + if value < 0 then value = 0 end + return math.min(value, BLUE_CARD_POINT_CAP) +end + +local function setBlueCardBalance(vm, value) + if vm.writeVarFn then + vm.writeVarFn(VAR_BLUECARDBALANCE, math.max(0, value) % 256) + end +end + +-- ReceiveItem into wNumItems (../pokecrystal/engine/events/buena.asm:104-110). +local function receiveItem(vm, itemId) + local record = S.save(vm) + if not record then return false end + record.inventory = record.inventory or {} + return Bag.add(record, itemId, 1, S.data(vm)) and true or false +end + +M.BuenaPrize = function(vm) + local h = hooks(vm) + if not h.pushScreen then return end + local d = S.data(vm) + local items = d and d.items + local rows = {} + for i, prize in ipairs(BUENA_PRIZES) do + local def = items and items[prize.item] + rows[i] = { + item = prize.item, + cost = prize.cost, + name = (def and def.name) or prize.item, + } + end + + while true do + -- ../pokecrystal/engine/events/buena.asm:71-83: the page is printed and the menu opens + -- over it, so the box is held the way a `yesorno` page is held. + S.showRawHeld(vm, Strings(PRIZE_TEXT.which)) + local pick = Specials.block(vm, function(done) + local ok = h.pushScreen("Gen2BuenaPassword", { + mode = "prize", + prizes = rows, + balance = blueCardBalance(vm), + onDone = function(index) done(index or 0) end, + }) + if not ok then done(0) end + end) + -- ../pokecrystal/engine/events/buena.asm:84 `jr z, .done`: 0 is the B press. + if not (type(pick) == "number" and rows[pick]) then break end + + local row = rows[pick] + vm:setStringBuffer(row.name) + S.showRawHeld(vm, Strings(PRIZE_TEXT.confirm)) + local sure = coroutine.yield({ kind = "yesorno" }) + if sure then + -- ../pokecrystal/engine/events/buena.asm:95-119: the cost is checked, then ReceiveItem, + -- and only a delivered item spends the points. + local balance = blueCardBalance(vm) + if balance < row.cost then + vm:showRaw(Strings(PRIZE_TEXT.notEnough)) + else + if not receiveItem(vm, row.item) then + vm:showRaw(Strings(PRIZE_TEXT.noRoom)) + else + setBlueCardBalance(vm, balance - row.cost) + if h.playSfxNamed then h.playSfxNamed("Sfx_Transaction") end + vm:showRaw(Strings(PRIZE_TEXT.hereYouGo)) + end + end + end + end + -- ../pokecrystal/engine/events/buena.asm:138-145 .done. + vm:showRaw(Strings(PRIZE_TEXT.comeAgain)) +end + +-------------------------------------------------------------------------- +-- PokeSeer -- ../pokecrystal/engine/events/poke_seer.asm:18 +-------------------------------------------------------------------------- + +-- data/text/common_3.asm:281-445, SeerTexts (../pokecrystal/engine/events/poke_seer.asm:289) +-- in jumptable order, with each text_ram buffer spelled as a directive. +local SEER_TEXT = { + intro = Strings.source("I see all.\nI know all…\fCertainly, I know\nof your #MON!"), + cantTell = Strings.source( + "Whaaaat? I can't\ntell a thing!\fHow could I not\nknow of this?"), + nameLocation = Strings.source("Hm… I see you met\n%s here:\v%s!"), + timeLevel = Strings.source( + "The time was\n%s!\fIts level was %s!\fAm I good or what?"), + trade = Strings.source( + "Hm… %s\ncame from %s\vin a trade?\f%s\nwas where %s\vmet %s!"), + noLocation = Strings.source( + "What!? Incredible!\fI don't understand\nhow, but it is\fincredible!\n" + .. "You are special.\fI can't tell where\nyou met it, but it\v" + .. "was at level %s.\fAm I good or what?"), + egg = Strings.source("Hey!\fThat's an EGG!\fYou can't say that\nyou've met it yet…"), + doNothing = Strings.source("Fufufu! I saw that\nyou'd do nothing!"), +} + +-- SeerAdviceTexts (../pokecrystal/engine/events/poke_seer.asm:357), `dbw level, text`. Field +-- three marks the rows that splice the nickname in. +local SEER_ADVICE = { + { 9, Strings.source( + "Incidentally…\fIt would be wise\nto raise your\f#MON with a\nlittle more care.") }, + { 29, Strings.source( + "Incidentally…\fIt seems to have\ngrown a little.\f%s seems\nto be becoming\v" + .. "more confident."), true }, + { 59, Strings.source( + "Incidentally…\f%s has\ngrown. It's gained\vmuch strength."), true }, + { 89, Strings.source( + "Incidentally…\fIt certainly has\ngrown mighty!\fThis %s\nmust have come\f" + .. "through numerous\n#MON battles.\fIt looks brimming\nwith confidence."), true }, + { 100, Strings.source( + "Incidentally…\fI'm impressed by\nyour dedication.\fIt's been a long\n" + .. "time since I've\fseen a #MON as\nmighty as this\v%s.\fI'm sure that\n" + .. "seeing %s\fin battle would\nexcite anyone."), true }, + { 255, Strings.source( + "Incidentally…\fIt would be wise\nto raise your\f#MON with a\nlittle more care.") }, +} + +-- GetCaughtTime's .times (../pokecrystal/engine/events/poke_seer.asm:203) and +-- UnknownCaughtData's "Unknown@" (:215). +local SEER_TIMES = { + Strings.source("Morning"), Strings.source("Day"), Strings.source("Night"), +} +local SEER_UNKNOWN = Strings.source("Unknown") +local SEER_NO_LEVEL = Strings.source("???") + +-- constants/pokemon_data_constants.asm:130 CAUGHT_EGG_LEVEL and +-- constants/battle_constants.asm:4 EGG_LEVEL. +local CAUGHT_EGG_LEVEL, EGG_LEVEL = 1, 5 + +-- GetCaughtLevel (../pokecrystal/engine/events/poke_seer.asm:148-179). +local function caughtLevel(byte0) + local level = byte0 % 0x40 + if level == 0 then return nil, Strings(SEER_NO_LEVEL) end + if level == CAUGHT_EGG_LEVEL then level = EGG_LEVEL end + return level, tostring(level) +end + +-- GetCaughtLocation (../pokecrystal/engine/events/poke_seer.asm:217-249); the second answer +-- is the SEERACTION_* its two sentinel arms override wSeerAction with. +local function caughtLocation(vm, byte1) + local landmark = byte1 % 0x80 + if landmark == 0 then return Strings(SEER_UNKNOWN), nil end + if landmark == Mon.LANDMARK_EVENT then return nil, "level_only" end + if landmark == Mon.LANDMARK_GIFT then return nil, "cant_tell" end + local record = Nests.landmark(S.data(vm), landmark) + local name = record and record.name + if not name then return Strings(SEER_UNKNOWN), nil end + -- engine/overworld/landmarks.asm:16 GetLandmarkName copies the break byte + -- through; engine/pokegear/townmap_convertlinebreakcharacters.asm:1 is it. + return (tostring(name):gsub("\n", " ")), nil +end + +-- SeerAdvice (../pokecrystal/engine/events/poke_seer.asm:331-355); `sub c` is one byte. +local function seerAdvice(vm, mon, level) + local diff = ((mon.level or 0) - (level or 0)) % 256 + local name = Mon.displayName(mon) + for _, row in ipairs(SEER_ADVICE) do + if diff <= row[1] then + if row[3] then return vm:showRaw(Strings(row[2], name, name)) end + return vm:showRaw(Strings(row[2])) + end + end +end + +M.PokeSeer = function(vm) + vm:showRaw(Strings(SEER_TEXT.intro)) + local _, mon = S.selectMon(vm, "choose") + -- ../pokecrystal/engine/events/poke_seer.asm:38 .cancel. + if not mon then + vm:showRaw(Strings(SEER_TEXT.doNothing)) + return + end + -- :28-29 `cp EGG / jr z, .egg`, plus the IsAPokemon test at :31. + if mon.isEgg then + vm:showRaw(Strings(SEER_TEXT.egg)) + return + end + + local byte0, byte1 = Mon.packCaughtData(mon) + -- ReadCaughtData's `.error` (../pokecrystal/engine/events/poke_seer.asm:104-105, :133). + if byte0 == 0 and byte1 == 0 then + vm:showRaw(Strings(SEER_TEXT.cantTell)) + return + end + + -- ../pokecrystal/engine/events/poke_seer.asm:110-119: the `cp [hl]` on the OT id's second + -- byte is commented out, so only the HIGH byte decides "traded". + local record = S.save(vm) + local playerId = (record and record.player and record.player.id) or 0 + local traded = math.floor((mon.otId or 0) / 256) % 256 + ~= math.floor(playerId / 256) % 256 + + local level, levelText = caughtLevel(byte0) + local place, override = caughtLocation(vm, byte1) + local name = Mon.displayName(mon) + + -- SeerAction2 / SeerAction3 (../pokecrystal/engine/events/poke_seer.asm:81-89). + if override == "cant_tell" then + vm:showRaw(Strings(SEER_TEXT.cantTell)) + return + end + -- SeerAction4 (../pokecrystal/engine/events/poke_seer.asm:91-95). + if override == "level_only" then + vm:showRaw(Strings(SEER_TEXT.noLocation, levelText)) + return seerAdvice(vm, mon, level) + end + + local time = math.floor(byte0 / 0x40) + local timeText = (time > 0 and Strings(SEER_TIMES[time])) or Strings(SEER_UNKNOWN) + + if traded then + -- SeerAction1 (../pokecrystal/engine/events/poke_seer.asm:72-79). + local ot = mon.otName or mon.ot or Strings(SEER_UNKNOWN) + vm:showRaw(Strings(SEER_TEXT.trade, name, ot, place, ot, name)) + else + -- SeerAction0 (../pokecrystal/engine/events/poke_seer.asm:64-70). + vm:showRaw(Strings(SEER_TEXT.nameLocation, name, place)) + end + vm:showRaw(Strings(SEER_TEXT.timeLevel, timeText, levelText)) + seerAdvice(vm, mon, level) +end + +-------------------------------------------------------------------------- +-- UnusedFindItemInPCOrBag -- ../pokecrystal/mobile/mobile_12_2.asm:191 +-------------------------------------------------------------------------- + +-- CheckItem against wNumPCItems, then wNumItems (../pokecrystal/mobile/mobile_12_2.asm:194, +-- :201); either hit is TRUE. +M.UnusedFindItemInPCOrBag = function(vm) + local h = hooks(vm) + local index = vm.scriptVar or 0 + local record = S.save(vm) + local d = S.data(vm) + local id + for key, def in pairs((d and d.items) or {}) do + if type(def) == "table" and def.index == index then id = key break end + end + local pc = record and record.pcItems + if id and type(pc) == "table" and (tonumber(pc[id]) or 0) > 0 then + S.answer(vm, 1) + return + end + if h.hasItem and h.hasItem(index) then + S.answer(vm, 1) + return + end + S.answer(vm, 0) +end + +-------------------------------------------------------------------------- +-- The wall map -- engine/events/specials.asm:100 +-------------------------------------------------------------------------- + +-- OverworldTownMap is `FadeToMenu / farcall _TownMap / ExitAllMenus`, so it +-- never writes wScriptVar; both callers (engine/events/std_scripts.asm:145 +-- TownMapScript and engine/overworld/decorations.asm:1005 +-- DecorationDesc_TownMapPoster) follow it with a bare `closetext`. +-- +-- _TownMap (engine/pokegear/pokegear.asm:1709) draws the SAME region map the +-- POKeGEAR's MAP card draws -- it calls Pokegear_LoadGFX and PokegearMap for +-- it -- with no card strip, no ENGINE_MAP_CARD gate and no A press, which is +-- src/ui/gen2/Pokegear.lua's `townMap` mode. +M.OverworldTownMap = function(vm) + local h = S.hooks(vm) + if not h.pushScreen then return end + Specials.block(vm, function(done) + local ok = h.pushScreen("Gen2Pokegear", { + townMap = true, + onClose = function() done(true) end, + }) + if not ok then done(false) end + end) +end + +-------------------------------------------------------------------------- +-- The printed diploma -- engine/events/specials.asm:448 +-------------------------------------------------------------------------- + +-- _PrintDiploma (engine/printer/printer.asm:382) opens with the very page +-- `special Diploma` shows -- `farcall PlaceDiplomaOnScreen`, +-- engine/events/diploma.asm:12 -- and only then reaches for the serial port. +-- The second sheet is built with hBGMapMode zeroed and SafeLoadTempTilemapToTilemap +-- puts page 1 straight back, so PrintDiplomaPage2 never reaches the screen on +-- the cartridge either: page 1 IS the whole visible routine. +-- +-- The two SendScreenToPrinter passes are the same missing peripheral +-- H.PhotoStudio and H.UnownPrinter degrade around, and .CancelPrinting +-- (maps/CeladonMansion3F.asm:60) is unreferenced, so the cart has no text for +-- a failed print and neither does this. +M.PrintDiploma = function(vm) + local h = S.hooks(vm) + if not h.showDiploma then return end + Specials.block(vm, function(done) + h.showDiploma(function() done(true) end) + end) +end + +return M diff --git a/src/script/gen2/specials/crystal_story.lua b/src/script/gen2/specials/crystal_story.lua new file mode 100644 index 00000000..548ddc69 --- /dev/null +++ b/src/script/gen2/specials/crystal_story.lua @@ -0,0 +1,298 @@ +-- ../pokecrystal/data/events/special_pointers.asm:141 GiveOddEgg, :148 +-- OmanyteChamber, :157 HoOhChamber, :159 CelebiShrineEvent, :160 +-- CheckCaughtCelebi, :164 GiveDratini, :166 BeastsCheck. + +local Specials = require("src.script.gen2.Specials") +local Mon = require("src.battle.gen2.Mon") +local UnownWords = require("src.world.gen2.UnownWords") + +local S = Specials.shared +local M = {} + +-- ../pokecrystal/constants/script_constants.asm:51 VAR_BATTLETYPE. +local VAR_BATTLETYPE = 0x03 +-- ../pokecrystal/constants/battle_constants.asm:102 BATTLETYPE_CELEBI. +local BATTLETYPE_CELEBI = 11 + +-- ../pokecrystal/engine/pokemon/search_owned.asm:6, :11, :16 -- the order the +-- routine writes into wScriptVar before each CheckOwnMonAnywhere. +local BEASTS = { "RAIKOU", "ENTEI", "SUICUNE" } + +-- ../pokecrystal/engine/pokemon/search_owned.asm:1; MonCheck is +-- CheckOwnMonAnywhere (:48) with the answer already written. +M.BeastsCheck = function(vm) + local monCheck = Specials.HANDLERS.MonCheck + local h = S.hooks(vm) + for _, name in ipairs(BEASTS) do + vm.scriptVar = (h.monIndex and h.monIndex(name)) or name + monCheck(vm) + if vm.scriptVar ~= S.TRUE then + S.answer(vm, S.FALSE) + return + end + end + S.answer(vm, S.TRUE) +end + +-- ../pokecrystal/engine/events/dratini.asm:72 .Moveset0, :79 .Moveset1. +local DRATINI = "DRATINI" +local DRATINI_MOVESETS = { + [0] = { "WRAP", "THUNDER_WAVE", "TWISTER", "EXTREMESPEED" }, + [1] = { "WRAP", "LEER", "THUNDER_WAVE", "TWISTER" }, +} + +-- ../pokecrystal/engine/events/dratini.asm:1: `cp $2 / ret nc`, then :16 +-- .CheckForDratini walks the party BACKWARDS from the last slot. +M.GiveDratini = function(vm) + local set = DRATINI_MOVESETS[vm.scriptVar or 0] + if not set then return end + local list = S.party(vm) + local target + for index = #list, 1, -1 do + local mon = list[index] + if mon and mon.species == DRATINI then + target = mon + break + end + end + if not target then return end + -- ../pokecrystal/engine/events/dratini.asm:54: each new move's PP comes from + -- Moves + MOVE_PP, so a PP Up on the replaced slot is dropped. + local defs = S.data(vm) + local moves = defs and defs.moves + target.moves = target.moves or {} + for index, id in ipairs(set) do + local pp = (moves and moves[id] and moves[id].pp) or 0 + target.moves[index] = { id = id, pp = pp, maxPp = pp } + end +end + +-- ../pokecrystal/data/events/odd_eggs.asm:14-33, the `odd_egg_prob` arguments in +-- order; the macro (:5) accumulates them and stores total * $ffff / 100. +local ODD_EGG_PERCENTS = { 8, 1, 16, 3, 16, 3, 14, 2, 10, 2, 12, 2, 10, 1 } + +local ODD_EGG_PROBABILITIES = {} +do + local total = 0 + for index, percent in ipairs(ODD_EGG_PERCENTS) do + total = total + percent + ODD_EGG_PROBABILITIES[index] = math.floor(total * 0xffff / 100) + end +end + +-- ../pokecrystal/data/events/odd_eggs.asm:37 OddEggs, one row per +-- NICKNAMED_MON_STRUCT. +local ODD_EGGS = { + { species = "PICHU", otId = 2048, experience = 125, level = 5, eggSteps = 20, + moves = { "THUNDERSHOCK", "CHARM", "DIZZY_PUNCH" }, + pp = { 30, 20, 10 }, + dvs = { attack = 0, defense = 0, speed = 0, special = 0 }, + stats = { hp = 17, attack = 9, defense = 6, speed = 11, + specialAttack = 8, specialDefense = 8 } }, + { species = "PICHU", otId = 256, experience = 125, level = 5, eggSteps = 20, + moves = { "THUNDERSHOCK", "CHARM", "DIZZY_PUNCH" }, + pp = { 30, 20, 10 }, + dvs = { attack = 2, defense = 10, speed = 10, special = 10 }, + stats = { hp = 17, attack = 9, defense = 7, speed = 12, + specialAttack = 9, specialDefense = 9 } }, + { species = "CLEFFA", otId = 4096, experience = 125, level = 5, eggSteps = 20, + moves = { "POUND", "CHARM", "DIZZY_PUNCH" }, + pp = { 35, 20, 10 }, + dvs = { attack = 0, defense = 0, speed = 0, special = 0 }, + stats = { hp = 20, attack = 7, defense = 7, speed = 6, + specialAttack = 9, specialDefense = 10 } }, + { species = "CLEFFA", otId = 768, experience = 125, level = 5, eggSteps = 20, + moves = { "POUND", "CHARM", "DIZZY_PUNCH" }, + pp = { 35, 20, 10 }, + dvs = { attack = 2, defense = 10, speed = 10, special = 10 }, + stats = { hp = 20, attack = 7, defense = 8, speed = 7, + specialAttack = 10, specialDefense = 11 } }, + { species = "IGGLYBUFF", otId = 4096, experience = 125, level = 5, + eggSteps = 20, + moves = { "SING", "CHARM", "DIZZY_PUNCH" }, + pp = { 15, 20, 10 }, + dvs = { attack = 0, defense = 0, speed = 0, special = 0 }, + stats = { hp = 24, attack = 8, defense = 6, speed = 6, + specialAttack = 9, specialDefense = 7 } }, + { species = "IGGLYBUFF", otId = 768, experience = 125, level = 5, + eggSteps = 20, + moves = { "SING", "CHARM", "DIZZY_PUNCH" }, + pp = { 15, 20, 10 }, + dvs = { attack = 2, defense = 10, speed = 10, special = 10 }, + stats = { hp = 24, attack = 8, defense = 7, speed = 7, + specialAttack = 10, specialDefense = 8 } }, + { species = "SMOOCHUM", otId = 3584, experience = 125, level = 5, + eggSteps = 20, + moves = { "POUND", "LICK", "DIZZY_PUNCH" }, + pp = { 35, 30, 10 }, + dvs = { attack = 0, defense = 0, speed = 0, special = 0 }, + stats = { hp = 19, attack = 8, defense = 6, speed = 11, + specialAttack = 13, specialDefense = 11 } }, + { species = "SMOOCHUM", otId = 512, experience = 125, level = 5, + eggSteps = 20, + moves = { "POUND", "LICK", "DIZZY_PUNCH" }, + pp = { 35, 30, 10 }, + dvs = { attack = 2, defense = 10, speed = 10, special = 10 }, + stats = { hp = 19, attack = 8, defense = 7, speed = 12, + specialAttack = 14, specialDefense = 12 } }, + { species = "MAGBY", otId = 2560, experience = 125, level = 5, eggSteps = 20, + moves = { "EMBER", "DIZZY_PUNCH" }, + pp = { 25, 10 }, + dvs = { attack = 0, defense = 0, speed = 0, special = 0 }, + stats = { hp = 19, attack = 12, defense = 8, speed = 13, + specialAttack = 12, specialDefense = 10 } }, + { species = "MAGBY", otId = 512, experience = 125, level = 5, eggSteps = 20, + moves = { "EMBER", "DIZZY_PUNCH" }, + pp = { 25, 10 }, + dvs = { attack = 2, defense = 10, speed = 10, special = 10 }, + stats = { hp = 19, attack = 12, defense = 9, speed = 14, + specialAttack = 13, specialDefense = 11 } }, + { species = "ELEKID", otId = 3072, experience = 125, level = 5, eggSteps = 20, + moves = { "QUICK_ATTACK", "LEER", "DIZZY_PUNCH" }, + pp = { 30, 30, 10 }, + dvs = { attack = 0, defense = 0, speed = 0, special = 0 }, + stats = { hp = 19, attack = 11, defense = 8, speed = 14, + specialAttack = 11, specialDefense = 10 } }, + { species = "ELEKID", otId = 512, experience = 125, level = 5, eggSteps = 20, + moves = { "QUICK_ATTACK", "LEER", "DIZZY_PUNCH" }, + pp = { 30, 30, 10 }, + dvs = { attack = 2, defense = 10, speed = 10, special = 10 }, + stats = { hp = 19, attack = 11, defense = 9, speed = 15, + specialAttack = 12, specialDefense = 11 } }, + { species = "TYROGUE", otId = 2560, experience = 125, level = 5, + eggSteps = 20, + moves = { "TACKLE", "DIZZY_PUNCH" }, + pp = { 35, 10 }, + dvs = { attack = 0, defense = 0, speed = 0, special = 0 }, + stats = { hp = 18, attack = 8, defense = 8, speed = 8, + specialAttack = 8, specialDefense = 8 } }, + { species = "TYROGUE", otId = 256, experience = 125, level = 5, eggSteps = 20, + moves = { "TACKLE", "DIZZY_PUNCH" }, + pp = { 35, 10 }, + dvs = { attack = 2, defense = 10, speed = 10, special = 10 }, + stats = { hp = 18, attack = 8, defense = 9, speed = 9, + specialAttack = 9, specialDefense = 9 } }, +} + +-- ../pokecrystal/engine/events/odd_egg.asm:93 `.Odd` is the OT NAME +-- (../pokecrystal/mobile/mobile_46.asm:7561); wOddEggName ("EGG") is the nickname. +local ODD_EGG_OT = "ODD" +local ODD_EGG_NICKNAME = "EGG" +local EGG_TICKET = "EGG_TICKET" + +-- ../pokecrystal/engine/events/odd_egg.asm:5-38, one Random word against the +-- cumulative table; the $ffff break is :17. +local function oddEggIndex(roll) + for index = 1, #ODD_EGG_PROBABILITIES do + local probability = ODD_EGG_PROBABILITIES[index] + if probability >= 0xffff then return index end + if roll <= probability then return index end + end + return #ODD_EGG_PROBABILITIES +end + +-- ../pokecrystal/engine/events/odd_egg.asm:40-48, NICKNAMED_MON_STRUCT_LENGTH +-- bytes copied verbatim. +local function buildOddEgg(data, row, save) + if not (data and row) then return nil end + local moves = {} + for index, id in ipairs(row.moves) do + local pp = row.pp[index] or 0 + moves[index] = { id = id, pp = pp, maxPp = pp } + end + local dvs = { + attack = row.dvs.attack, defense = row.dvs.defense, + speed = row.dvs.speed, special = row.dvs.special, + } + local egg = Mon.new(data, row.species, row.level, { + dvs = dvs, + moves = moves, + -- ../pokecrystal/data/events/odd_eggs.asm:57 `bigdw 0 ; HP` + hp = 0, + nickname = ODD_EGG_NICKNAME, + }) + if not egg then return nil end + egg.experience = row.experience + egg.stats = { + hp = row.stats.hp, attack = row.stats.attack, + defense = row.stats.defense, speed = row.stats.speed, + specialAttack = row.stats.specialAttack, + specialDefense = row.stats.specialDefense, + } + egg.maxHp = row.stats.hp + egg.hp = 0 + -- ../pokecrystal/mobile/mobile_46.asm:7528 writes EGG into wPartySpecies while the struct + -- keeps the hatchling's own species. + egg.isEgg = true + -- ../pokecrystal/data/events/odd_eggs.asm:53 `db 20 ; Step cycles to hatch`, + -- the byte MON_HAPPINESS holds while the thing is an egg. + egg.eggSteps = row.eggSteps + egg.ot = ODD_EGG_OT + egg.otName = ODD_EGG_OT + egg.otId = row.otId + if save then Mon.stampOT(save, egg) end + return egg +end + +-- ../pokecrystal/engine/events/odd_egg.asm:1. The party-full refusal is the +-- caller's (../pokecrystal/maps/DayCare.asm:31-32), not the routine's. +M.GiveOddEgg = function(vm) + local record = S.save(vm) + local data = S.data(vm) + if not (record and data) then return end + local party = record.party or {} + record.party = party + if #party >= Mon.PARTY_SIZE then return end + local row = ODD_EGGS[oddEggIndex(Specials.random(0x10000) - 1)] + local egg = buildOddEgg(data, row, record) + if not egg then return end + -- ../pokecrystal/engine/events/odd_egg.asm:50-57 TossItem on the EGG TICKET, + -- ahead of the party write. + local h = S.hooks(vm) + local ticket = h.itemIndex and h.itemIndex(EGG_TICKET) + if ticket and h.takeItem then h.takeItem(ticket, 1) end + party[#party + 1] = egg +end + +-- ../pokecrystal/engine/events/unown_walls.asm:1, run by +-- ../pokecrystal/maps/RuinsOfAlphHoOhChamber.asm:10. +M.HoOhChamber = function(vm) + if not UnownWords.leadIsHoOh(S.party(vm)) then return end + UnownWords.openWall(vm.events, "HO_OH") +end + +-- ../pokecrystal/engine/events/unown_walls.asm:13, run by +-- ../pokecrystal/maps/RuinsOfAlphOmanyteChamber.asm:10; :25 CheckItem then :38 +-- MON_ITEM. +M.OmanyteChamber = function(vm) + if UnownWords.wallOpened(vm.events, "OMANYTE") then return end + local h = S.hooks(vm) + local index = h.itemIndex and h.itemIndex(UnownWords.WATER_STONE) + local inPack = index and h.hasItem and h.hasItem(index) + if not inPack and not UnownWords.waterStoneSlot(S.party(vm)) then return end + UnownWords.openWall(vm.events, "OMANYTE") +end + +-- ../pokecrystal/engine/events/celebi.asm:9; :296 CelebiEvent_SetBattleType is +-- the only state it leaves, and src/world/gen2/World.lua:6161 clears that. +M.CelebiShrineEvent = function(vm) + if vm.writeVarFn then vm.writeVarFn(VAR_BATTLETYPE, BATTLETYPE_CELEBI) end + vm.celebiArmed = true +end + +-- ../pokecrystal/engine/events/celebi.asm:301 reads the bit +-- ../pokecrystal/engine/items/item_effects.asm:545 sets, gated on :542. +M.CheckCaughtCelebi = function(vm) + local caught = vm.celebiArmed == true and vm.battleOutcome == "caught" + vm.celebiArmed = nil + if caught then + local record = S.save(vm) + if record then + local Save = require("src.core.gen2.Save") + Save.crystalState(record).celebiCaught = true + end + end + S.answer(vm, caught and S.TRUE or S.FALSE) +end + +return M diff --git a/src/script/gen2/specials/unown_words.lua b/src/script/gen2/specials/unown_words.lua new file mode 100644 index 00000000..e3a25664 --- /dev/null +++ b/src/script/gen2/specials/unown_words.lua @@ -0,0 +1,29 @@ +-- ../pokecrystal/data/events/special_pointers.asm:151 DisplayUnownWords, run by all four +-- chambers: ../pokecrystal/maps/RuinsOfAlphKabutoChamber.asm:123, +-- ../pokecrystal/maps/RuinsOfAlphHoOhChamber.asm:86, ../pokecrystal/maps/RuinsOfAlphOmanyteChamber.asm:86, +-- ../pokecrystal/maps/RuinsOfAlphAerodactylChamber.asm:85. + +local Specials = require("src.script.gen2.Specials") +local UnownWords = require("src.world.gen2.UnownWords") + +local S = Specials.shared + +local M = {} + +M.DisplayUnownWords = function(vm) + local h = S.hooks(vm) + local world = h.world + local game = world and world.game + if not (game and game.stack) then return end + local wall = UnownWords.wallFor(game.data, vm.scriptVar or 0) + if not wall then return end + S.block(vm, function(done) + local screen = UnownWords.new(game, { + wall = wall, world = world, onClose = function() done(true) end, + }) + local ok = pcall(game.stack.push, game.stack, screen) + if not ok then done(false) end + end) +end + +return M diff --git a/src/sync/SyncMods.lua b/src/sync/SyncMods.lua index 814447e1..51adfda2 100644 --- a/src/sync/SyncMods.lua +++ b/src/sync/SyncMods.lua @@ -7,7 +7,7 @@ SyncMods.MAX_OPTION_TEXT = 256 local function versions() local ok, GameVersion = pcall(require, "src.core.GameVersion") if ok and GameVersion and GameVersion.ORDER then return GameVersion.ORDER end - return { "red", "blue", "yellow", "gold", "silver" } + return { "red", "blue", "yellow", "gold", "silver", "crystal" } end local function defaultDeps() diff --git a/src/ui/BagMenu.lua b/src/ui/BagMenu.lua index a59adc2e..949dcfcc 100644 --- a/src/ui/BagMenu.lua +++ b/src/ui/BagMenu.lua @@ -6,6 +6,7 @@ local ItemEffects = require("src.inventory.ItemEffects") local ListMenu = require("src.ui.ListMenu") local Runtime = require("src.mods.Runtime") local TextBox = require("src.render.TextBox") +local romText = require("src.core.RomText") local BagMenu = {} @@ -26,11 +27,19 @@ local function buildItems(game) right = (not unsellable) and ("x" .. game.save.inventory[id]) or nil, }) end + -- the $ff terminator's row: CANCEL is selectable and exits like B + -- (home/list_menu.asm:105-110, 523-528) #1685 + items[#items + 1] = { cancel = true, label = Strings("CANCEL") } return items end -local function consume(game, id) +local function consume(game, id, list) Bag.remove(game.save, id, 1) + -- engine/items/inventory.asm:131-136 + if not game.save.inventory[id] then + game.bagSavedMenuItem, game.bagListScrollOffset = 0, 0 + if list then list.index, list.scroll = 1, 0 end + end end local function save_name(game) @@ -66,6 +75,13 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker) if picker then picker:close() end end + -- .useItem_closeMenu ends at CloseStartMenu, so the START menu kept open + -- behind the bag comes down with it (start_sub_menus.asm:400-407) #1745 + local function closeBag() + list:close() + if list.closeStartMenu then list.closeStartMenu() end + end + -- field POKé FLUTE: play the tune, then the no-effect text if result == "flute_field" then require("src.core.Sound").play(game.data, "Pokeflute") @@ -84,7 +100,7 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker) -- field POKé FLUTE next to a not-yet-beaten Snorlax: "had effect" text, -- then the woke-up/battle sequence (data/scripts/story.lua snorlaxWake) if result == "flute_wake" then - list:close() + closeBag() require("src.core.Sound").play(game.data, "Pokeflute") showMessages(game, payload, function() local ow = game.overworld @@ -97,7 +113,7 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker) end if result == "consumed_escape" then -- Poké Doll - consume(game, id) + consume(game, id, list) list:close() showMessages(game, payload, function() -- ItemUsePokeDoll sets wEscapedFromBattle and never touches @@ -120,12 +136,11 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker) -- scripts -- the BICYCLE refuses with _CannotGetOffHereText and jumps -- back to ItemMenuLoop, so the bag stays open and no dismount happens -- (#513). The gate sits ahead of UseItem, before ItemUseBicycle ever - -- runs, which is why it precedes list:close() here. + -- runs, which is why it precedes the mount/dismount here. if game.save.forcedBike then showMessages(game, { Strings("You can't get off\nhere.") }) return end - list:close() local ow = game.overworld local Music = require("src.core.Music") -- IsBikeRidingAllowed (home/overworld.asm): the tilesets of @@ -146,27 +161,33 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker) return false end if game.save.onBike then + closeBag() game.save.onBike = false Music.playMap(game.data, ow and ow.map.id, false) showMessages(game, { Strings("%s got off\nthe BICYCLE.", save_name(game)) }) elseif bikeAllowed() then + closeBag() game.save.onBike = true Music.playMap(game.data, ow.map.id, true) showMessages(game, { Strings("%s got on\nthe BICYCLE!", save_name(game)) }) else + -- NoCyclingAllowedHere -> ItemUseFailed zeroes the result byte and + -- .useItem_closeMenu loops back (item_effects.asm:2305-2319) showMessages(game, { Strings("No cycling\nallowed here.") }) end return end if result == "fish" then - list:close() local ow = game.overworld local p = ow and ow.player if ow and p and ow:facingIsShoreOrWater() then + closeBag() ow:goFishing(id) return end + -- FishingInit's `ret c` -> ItemUseNotTime -> ItemUseFailed, so a rod + -- away from water leaves the bag up (item_effects.asm:1893-1901) showMessages(game, { Strings("No good! It's not\neven near water.") }) return end @@ -177,7 +198,7 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker) game.save.player.name) }) return end - consume(game, id) + consume(game, id, list) list:close() battle:throwBall(id) return @@ -197,21 +218,22 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker) -- LearnedMove1Text: text_far, sound_get_item_1, text_promptbutton -- (learn_move.asm), so the jingle rides the box showMessages(game, { Strings("%s learned\n%s!", target.nickname or - game.data.pokemon[target.species].name, mdef.name) }, nil, + game.data.pokemon[target.species].name, mdef.name) }, closePicker, TextBox.soundOpts(game, "Get_Item1")) - if result == "learn" then consume(game, id) end + if result == "learn" then consume(game, id, list) end list.items = buildItems(game) list.index = math.min(list.index, math.max(1, #list.items)) taught() else require("src.ui.Screens").push(game, "MoveLearnMenu", target, moveId, function(learned) - if learned and result == "learn" then consume(game, id) end + if learned and result == "learn" then consume(game, id, list) end if learned then list.items = buildItems(game) list.index = math.min(list.index, math.max(1, #list.items)) end if learned then taught() end + closePicker() end) end end @@ -265,8 +287,8 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker) local ow = game.overworld if ow and ESCAPE_ROPE_TILESETS[ow.map.def.tileset] and ow.map.id ~= "AGATHAS_ROOM" then - list:close() - consume(game, id) + closeBag() + consume(game, id, list) -- LeaveMapAnim spin-up + SFX_TELEPORT_EXIT_1, a fade, then land OUTSIDE -- the last Pokémon Center town door like Fly (#196), via the shared -- departure helper -- the same path Dig/Teleport take from the party menu @@ -292,7 +314,7 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker) end if result == "consumed" then - consume(game, id) + consume(game, id, list) -- refresh counts in the list for i, it in ipairs(list.items) do if it.value == id then @@ -388,6 +410,13 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker) return end + -- .chooseMon loops on a refusal, so the TM/HM picker stays up for another + -- pick (engine/items/item_effects.asm:2234, :2237) (#1686) + local machineDef = game.data.items[id] + if picker and picker.keepOpen and machineDef and machineDef.machine then + showMessages(game, payload) + return + end -- .healingItemNoEffect prints over the still-drawn party menu too, so the -- refusal closes the picker the same way (#252) showMessages(game, payload, closePicker) -- failed @@ -406,10 +435,15 @@ local function pickTargetAndUse(game, battle, id, list) local def = game.data.items[id] local opts = { pickOnly = true, + -- USE_ITEM_PARTY_MENU / EVO_STONE_PARTY_MENU, not the trade and daycare + -- NORMAL_PARTY_MENU (item_effects.asm:813, :768). #1610 + itemUse = true, battle = battle, -- HP medicine animates with the picker up (#252), RARE CANDY prints over - -- the party menu (item_effects.asm:1392-1418) - keepOpen = (not battle) and ItemEffects.keepsPartyMenuOpen(id), + -- the party menu (item_effects.asm:1392-1418); TM/HM stays up through + -- `predef LearnMove` (item_effects.asm:2238) (#1686) + keepOpen = (not battle) + and (ItemEffects.keepsPartyMenuOpen(id) or (def and def.machine ~= nil)), onSwitch = function(mon, picker) if not wantsMove then useOn(game, battle, id, mon, list, nil, picker) @@ -460,9 +494,15 @@ local function useItem(game, battle, id, list) local moveDef = game.data.moves[def.machine.move] local moveName = moveDef and moveDef.name or def.machine.move local booted = def.machine.kind == "HM" - and "Booted up an HM!" or Strings("Booted up a TM!") - showMessages(game, { booted, Strings("It contained\n%s!", moveName) }, - function() pickTargetAndUse(game, battle, id, list) end) + and romText(game.data, "_BootedUpHMText", "Booted up an HM!") + or romText(game.data, "_BootedUpTMText", "Booted up a TM!") + -- engine/items/item_effects.asm:2177 (#1686) + showMessages(game, { booted, + romText(game.data, "_TeachMachineMoveText", + "It contained\n%s!\fTeach %s\nto a POKéMON?", moveName, moveName) }, + nil, { choice = function(yes) + if yes then pickTargetAndUse(game, battle, id, list) end + end }) return end pickTargetAndUse(game, battle, id, list) @@ -484,7 +524,8 @@ function BagMenu.new(game, opts) onCancel = opts.onCancel, -- SELECT reorders items like the original bag (swap_items.asm) onSelectKey = function(item, l) - if not item then return end + -- attempts to swap the CANCEL row are ignored (swap_items.asm:19-22) + if not item or item.cancel then return end if not l.swapIndex then l.swapIndex = l.index return @@ -496,6 +537,12 @@ function BagMenu.new(game, opts) l.items = buildItems(game) end, onChoose = function(item) + -- A on CANCEL leaves the list exactly like B (home/list_menu.asm:105-110) + if item and item.cancel then + list:close() + if opts.onCancel then opts.onCancel() end + return + end local id = item.value local def = game.data.items[id] if list.swapIndex then -- A also completes a pending swap @@ -510,6 +557,12 @@ function BagMenu.new(game, opts) useItem(game, battle, id, list) return end + -- the BICYCLE never gets the option box: StartMenu_Item jumps straight + -- to .useOrTossItem (start_sub_menus.asm:340-342) #1705 + if id == "BICYCLE" then + useItem(game, battle, id, list) + return + end -- USE / TOSS submenu (the original's item options). -- data/text_boxes.asm USE_TOSS_MENU_TEMPLATE: box (13,10)-(19,14), -- text at (15,11); start_sub_menus.asm then sets wTopMenuItemY/X to @@ -548,6 +601,25 @@ function BagMenu.new(game, opts) }, { tx = 13, ty = 10, tw = 7, th = 5 })) end, }) + -- reopen on the saved cursor: engine/battle/core.asm:2230-2234 and + -- engine/menus/start_sub_menus.asm:319-323 #1732 + local n, rows = #list.items, list.cursorRows or list.rows + if n > 0 then + local saved = game.bagListScrollOffset or 0 + local index = math.min(saved + (game.bagSavedMenuItem or 0) + 1, n) + local scroll = math.min(saved, index - 1, math.max(0, n - rows)) + list.index, list.scroll = index, math.max(0, scroll, index - rows) + end + local baseUpdate = list.update + list.update = function(self, dt) + baseUpdate(self, dt) + game.bagListScrollOffset = self.scroll + game.bagSavedMenuItem = self.index - self.scroll - 1 + end + list.closeStartMenu = opts.onClose + -- the item box overlaps the kept-open START menu box, so neither docks to + -- a screen edge on its own (start_sub_menus.asm:302-329) #1745 + list.holdsUIAnchors = true return list end diff --git a/src/ui/ListMenu.lua b/src/ui/ListMenu.lua index 01d0a24c..dad1bda9 100644 --- a/src/ui/ListMenu.lua +++ b/src/ui/ListMenu.lua @@ -16,6 +16,34 @@ function ListMenu:sgbPalettes(game) return require("src.render.PaletteFX").wholeNamed(game.data, "MEWMON") end +local BLACK = { 0, 0, 0, 1 } +local MUTED_TEXT = { 0.55, 0.55, 0.55, 1 } + +-- row text runs from x=16 to the 160px screen's right margin (160-8, same +-- margin item.right right-aligns against); GAP is the blank strip kept +-- between a truncated label and item.right so the two never touch. +local ROW_LEFT = 16 +local ROW_RIGHT_MARGIN = 160 - 8 +local LABEL_GAP = 4 + +-- Truncate `text` to `pixels`, same convention as WideBattle.lua's own +-- fitName (HP-bar names facing the identical "arbitrary text vs. fixed +-- pixel budget" problem): cut on a whole glyph span, never mid-character, +-- and mark the cut with a trailing '.'. ShaderFXScreen's preset names are +-- player-supplied filenames of arbitrary length -- unclipped, a long one +-- either overlapped/garbled item.right's "CONVERT" hint or ran past the +-- screen's right edge outright. +local function fitLabel(text, pixels) + local spans = Font.split(text or "") + local n = Font.spansFitting(spans, pixels) + if n >= #spans then return text or "" end + local out = {} + for i = 1, math.max(0, n - 1) do + out[#out + 1] = (text or ""):sub(spans[i].from, spans[i].to) + end + return table.concat(out) .. "." +end + local ROWS = 7 -- LIST_MENU_BOX 4,2 - 19,12 (data/text_boxes.asm:13); 4 names from -- hlcoord 6,4 two rows apart (home/list_menu.asm:51-52, 364-365, 471-479) @@ -237,12 +265,13 @@ function ListMenu:drawItemBox() if #self.items == 0 then Font.draw(Strings("Nothing here."), ITEM_NAME_X, ITEM_TOP_Y) end - local shown = 0 + local shown, sawCancel = 0, false for row = 1, self.rows do local i = self.scroll + row local item = self.items[i] if not item then break end shown = shown + 1 + if item.cancel then sawCancel = true end local y = ITEM_TOP_Y + (row - 1) * 16 Font.draw(item.label, ITEM_NAME_X, y) if item.right then @@ -262,7 +291,7 @@ function ListMenu:drawItemBox() end -- the terminator prints CANCEL and returns before the '▼' -- (home/list_menu.asm:372, 518-524) - if shown == self.rows then + if shown == self.rows and not sawCancel then Font.drawCode(Theme.moreArrow, ITEM_MORE_X, ITEM_MORE_Y) end love.graphics.setColor(1, 1, 1, 1) @@ -282,22 +311,33 @@ function ListMenu:draw() local item = self.items[i] if not item then break end local y = 8 + row * 16 - Font.draw(item.label, 16, y) + -- item.muted: a real, selectable row that isn't fully "ready" yet (e.g. + -- ShaderFXScreen's unconverted presets) -- readable, never hidden, same + -- "always still readable" convention kit/Theme.lua's own disabled state + -- documents, just not the generic list-item shape that lived here + -- before ShaderFXScreen needed it. + local textColor = item.muted and MUTED_TEXT or BLACK + love.graphics.setColor(unpack(textColor)) + local budget = ROW_RIGHT_MARGIN - ROW_LEFT + if item.right then budget = budget - Font.width(item.right) - LABEL_GAP end + local label = fitLabel(item.label, budget) + Font.draw(label, 16, y) if item.ball then -- the Pokédex owned-ball marker tile -- one blank glyph after the name, measured in glyph advances rather -- than bytes: NIDORAN♂/♀ carry a multi-byte charmap entry, so -- `#item.label` overcounted by 2 and pushed their ball 16px right (#285) - local bx = 16 + Font.width(item.label) + 8 + 3 + local bx = 16 + Font.width(label) + 8 + 3 local by = y + 3 love.graphics.circle("fill", bx, by, 3.5) love.graphics.setColor(1, 1, 1, 1) love.graphics.rectangle("fill", bx - 3.5, by - 0.5, 7, 1) love.graphics.circle("fill", bx, by, 1.2) - love.graphics.setColor(0, 0, 0, 1) + love.graphics.setColor(unpack(textColor)) end if item.right then Font.draw(item.right, 160 - 8 - Font.width(item.right), y) end + love.graphics.setColor(unpack(BLACK)) if i == self.index then -- hollowIndex: a chosen row keeps the hollow '▷' left behind by -- pokered's PlaceUnfilledArrowMenuCursor (the old man demo's diff --git a/src/ui/MoveLearnMenu.lua b/src/ui/MoveLearnMenu.lua index 58c41b1d..5ceb00ce 100644 --- a/src/ui/MoveLearnMenu.lua +++ b/src/ui/MoveLearnMenu.lua @@ -1,7 +1,7 @@ -- "Which move should be forgotten?", replaces a move when a Pokémon with -- four moves learns a new one (engine/pokemon/learn_move.asm). Opens -- with the TryingToLearnText "Delete an older move...?" YES/NO; HM moves --- can't be forgotten; B / CANCEL gives up on the new move. +-- can't be forgotten; B gives up on the new move. local Font = require("src.render.Font") local Strings = require("src.core.Strings") @@ -66,7 +66,9 @@ end function MoveLearnMenu:update(dt) if not self.selecting then return end local input = self.game.input - local n = #self.mon.moves + 1 -- moves + CANCEL + -- wMaxMenuItem = wNumMovesMinusOne, no CANCEL row + -- engine/pokemon/learn_move.asm:144 (#1686) + local n = #self.mon.moves if input:wasPressed("up") then self.index = self.index > 1 and self.index - 1 or n elseif input:wasPressed("down") then @@ -74,23 +76,19 @@ function MoveLearnMenu:update(dt) elseif input:wasPressed("b") then self:confirmAbandon() elseif input:wasPressed("a") then - if self.index > #self.mon.moves then - self:confirmAbandon() - else - local old = self.mon.moves[self.index] - if HM_MOVES[old.id] then - -- HMCantDeleteText, then back to the forget list - local TextBox = require("src.render.TextBox") - self.game.stack:push(TextBox.new(self.game, - romText(self.game.data, "_HMCantDeleteText", - "HM techniques\ncan't be deleted!"))) - return - end - local mdef = self.game.data.moves[self.newMoveId] - self.mon.moves[self.index] = { id = self.newMoveId, pp = mdef.pp } - self.forgot = self.game.data.moves[old.id].name - self:finish(true) + local old = self.mon.moves[self.index] + if HM_MOVES[old.id] then + -- HMCantDeleteText, then back to the forget list + local TextBox = require("src.render.TextBox") + self.game.stack:push(TextBox.new(self.game, + romText(self.game.data, "_HMCantDeleteText", + "HM techniques\ncan't be deleted!"))) + return end + local mdef = self.game.data.moves[self.newMoveId] + self.mon.moves[self.index] = { id = self.newMoveId, pp = mdef.pp } + self.forgot = self.game.data.moves[old.id].name + self:finish(true) end end @@ -143,15 +141,14 @@ end function MoveLearnMenu:draw() if not self.selecting then return end - -- single-spaced move list box (TryingToLearn: TextBoxBorder at 4,7) - -- plus the port's extra CANCEL row - Font.drawBox(4, 5, 16, 7) + -- TextBoxBorder 4,7 b=4 c=14; PlaceString 6,8; cursor 5,8 + -- engine/pokemon/learn_move.asm:123-140 (#1686) + Font.drawBox(4, 7, 16, 6) love.graphics.setColor(0, 0, 0, 1) for i, mv in ipairs(self.mon.moves) do - Font.draw(self.game.data.moves[mv.id].name, 48, (5 + i) * 8) + Font.draw(self.game.data.moves[mv.id].name, 48, (7 + i) * 8) end - Font.draw(Strings("CANCEL"), 48, (6 + #self.mon.moves) * 8) - Font.drawCode(CURSOR, 40, (5 + self.index) * 8) + Font.drawCode(CURSOR, 40, (7 + self.index) * 8) -- WhichMoveToForgetText in the bottom dialogue box Font.drawBox(0, 12, 20, 6) Font.draw(Strings("Which move should"), 8, 14 * 8) diff --git a/src/ui/OptionsMenu.lua b/src/ui/OptionsMenu.lua index 503b5dfa..366cfb86 100644 --- a/src/ui/OptionsMenu.lua +++ b/src/ui/OptionsMenu.lua @@ -4,7 +4,7 @@ -- quirks), plus the port's audio rows and display rows: music/SFX -- volume (0-7), PIKACHU VOL (0-7, Yellow only: trims the PCM voice clips -- under SFX VOL), music low-pass filter (OFF/1X/2X/3X), COLORS / TILT / --- GBC FX / ZOOM / VOID FILL / VIDEO MODE, and the MODS row that opens +-- SHADER FX / ZOOM / VOID FILL / VIDEO MODE, and the MODS row that opens -- the mod manager. -- Rows are descriptors fed through the ui.options.rows hook, so mods can -- add their own; CANCEL is appended after the hook and stays fixed on the @@ -13,7 +13,7 @@ local PaletteFX = require("src.render.PaletteFX") local Pipelines = require("src.render.Pipelines") local Tilt = require("src.render.Tilt") -local GBCFX = require("src.render.GBCFX") +local ShaderFX = require("src.render.ShaderFX") local Zoom = require("src.render.Zoom") local TileRenderer = require("src.render.TileRenderer") local GameSpeed = require("src.core.GameSpeed") @@ -143,6 +143,20 @@ end local function sameRows(_, rows) return rows end +-- "OFF" plus every entry's display name, extension stripped -- a +-- Strings() lookup like RULESET's own record.name so a translation catalog +-- could rewrite "OFF" without needing to know about arbitrary preset +-- filenames. +local function shaderfxLabel(entry) + if not entry then return Strings("OFF") end + return Strings((entry.name:gsub("%.slangp$", ""))) +end + +-- Dual-shader slots: "main" backs the original SHADER +-- FX row (unchanged save key, unchanged default OFF), "secondary" backs the +-- new SHADER FX 2 row below it -- when both are set, ShaderFX.render() runs +-- main's chain into secondary's, same as stacking two RetroArch presets. + -- the vanilla rows as descriptors; each step body is the old per-index -- ladder's, so the save.options mutations are unchanged local function buildRows(game) @@ -323,10 +337,10 @@ local function buildRows(game) return true end }, -- Heads the port's display group: one tier that scales the heavy extras - -- (TILT / GBC FX / survey ZOOM) and the FPS ceiling for weaker devices. + -- (TILT / survey ZOOM) and the FPS ceiling for weaker devices. -- AUTO picks a default from the hardware; every tier is overridable. -- Re-applies live so the extras clamp (or, on a higher tier, restore to - -- the player's stored TILT / GBC FX / ZOOM) the moment the row changes. + -- the player's stored TILT / ZOOM) the moment the row changes. { id = "performance", label = Strings("PERFORMANCE"), value = function(g) return Strings(Performance.label(g.save.options.performance)) @@ -366,15 +380,30 @@ local function buildRows(game) end return true end }, - { id = "gbcfx", label = Strings("GBC FX"), + -- The generalized slang-shader-preset feature: a picker over + -- ShaderFX.list()'s real drop-in presets -- replaced GBCFX.lua's fixed + -- level ladder entirely (GBCFX.lua removed). A pushes a real list + -- screen -- OFF plus one row per .slangp found -- the same + -- "activate, not step" shape CONTROLS/MODS already use, rather than + -- cycling in place on this row. + -- ShaderFXScreen.lua does the actual list/activate; this row only opens + -- it and shows what is currently active. + { id = "shaderfx", label = Strings("SHADER FX"), value = function(g) - return GBCFX.levelLabel(g.save.options.gbcfx or 0) + return shaderfxLabel(ShaderFX.activeEntry("main")) end, - step = function(g, dir) - local o = g.save.options - o.gbcfx = wrapIndex((o.gbcfx or 0) + dir, 5) - GBCFX.setLevel(o.gbcfx) - return true + activate = function(g) + require("src.ui.Screens").push(g, "ShaderFXScreen", "main") + end }, + -- The secondary slot: same picker screen, opened on "secondary" instead + -- -- its own row so both slots are visible/settable independently + -- without a submenu inside a submenu. + { id = "shaderfx2", label = Strings("SHADER FX 2"), + value = function(g) + return shaderfxLabel(ShaderFX.activeEntry("secondary")) + end, + activate = function(g) + require("src.ui.Screens").push(g, "ShaderFXScreen", "secondary") end }, { id = "zoom", label = Strings("ZOOM"), value = function(g) @@ -565,14 +594,6 @@ local function buildRows(game) return true end }, } - -- issue #136: hide GBC FX on Android/iOS -- the present shader soft-bricks - if not GBCFX.isSupported() then - local filtered = {} - for _, row in ipairs(rows) do - if row.id ~= "gbcfx" then filtered[#filtered + 1] = row end - end - rows = filtered - end -- ORIENTATION only on the platforms Orientation.apply reaches (#1638). if not (Orientation.isAndroid() or Orientation.isIOS()) then local filtered = {} diff --git a/src/ui/PartyMenu.lua b/src/ui/PartyMenu.lua index 4e2b9535..718c5ba3 100644 --- a/src/ui/PartyMenu.lua +++ b/src/ui/PartyMenu.lua @@ -305,6 +305,7 @@ function PartyMenu.new(game, opts) self.onSwitch = opts.onSwitch self.onCancel = opts.onCancel self.pickOnly = opts.pickOnly + self.itemUse = opts.itemUse -- USE_ITEM_PARTY_MENU (item_effects.asm:813) -- Medicine keeps the picker on screen: item_effects.asm .doneHealing -- animates the party HP bar and then prints the message through -- RedrawPartyMenu with the menu STILL up, so BagMenu asks for keepOpen and @@ -725,16 +726,19 @@ end -- the party menu always prints a message in the bottom text box. With the -- normal message id that is PartyMenuBattleText ("Bring out which POKéMON?") -- when IsInBattle else PartyMenuNormalText ("Choose a POKéMON."); the swap / --- item / TM-HM ids print their own strings, which draw() handles inline. --- Pure (no side effects) so drivers can assert it. #147 +-- item / TM-HM ids print their own strings, and EVO_STONE shares +-- PartyMenuItemUseText (party_menu.asm:229 PartyMenuMessagePointers). +-- Pure (no side effects) so drivers can assert it. #147 #1610 function PartyMenu:bottomMessage() if self.swapFrom then - return "Move to where?" - elseif self.softboiledFrom or self.pickOnly then - return "Use on which one?" + return self.game.data.text._PartyMenuSwapMonText + or Strings("Move POKéMON\nwhere?") elseif self.tmhm then return self.game.data.text._PartyMenuUseTMText or Strings("Use TM on which\nPOKéMON?") + elseif self.softboiledFrom or self.itemUse then + return self.game.data.text._PartyMenuItemUseText + or Strings("Use item on which\nPOKéMON?") elseif self.battle then return self.game.data.text._PartyMenuBattleText or Strings("Bring out which\nPOKéMON?") @@ -858,38 +862,14 @@ function PartyMenu:draw() Font.drawCode(Theme.cursorHollow, 0, cursorY) end end - if self.swapFrom then - Font.draw(Strings("Move to where?"), 8, 136) - elseif self.softboiledFrom then - Font.draw(Strings("Use on which one?"), 8, 136) - elseif self.tmhm then - -- "Use TM on which\nPOKeMON?" in the standard bottom text box - -- (party_menu.asm keeps the message box for the TM/HM menu); box + line - -- geometry match TextBox's default (rows 12-17, text on rows 14/16). #210 - Font.drawBox(0, 12, 20, 6) - love.graphics.setColor(0, 0, 0, 1) - local prompt = self.game.data.text._PartyMenuUseTMText - or Strings("Use TM on which\nPOKéMON?") - local ly = 112 - for line in (prompt .. "\n"):gmatch("([^\n]*)\n") do - Font.draw(line, 8, ly) - ly = ly + 16 - end - elseif self.pickOnly then - Font.draw(Strings("Use on which one?"), 8, 136) - else - -- default field party menu (StartMenu) and the battle voluntary-switch - -- (BattleState:openParty): Gen1 prints PartyMenuNormalText / PartyMenuBattleText - -- in the standard bottom text box (party_menu.asm PartyMenuMessage), not - -- plain bottom-row text. Box + line geometry match the #210 TM/HM case and - -- TextBox's default (rows 12-17, text on rows 14/16). #147 - Font.drawBox(0, 12, 20, 6) - love.graphics.setColor(0, 0, 0, 1) - local ly = 112 - for line in (self:bottomMessage() .. "\n"):gmatch("([^\n]*)\n") do - Font.draw(line, 8, ly) - ly = ly + 16 - end + -- every message id prints through PrintText, so it lands in the standard + -- bottom text box, rows 12-17 (party_menu.asm:174). #147 #210 #1610 + Font.drawBox(0, 12, 20, 6) + love.graphics.setColor(0, 0, 0, 1) + local ly = 112 + for line in (self:bottomMessage() .. "\n"):gmatch("([^\n]*)\n") do + Font.draw(line, 8, ly) + ly = ly + 16 end if self.submenu then local n = #self.subItems diff --git a/src/ui/Screens.lua b/src/ui/Screens.lua index 59b483e1..032441a3 100644 --- a/src/ui/Screens.lua +++ b/src/ui/Screens.lua @@ -39,14 +39,33 @@ local BUILTIN = { -- screens and do have ids. local GEN2 = { "BankOfMom", - "BattleState", "BattleTransition", "BoxMenu", "CardFlip", + "BattleState", + -- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1 the CHALLENGE + -- / EXPLANATION / CANCEL desk menu, Crystal only. + "BattleTowerMenu", + "BattleTransition", "BoxMenu", + -- ../pokecrystal/engine/events/buena.asm:1 the radio-password prompt behind + -- `special BuenasPassword`, Crystal only. + "BuenaPassword", + "CardFlip", -- The Pokecenter PC's whose-PC top menu and the player's item PC behind it; -- Gen2PcMenu below is the storage system both BILL's PC rows open. "CenterPcMenu", "ContestMenu", - "CopyrightSplash", "Credits", "DayCareMenu", "DecorationMenu", "Diploma", + "CopyrightSplash", "Credits", + -- ../pokecrystal/engine/movie/intro.asm:1 CrystalIntro, the Crystal-only + -- peer of GoldSilverIntro below. + "CrystalIntro", + -- ../pokecrystal/engine/movie/splash.asm:1 SplashScreen, the Crystal-only + -- Ditto peer of GameFreakPresents below. + "CrystalSplash", + "DayCareMenu", "DecorationMenu", "Diploma", "EggHatchAnim", "ElevatorMenu", "EvolutionAnim", - "GameFreakPresents", "GoldSilverIntro", "HallOfFame", "HeldItemMenu", + "GameFreakPresents", + -- ../pokecrystal/engine/menus/init_gender.asm:23 InitGender, the Crystal-only + -- screen PlayerProfileSetup runs before Oak's speech. + "GenderSelect", + "GoldSilverIntro", "HallOfFame", "HeldItemMenu", -- Gen2InitClock is both timeset.asm screens: the new-game hour/minute pair -- OakSpeech opens with, and Mom's day-of-week wheel. "InitClock", @@ -57,7 +76,11 @@ local GEN2 = { "MailCompose", "MailMenu", "MailRead", "MailboxMenu", -- Gen2MapRadio is the in-house wall radio (`special MapRadio`), not a card. "MapRadio", - "MainMenu", "MartMenu", "MoveDeleter", "NamePick", "NamingScreen", "OakSpeech", + "MainMenu", "MartMenu", "MoveDeleter", + -- ../pokecrystal/engine/events/move_tutor.asm:1 the Goldenrod tutor's own + -- move/party pages, Crystal only. + "MoveTutor", + "NamePick", "NamingScreen", "OakSpeech", "OptionsMenu", "PackMenu", "PartyMenu", "PcMenu", "PhotoStudio", "PokedexMenu", "Pokegear", "SaveMenu", "ScriptMenu", "SlotMachine", "StartMenu", "SummaryMenu", "TitleState", "TradeAnim", "TradeMenu", @@ -66,12 +89,31 @@ local GEN2 = { "UnownPrinter", "UnownPuzzle", } +local GEN2_PENDING = { + BattleTowerMenu = true, + BuenaPassword = true, + MoveTutor = true, +} + +local function moduleExists(name) + local path = "src/ui/gen2/" .. name .. ".lua" + local handle = io.open(path, "r") + if handle then + handle:close() + return true + end + local lfs = love and love.filesystem + return (lfs and lfs.getInfo and lfs.getInfo(path)) ~= nil +end + -- The full ids, in the same order, for tests and for the mod docs. Screens.GEN2_IDS = {} for _, name in ipairs(GEN2) do local id = "Gen2" .. name BUILTIN[id] = "src.ui.gen2." .. name - Screens.GEN2_IDS[#Screens.GEN2_IDS + 1] = id + if not GEN2_PENDING[name] or moduleExists(name) then + Screens.GEN2_IDS[#Screens.GEN2_IDS + 1] = id + end end local cache = {} diff --git a/src/ui/ShaderFXParamsScreen.lua b/src/ui/ShaderFXParamsScreen.lua new file mode 100644 index 00000000..3a8a65e0 --- /dev/null +++ b/src/ui/ShaderFXParamsScreen.lua @@ -0,0 +1,161 @@ +-- Per-preset #pragma parameter editor, reached via SELECT on a converted row in +-- ShaderFXScreen. A steps and wraps, Left/Right step and clamp, SELECT resets +-- one row, START resets all. Edits are keyed by preset name, not by slot. +-- See docs/shaderfx.md. + +local ListMenu = require("src.ui.ListMenu") +local ChoiceBox = require("src.ui.ChoiceBox") +local ShaderFX = require("src.render.ShaderFX") +local Strings = require("src.core.Strings") +local Logger = require("src.core.Logger") + +local ShaderFXParamsScreen = setmetatable({}, { __index = ListMenu }) +ShaderFXParamsScreen.__index = ShaderFXParamsScreen + +local function overridesFor(game, entry) + local opts = game.save and game.save.options + local all = opts and opts.shaderfxParams + return all and all[entry.name] +end + +local function currentValue(p, overrides) + local v = overrides and overrides[p.id] + if v == nil then v = p.initial end + return v +end + +-- Trims trailing zeros but always keeps one decimal place: many real pragma +-- steps are 0.01-0.05, which a fixed-width format would hide. +local function fmt(v) + local s = ("%.3f"):format(v) + s = s:gsub("(%..-)0+$", "%1"):gsub("%.$", ".0") + return s +end + +local function buildItems(game, entry, params) + local overrides = overridesFor(game, entry) + local items = {} + for _, p in ipairs(params) do + items[#items + 1] = { + label = Strings(p.description), param = p, + right = fmt(currentValue(p, overrides)), + } + end + return items +end + +function ShaderFXParamsScreen.new(game, entry) + local params, err = ShaderFX.listParams(entry) + if not params then + Logger.error("ShaderFXParamsScreen: %s: %s", entry.name, tostring(err)) + params = {} + end + local items = buildItems(game, entry, params) + local title = Strings((entry.name:gsub("%.slangp$", ""))) + + local self = setmetatable(ListMenu.new(game, title, items, { + rows = 6, + footer = Strings("A/L/R:STEP\nSELECT:RESET START:ALL"), + }), ShaderFXParamsScreen) + + -- Re-activates whichever slot(s) currently show this preset, so an edit + -- reaches the running chain by the next frame. Cheap enough to call on every + -- step: activate() re-reads the cached artifact, it never calls the bridge. + local function applyLive(overrides) + for _, slot in ipairs(ShaderFX.SLOTS) do + local active = ShaderFX.activeEntry(slot) + if active and active.name == entry.name then + ShaderFX.activate(slot, active, overrides) + end + end + end + + local function setParam(item, value) + local p = item.param + value = math.max(p.minimum, math.min(p.maximum, value)) + local opts = game.save and game.save.options + if not opts then return end + opts.shaderfxParams = opts.shaderfxParams or {} + opts.shaderfxParams[entry.name] = opts.shaderfxParams[entry.name] or {} + opts.shaderfxParams[entry.name][p.id] = value + item.right = fmt(value) + applyLive(opts.shaderfxParams[entry.name]) + if game.writeOptions then + game:writeOptions() + elseif game.persistOptions then + game:persistOptions() + end + end + + self.onChoose = function(item) + local overrides = overridesFor(game, entry) + local cur = currentValue(item.param, overrides) + local nextVal = cur + item.param.step + if nextVal > item.param.maximum + 1e-6 then nextVal = item.param.minimum end + setParam(item, nextVal) + end + self.onSelectKey = function(item) + setParam(item, item.param.initial) + end + + -- dir=-1/+1; clamps at the ends instead of onChoose's wrap. + local function stepCurrent(dir) + local item = self.items[self.index] + if not item then return end + local overrides = overridesFor(game, entry) + local cur = currentValue(item.param, overrides) + setParam(item, cur + dir * item.param.step) + end + + -- One write + one applyLive for the whole preset, not one per row. + local function resetAll() + local opts = game.save and game.save.options + if not opts then return end + opts.shaderfxParams = opts.shaderfxParams or {} + local ov = {} + opts.shaderfxParams[entry.name] = ov + for _, item in ipairs(items) do + local v = item.param.initial + ov[item.param.id] = v + item.right = fmt(v) + end + applyLive(ov) + if game.writeOptions then + game:writeOptions() + elseif game.persistOptions then + game:persistOptions() + end + end + + -- START: confirm, then reset every row. + local function confirmResetAll() + local hint = self.footer + self.footer = Strings("RESET ALL PARAMS?") + game.stack:push(ChoiceBox.new(game, function(yes) + self.footer = hint + if yes then resetAll() end + end, { defaultNo = true })) + end + + local baseUpdate = ListMenu.update + self.update = function(self_, dt) + local input = self_.game.input + if #self_.items > 0 then + if input:wasPressed("left") then + stepCurrent(-1) + return + elseif input:wasPressed("right") then + stepCurrent(1) + return + elseif input:wasPressed("start") then + confirmResetAll() + return + end + end + baseUpdate(self_, dt) + end + + return self +end + +return ShaderFXParamsScreen diff --git a/src/ui/ShaderFXScreen.lua b/src/ui/ShaderFXScreen.lua new file mode 100644 index 00000000..8c7a1b75 --- /dev/null +++ b/src/ui/ShaderFXScreen.lua @@ -0,0 +1,159 @@ +-- The SHADER FX preset picker: OFF, every .slangp ShaderFX.list() finds, then a +-- permanent DOWNLOAD SHADERS action row. `slot` picks which of ShaderFX.SLOTS +-- A activates and which save.options key persists it. See docs/shaderfx.md. + +local ListMenu = require("src.ui.ListMenu") +local ShaderFX = require("src.render.ShaderFX") +local Strings = require("src.core.Strings") +local Screens = require("src.ui.Screens") + +local ShaderFXScreen = setmetatable({}, { __index = ListMenu }) +ShaderFXScreen.__index = ShaderFXScreen + +-- OFF plus the preset's display name with its extension stripped. +local function label(entry) + if not entry then return Strings("OFF") end + return Strings((entry.name:gsub("%.slangp$", ""))) +end + +-- One row's display state from its entry.converted, shared by new() and +-- onChoose() so a successful convert updates a row exactly as building it did. +local function applyRowState(item) + if not item.entry then return end + -- NOT `cond and a or b`: that idiom breaks when `a` is itself nil/false. + if item.entry.converted then + item.muted = false + item.right = nil + else + item.muted = true + item.right = Strings("CONVERT") + end +end + +-- OFF, every real preset, then the permanent DOWNLOAD SHADERS action row. +local function buildItems(active) + local items = { { label = label(nil), entry = nil } } + local selected = 1 + for _, entry in ipairs(ShaderFX.list()) do + local item = { label = label(entry), entry = entry } + applyRowState(item) + items[#items + 1] = item + if active and active.name == entry.name then selected = #items end + end + items[#items + 1] = { label = Strings("DOWNLOAD SHADERS"), download = true } + return items, selected +end + +-- `slot` defaults to "main" so a caller that still pushes this screen with no +-- argument keeps today's behavior rather than erroring on a nil slot key. +function ShaderFXScreen.new(game, slot) + slot = slot or "main" + local optKey = ShaderFX.OPTION_KEY[slot] + local title = (slot == "secondary") and "SHADER FX 2" or "SHADER FX" + local items, selected = buildItems(ShaderFX.activeEntry(slot)) + + local self = setmetatable(ListMenu.new(game, title, items, { + -- 7 rows leaves the title line and the one-line footer hint free. + rows = 7, + footer = Strings("SELECT:EDIT PARAMS"), + }), ShaderFXScreen) + -- Opens on the active preset (or OFF). + self.index = selected + self.downloadJob = nil + + -- Rebuilds from ShaderFX.list() (e.g. after an install adds presets), keeping + -- the cursor on the same row index (clamped) rather than resetting to OFF. + local function refresh() + local newItems = buildItems(ShaderFX.activeEntry(slot)) + self.items = newItems + self.index = math.max(1, math.min(self.index, #newItems)) + end + + -- Polls the in-flight buildbot download alongside ListMenu's own input + -- handling. installDownloaded() itself is synchronous, a sub-second job. + local baseUpdate = ListMenu.update + self.update = function(self_, dt) + if self_.downloadJob then + local st = ShaderFX.downloadStatus(self_.downloadJob) + local row = self_.items[#self_.items] + if st.status == "pending" then + local pct = st.progress and (" %d%%"):format(st.progress * 100) or "" + row.right = Strings("...") .. pct + elseif st.status == "ok" then + local copied, err, unchanged = ShaderFX.installDownloaded(st.notModified) + self_.downloadJob = nil + if unchanged then + require("src.core.Logger").info("ShaderFXScreen: buildbot presets already up to date") + row.right = Strings("UP TO DATE") + elseif copied then + require("src.core.Logger").info("ShaderFXScreen: buildbot install copied %d files", copied) + refresh() + else + require("src.core.Logger").error("ShaderFXScreen: buildbot install failed: %s", tostring(err)) + self_.items[#self_.items].right = Strings("FAILED") + end + else -- "error" or "cancelled" + self_.downloadJob = nil + require("src.core.Logger").error("ShaderFXScreen: buildbot download failed: %s", tostring(st.err)) + row.right = Strings("FAILED") + end + end + baseUpdate(self_, dt) + end + + self.onChoose = function(item) + if item.download then + -- Ignore a repeat press while one is already in flight. + if self.downloadJob then return end + self.downloadJob = ShaderFX.downloadPresets() + item.right = Strings("...") + return + end + + -- A on an unconverted preset converts it in place instead of activating; + -- the screen stays open either way, since a convert is not a selection. + if item.entry and not item.entry.converted then + local ok, err = ShaderFX.convert(item.entry) + if not ok then + require("src.core.Logger").error("ShaderFXScreen: convert failed for %s: %s", + item.entry.name, tostring(err)) + end + applyRowState(item) + return + end + + local opts = game.save and game.save.options + if not item.entry then + ShaderFX.deactivate(slot) + if opts then opts[optKey] = nil end + else + -- isConverted() is existence-only with no staleness check, so an explicit + -- selection always reconverts. Human-paced, CPU-only, never per frame. + local convOk, convErr = ShaderFX.convert(item.entry) + if not convOk then + require("src.core.Logger").error("ShaderFXScreen: reconvert failed for %s: %s", + item.entry.name, tostring(convErr)) + end + local overrides = opts and opts.shaderfxParams and opts.shaderfxParams[item.entry.name] + local ok = ShaderFX.activate(slot, item.entry, overrides) + if opts then opts[optKey] = ok and item.entry.name or nil end + end + if game.writeOptions then + game:writeOptions() + elseif game.persistOptions then + game:persistOptions() + end + self:close() + end + + -- SELECT on a real, converted row opens its pragma-parameter editor; OFF, the + -- action row and an unconverted preset have nothing to read metadata from. + self.onSelectKey = function(item) + if not item.entry or item.download or not item.entry.converted then return end + Screens.push(game, "ShaderFXParamsScreen", item.entry) + end + + return self +end + +return ShaderFXScreen diff --git a/src/ui/ShopMenu.lua b/src/ui/ShopMenu.lua index 23ecc7ad..7d8b22d0 100644 --- a/src/ui/ShopMenu.lua +++ b/src/ui/ShopMenu.lua @@ -9,10 +9,12 @@ local Bag = require("src.inventory.Bag") local ChoiceBox = require("src.ui.ChoiceBox") +local Font = require("src.render.Font") local ListMenu = require("src.ui.ListMenu") local Menu = require("src.ui.Menu") local QuantityBox = require("src.ui.QuantityBox") local Strings = require("src.core.Strings") +local TextBox = require("src.render.TextBox") local romText = require("src.core.RomText") local ShopMenu = {} @@ -21,7 +23,21 @@ local function txt(game, key, fallback) return game.data.text[key] or fallback end -local function buy(game, stock) +-- engine/events/pokemart.asm:204 (.returnToMainPokemartMenu) +local function anythingElse(game) + return txt(game, "_PokemartAnythingElseText", + Strings("Is there anything\nelse I can do?")) +end + +-- prompt refusals leave the list for the mart menu -- pokemart.asm:113 +local function refuse(game, menu, list, text) + if list then list:close() end + game.stack:push(TextBox.new(game, text, function() + menu.footer = anythingElse(game) + end)) +end + +local function buy(game, stock, menu) local items = {} for _, id in ipairs(stock) do local def = game.data.items[id] @@ -36,11 +52,14 @@ local function buy(game, stock) local greet = txt(game, "_PokemartBuyingGreetingText", "Take your time.") local notEnough = txt(game, "_PokemartNotEnoughMoneyText", Strings("You don't have\nenough money.")) + local bagFull = txt(game, "_PokemartItemBagFullText", + Strings("You can't carry\nany more items.")) local list list = ListMenu.new(game, "BUY", items, { dialogue = true, money = function() return game.save.money end, footer = greet, + onCancel = function() menu.footer = anythingElse(game) end, onChoose = function(item) local def = game.data.items[item.value] if game.save.money < def.price then @@ -66,18 +85,22 @@ local function buy(game, stock) return end if game.save.money < cost then - list.footer = notEnough + refuse(game, menu, list, notEnough) return end if not Bag.add(game.save, item.value, qty, game.data) then - list.footer = txt(game, "_PokemartItemBagFullText", - Strings("You can't carry\nany more items.")) + refuse(game, menu, list, bagFull) return end - require("src.core.Sound").play(game.data, "Purchase") game.save.money = game.save.money - cost - list.footer = txt(game, "_PokemartBoughtItemText", - Strings("Here you are!\nThank you!")) + -- SFX_PURCHASE drains before the receipt -- pokemart.asm:193 + game.stack:push(TextBox.new(game, + txt(game, "_PokemartBoughtItemText", + Strings("Here you are!\nThank you!")), + function() list.footer = greet end, + { preSound = function() + return require("src.core.Sound").play(game.data, "Purchase") + end })) end)) end, })) @@ -86,7 +109,7 @@ local function buy(game, stock) game.stack:push(list) end -local function sell(game) +local function sell(game, menu) -- Sell list is ITEMLISTMENU with wPrintItemPrices cleared -- (pokemart.asm .sellMenuLoop): name + quantity only. Price shows -- in the quantity chooser. Stuffing "xN" into the label next to a @@ -100,12 +123,22 @@ local function sell(game) right = "x" .. game.save.inventory[id], }) end - local greet = txt(game, "_PokemartBuyingGreetingText", "Take your time.") + -- engine/events/pokemart.asm:50 + local greet = txt(game, "_PokemonSellingGreetingText", + Strings("What would you\nlike to sell?")) + if #items == 0 then + refuse(game, menu, nil, txt(game, "_PokemartItemBagEmptyText", + Strings("You don't have\nanything to sell."))) + return + end + local unsellable = txt(game, "_PokemartUnsellableItemText", + Strings("I can't put a\nprice on that.")) local list list = ListMenu.new(game, "SELL", items, { dialogue = true, money = function() return game.save.money end, footer = greet, + onCancel = function() menu.footer = anythingElse(game) end, onSelectKey = function(item, l) if not item then return end if not l.swapIndex then @@ -135,8 +168,7 @@ local function sell(game) -- indexing nil below -- guards saves that already picked up a bogus -- ITEM_NONE "0" from Blue's House before that pickup was fixed (#11). if not def or def.keyItem or item.value:find("^HM_") then - list.footer = txt(game, "_PokemartUnsellableItemText", - Strings("I can't put a\nprice on that.")) + refuse(game, menu, list, unsellable) return end local unit = math.floor(def.price / 2) @@ -157,6 +189,8 @@ local function sell(game) return end game.save.money = game.save.money + unit * qty + -- home/inventory.asm:15 AddAmountSoldToMoney sounds SFX_PURCHASE + require("src.core.Sound").play(game.data, "Purchase") Bag.remove(game.save, item.value, qty) local left = game.save.inventory[item.value] if left then @@ -164,7 +198,8 @@ local function sell(game) else list:removeCurrent() end - list.footer = txt(game, "_PokemartThankYouText", "Thank you!") + -- a sale prints nothing -- engine/events/pokemart.asm:112 + list.footer = greet end)) end, })) @@ -173,15 +208,53 @@ local function sell(game) game.stack:push(list) end +-- MONEY_BOX 11,0 (data/text_boxes.asm:35) over the greeting PrintText left +-- in the bottom box (home/text_script.asm:143) +local function drawClerk(menu) + local game = menu.game + love.graphics.setColor(1, 1, 1, 1) + Font.drawBox(11, 0, 9, 3) + love.graphics.setColor(0, 0, 0, 1) + local money = ("¥%d"):format((game.save and game.save.money) or 0) + Font.draw(money, 152 - Font.width(money), 8) + love.graphics.setColor(1, 1, 1, 1) + Font.drawBox(0, 12, 20, 6) + love.graphics.setColor(0, 0, 0, 1) + if menu.footer then + local flat = {} + for _, page in ipairs(TextBox.paginate(menu.footer)) do + for _, line in ipairs(page) do flat[#flat + 1] = line end + end + local y = 112 + for i = math.max(1, #flat - 1), #flat do + Font.draw(flat[i], 8, y) + y = y + 16 + end + end + love.graphics.setColor(1, 1, 1, 1) +end + function ShopMenu.new(game, stock, onQuit) -- keepOpen: the mart menu stays underneath its list so closing the -- list lands back here; only QUIT (or B) leaves and fires onQuit - local menu = Menu.new(game, { - { label = Strings("BUY"), keepOpen = true, onSelect = function() buy(game, stock) end }, - { label = Strings("SELL"), keepOpen = true, onSelect = function() sell(game) end }, - { label = Strings("QUIT"), onSelect = onQuit }, + local menu + -- engine/events/pokemart.asm:220 .done, reached by QUIT and by B alike + local function farewell() + game.stack:push(TextBox.new(game, + txt(game, "_PokemartThankYouText", "Thank you!"), onQuit)) + end + menu = Menu.new(game, { + { label = Strings("BUY"), keepOpen = true, onSelect = function() buy(game, stock, menu) end }, + { label = Strings("SELL"), keepOpen = true, onSelect = function() sell(game, menu) end }, + { label = Strings("QUIT"), onSelect = farewell }, }, { tx = 0, ty = 0, tw = 8, th = 8 }) - menu.onCancel = onQuit + menu.onCancel = farewell + menu.footer = txt(game, "_PokemartGreetingText", + Strings("Hi there!\nMay I help you?")) + menu.draw = function(self) + drawClerk(self) + Menu.draw(self) + end return menu end diff --git a/src/ui/StartMenu.lua b/src/ui/StartMenu.lua index 65f01166..7df11e4c 100644 --- a/src/ui/StartMenu.lua +++ b/src/ui/StartMenu.lua @@ -42,9 +42,14 @@ function StartMenu.new(game) Screens.push(game, "PartyMenu", { onCancel = reopen }) end }) - table.insert(items, { label = Strings("ITEM"), onSelect = function() - Screens.push(game, "BagMenu", { onCancel = reopen }) - end }) + -- StartMenu_Item draws LIST_MENU_BOX over the still-drawn START menu and + -- only redisplays it on the way out (start_sub_menus.asm:302-329) #1745 + table.insert(items, { label = Strings("ITEM"), keepOpen = true, + onSelect = function() + Screens.push(game, "BagMenu", { onClose = function() + if menu and game.stack:top() == menu then game.stack:pop() end + end }) + end }) -- the player's name opens the trainer card (StartMenu_TrainerInfo) table.insert(items, { label = game.save.player.name or "RED", diff --git a/src/ui/gen2/BattleHud.lua b/src/ui/gen2/BattleHud.lua index 716ed85d..7822a1ae 100644 --- a/src/ui/gen2/BattleHud.lua +++ b/src/ui/gen2/BattleHud.lua @@ -100,11 +100,11 @@ end -- The 4-colour palette the HP-bar cells draw with: white, the bar's own light -- colour, the state's fill colour, black -- which is HPBarPals' two colours -- bracketed the way every Gen 2 palette is. -function BattleHud:barColors(key) +function BattleHud:barColors(key, zero) local pal = self.palettes and self.palettes.hpBar and self.palettes.hpBar[key] if not pal then return nil end return { - { 255, 255, 255 }, + zero or { 255, 255, 255 }, { pal[1][1], pal[1][2], pal[1][3] }, { pal[2][1], pal[2][2], pal[2][3] }, { 0, 0, 0 }, @@ -145,9 +145,9 @@ end -- HUD's bar minus the "HP:" prefix -- same tiles, same HPBarPals colour, same -- one-pixel-at-a-time fill. Sharing this method is what keeps the two screens -- from ever disagreeing about how full a bar looks. -function BattleHud:drawBar(hp, maxHp, tx, ty) +function BattleHud:drawBar(hp, maxHp, tx, ty, zero) local pixels = HpBar.pixels(hp, maxHp) - local colors = self:barColors(HpBar.palette(pixels)) + local colors = self:barColors(HpBar.palette(pixels), zero) for cell = 0, HpBar.LENGTH_TILES - 1 do local remaining = pixels - cell * 8 local filled = math.max(0, math.min(8, remaining)) @@ -160,9 +160,11 @@ end -- "HP:" plus the six bar cells plus the end cap, starting at tile (tx, ty). -- Returns the column just past the assembly (tx + 9), so the caller can put the -- frame's vertical stub there. -function BattleHud:drawHpBar(hp, maxHp, tx, ty) +-- `zero` overrides colour 0: the stats screen puts the page tint there +-- (engine/gfx/color.asm:386-390). #1693 +function BattleHud:drawHpBar(hp, maxHp, tx, ty, zero) local pixels = HpBar.pixels(hp, maxHp) - local colors = self:barColors(HpBar.palette(pixels)) + local colors = self:barColors(HpBar.palette(pixels), zero) -- The "HP:" badge sits inside the bar's own attrmap region, so it wears the -- HP palette too: its background is HPBarPals' light colour (the cream the -- cart shows) and its letters are black. Drawing it white-on-black was the @@ -170,7 +172,7 @@ function BattleHud:drawHpBar(hp, maxHp, tx, ty) self:drawTile("hpBar", FIRST_BATTLE_EXTRA, TILE_HP_LABEL, tx, ty, colors) self:drawTile("hpBar", FIRST_BATTLE_EXTRA, TILE_HP_LABEL + 1, tx + 1, ty, colors) - self:drawBar(hp, maxHp, tx + 2, ty) + self:drawBar(hp, maxHp, tx + 2, ty, zero) self:drawTile("hpBar", FIRST_BATTLE_EXTRA, TILE_BAR_END, tx + 2 + HpBar.LENGTH_TILES, ty, colors) return tx + 3 + HpBar.LENGTH_TILES @@ -198,23 +200,23 @@ local EXP_PARTIAL_BASE = 0x54 -- $54 + remainder lands in ExpBarGFX -- PAL_BATTLE_BG_EXP, which the attrmap lays over (10,11)..(18,11) -- (engine/gfx/cgb_layouts.asm:142-145). -function BattleHud:expColors() +function BattleHud:expColors(zero) local pal = self.palettes and self.palettes.expBar if not pal then return nil end return { - { 255, 255, 255 }, + zero or { 255, 255, 255 }, { pal[1][1], pal[1][2], pal[1][3] }, { pal[2][1], pal[2][2], pal[2][3] }, { 0, 0, 0 }, } end -function BattleHud:drawExpBar(fraction, tx, ty) +function BattleHud:drawExpBar(fraction, tx, ty, zero) if not self:image("hpBar") then return false end fraction = math.max(0, math.min(1, fraction or 0)) local pixels = math.floor(fraction * BattleHud.EXP_LENGTH_PX) -- The whole row wears the exp bar's palette, full and empty cells included. - local colors = self:expColors() + local colors = self:expColors(zero) local remaining = pixels for cell = BattleHud.EXP_CELLS - 1, 0, -1 do diff --git a/src/ui/gen2/BattleState.lua b/src/ui/gen2/BattleState.lua index f5d9adae..64eed26f 100644 --- a/src/ui/gen2/BattleState.lua +++ b/src/ui/gen2/BattleState.lua @@ -25,10 +25,12 @@ local Catching = require("src.battle.gen2.Catching") local Chrome = require("src.ui.gen2.Chrome") local Evolution = require("src.core.gen2.Evolution") local GbcPalette = require("src.render.GbcPalette") +local Gen2Save = require("src.core.gen2.Save") local Font = require("src.render.Font") local HpBar = require("src.battle.gen2.HpBar") local ItemEffects = require("src.core.gen2.ItemEffects") local Mon = require("src.battle.gen2.Mon") +local MonAnim = require("src.render.MonAnim") local Palettes = require("src.world.gen2.Palettes") local Pokerus = require("src.core.gen2.Pokerus") local Prize = require("src.battle.gen2.Prize") @@ -207,6 +209,12 @@ end function BattleState:wantsFillScale() return true end function BattleState:drawsWidescreen() return true end +-- BATTLE BG (#1709): WHITE is the cart's paper surround, BLACK plain bars. +function BattleState:bgMode() + local options = self.game and self.game.options + return (options and options.battleBg) == "black" and "black" or "white" +end + function BattleState:bottomUIVisible() if not Runtime.wantsHook("battle.bottom_ui_visible") then return true end return Runtime.call("battle.bottom_ui_visible", function() return true end, @@ -306,6 +314,11 @@ function BattleState.new(game, opts) self.playerBackTrueColor = false local hudGfx = data.gen2MenuGfx and data.gen2MenuGfx.battleHud local backPath = hudGfx and hudGfx.playerBack + -- GetTrainerBackpic's gender arm, under the Dude exception + -- (../pokecrystal/engine/battle/core.asm:8984-9008). + if hudGfx and hudGfx.playerBackFemale and Gen2Save.isFemale(self.save) then + backPath = hudGfx.playerBackFemale + end if self.tutorial and hudGfx and hudGfx.dudeBack then backPath = hudGfx.dudeBack end @@ -586,6 +599,65 @@ function BattleState:pic(mon, back) return cached or nil, trueColor, path end +-- Crystal's animated front pics. A cache with no `anim` row -- every Gold +-- and Silver one -- gets nil here and the static pic is drawn as before. +function BattleState:animData(mon) + local def = self.pokemon and mon and self.pokemon[mon.species] + if not def then return nil end + if mon.species == Unown.SPECIES and def.letters then + local entry = def.letters[Unown.monLetter(mon)] + if entry and entry.anim then return entry.anim end + end + return def.anim +end + +-- ANIM_MON_NORMAL, the scene BattleStartMessage runs on the enemy's frontpic. +-- ../pokecrystal/engine/battle/core.asm:9112-9113 +function BattleState:startFrontAnim(mon) + self.frontAnim = nil + local data = self:animData(mon) + if not data then return end + local cached = self.picCache[data.sheet] + if cached == nil then + local ok, image = pcall(Assets.image, data.sheet) + cached = ok and image or false + self.picCache[data.sheet] = cached + end + if not cached then return end + local runner = MonAnim.new(data, "battle") + if not runner then return end + local size = data.tiles * 8 + self.frontAnim = { mon = mon, runner = runner, sheet = cached, size = size, + quads = {} } +end + +-- AnimateFrontpic's .loop, one scene command per frame. +-- ../pokecrystal/engine/gfx/pic_animation.asm:79-89 +function BattleState:stepFrontAnim() + local state = self.frontAnim + if not state then return end + state.runner:update() + if state.runner:finished() then self.frontAnim = nil end +end + +-- The sheet is one column of whole pictures, base first, so a frame is one +-- quad at the size the static pic would have been. +function BattleState:frontAnimFrame(mon) + local state = self.frontAnim + if not (state and mon and state.mon == mon) then return nil end + local frame = state.runner:currentFrame() + if frame <= 0 then return nil end + local quad = state.quads[frame] + if not quad then + local w, h = state.sheet:getDimensions() + if (frame + 1) * state.size > h then return nil end + quad = love.graphics.newQuad(0, frame * state.size, state.size, state.size, + w, h) + state.quads[frame] = quad + end + return state.sheet, quad, state.size +end + -- The battle_sprite_scales registry: record id -> { path, scale }, keyed by the -- ASSET PATH the pic is drawn from rather than by species, which is the only -- handle there is on the pics that are not a species' own (the player's @@ -696,6 +768,13 @@ function BattleState:drawPic(mon, back) end local G = love.graphics local w, h = image:getDimensions() + -- Crystal only: the frame the animation is showing replaces the static + -- picture in the same box, at the same size. + local animSheet, animQuad, animSize + if not (back or trainerBack or enemyTrainer or doll) then + animSheet, animQuad, animSize = self:frontAnimFrame(mon) + if animSheet then w, h = animSize, animSize end + end local px, py local boxTiles if back then @@ -772,6 +851,10 @@ function BattleState:drawPic(mon, back) scale, scale) return end + if animQuad then + G.draw(animSheet, animQuad, px, py, 0, scale, scale) + return + end G.draw(image, px, py, 0, scale, scale) end -- A mod-supplied pic that says it is already coloured is drawn as it is: @@ -1588,7 +1671,10 @@ function BattleState:advanceQueue() if event.intro then self.introTextShown = true end -- BattleStartMessage's `.not_shiny` cries the wild mon before its own line -- (engine/battle/core.asm:8718-8721). - if event.cry then self:playCry(event.cry) end + if event.cry then + self:playCry(event.cry) + self:startFrontAnim(event.cry) + end -- A text_asm tail that plays its own sound, the way Text_BallCaught's -- sound_caught_mon rides the "Gotcha!" line rather than following it. if event.sfx then @@ -1694,6 +1780,7 @@ function BattleState:finishSendOut(after) if not after then return end self:playCry(after.mon) if after.side == "enemy" then + self:startFrontAnim(after.mon) self.showEnemyHud = true else self.showPlayerHud = true @@ -1964,6 +2051,7 @@ function BattleState:update(_dt) -- this state is only still here because ExitBattle has not cleaned up yet. if self.phase == "evolving" or self.phase == "done" then return end self:updateAlarm() + self:stepFrontAnim() local input = self.game and self.game.input if not input then return end @@ -2102,15 +2190,21 @@ function BattleState:update(_dt) end if self.phase == "menu" then - -- 2x2 grid: left/right swap the column, up/down the row. + -- 2x2 grid: left/right swap the column, up/down the row. No + -- STATICMENU_WRAP in BattleMenuHeader (engine/battle/menu.asm:31-33), so + -- the cursor clamps at each edge (engine/menus/menu.asm:156-166) (#1706). -- MenuClickSound / PlayClickSFX (home/menu.asm:746-762): SFX_READ_TEXT_2 -- on A/B only, never on D-pad. - if input:wasPressed("left") or input:wasPressed("right") then - self.menuIndex = self.menuIndex % 2 == 1 and self.menuIndex + 1 - or self.menuIndex - 1 - elseif input:wasPressed("up") or input:wasPressed("down") then - self.menuIndex = self.menuIndex <= 2 and self.menuIndex + 2 - or self.menuIndex - 2 + local col = (self.menuIndex - 1) % 2 + local row = math.floor((self.menuIndex - 1) / 2) + if input:wasPressed("left") then + self.menuIndex = row * 2 + math.max(0, col - 1) + 1 + elseif input:wasPressed("right") then + self.menuIndex = row * 2 + math.min(1, col + 1) + 1 + elseif input:wasPressed("up") then + self.menuIndex = math.max(0, row - 1) * 2 + col + 1 + elseif input:wasPressed("down") then + self.menuIndex = math.min(1, row + 1) * 2 + col + 1 elseif input:wasPressed("a") then self:playSfx("Sfx_ReadText2") self:chooseMenu(MENU_ACTION[MENU[self.menuIndex]]) @@ -2489,6 +2583,7 @@ function BattleState:openTutorialPack() end Screens.push(self.game, "Gen2PackMenu", { battle = true, + tutorial = true, save = CatchTutorial.dudeSave(), -- An empty world rather than the real one: a DUDE pocket must not reach -- World:useFieldItem, because these buffers are not the player's bag and @@ -2736,8 +2831,9 @@ function BattleState:pushCaught(enemy, itemId) -- PC's own move is InsertPokemonIntoBox, which inserts at the cursor. table.insert(box, 1, enemy) -- SendMonIntoBox refills the boxed slot's PP before it closes SRAM - -- (move_mon.asm:1062-1063). - Boxes.restorePP(enemy) + -- (move_mon.asm:1062-1063); the box_struct it writes carries no HP and no + -- status at all (macros/ram.asm:7-26). #1696 + Boxes.enterBox(enemy) -- `.SendToPC` re-reads sBoxCount AFTER the insert and sets -- BATTLERESULT_BOX_FULL when the box has just filled -- (item_effects.asm:612-619); Script_reloadmapafterbattle tests that bit diff --git a/src/ui/gen2/BattleTowerMenu.lua b/src/ui/gen2/BattleTowerMenu.lua new file mode 100644 index 00000000..b47c0178 --- /dev/null +++ b/src/ui/gen2/BattleTowerMenu.lua @@ -0,0 +1,220 @@ +-- ../pokecrystal/mobile/mobile_46.asm:137-177 _BattleTowerRoomMenu and the +-- jumptable at :625-641 it drives. + +local BattleTower = require("src.core.gen2.BattleTower") +local Chrome = require("src.ui.gen2.Chrome") +local Sound = require("src.core.Sound") +local Strings = require("src.core.Strings") + +local BattleTowerMenu = {} +BattleTowerMenu.__index = BattleTowerMenu +BattleTowerMenu.isOpaque = false + +-- ../pokecrystal/mobile/mobile_46.asm:3854-3858 MenuHeader_119cf7, +-- `menu_coords 12, 7, SCREEN_WIDTH - 1, TEXTBOX_Y - 1`. +local PICK_X, PICK_Y, PICK_W, PICK_H = 12, 7, 8, 5 +-- ../pokecrystal/mobile/mobile_46.asm:1178-1183, :1222 and :1147 +local ROW_X, ROW_Y = 13, 9 +local ARROW_X, UP_Y, DOWN_Y = 16, 8, 10 + +-- ../pokecrystal/mobile/mobile_46.asm:4635-4639 MenuHeader_11a2de and +-- :4516-4530 BattleTowerRoomMenu2_PlaceYesNoMenu. +local YN_X, YN_Y, YN_W, YN_H = 14, 7, 6, 5 +local YN_TEXT_X, YES_Y, NO_Y = 16, 8, 10 + +-- ../pokecrystal/constants/text_constants.asm:25-32, the box SpeechTextbox +-- fills at ../pokecrystal/mobile/mobile_46.asm:5267. +local SAY_X, SAY_Y, SAY_W, SAY_H = 0, 12, 18, 4 +local SAY_TEXT_X, SAY_TEXT_Y = 1, 14 + +-- ../pokecrystal/mobile/mobile_46.asm:3803-3813, `ld a, $80 / ld [wcd50]` +-- counted down one per frame before the menu restarts. +local MESSAGE_FRAMES = 0x80 + +-- ../pokecrystal/mobile/mobile_46.asm:4609-4610, the code +-- BattleTowerRoomMenu_Cleanup (:512-513) copies into wScriptVar. The chosen +-- room is a separate byte (w3_d800 at :1285), so onDone answers the level +-- group and nil for the cancel. +BattleTowerMenu.CANCELLED = 0x0a + +-- ../pokecrystal/mobile/mobile_46.asm:5488-5491 +local PICK_TEXT = Strings.source("What level do you\nwant to challenge?") +-- ../pokecrystal/mobile/mobile_46.asm:5459-5462 +local TOPS_TEXT = Strings.source("A party POKéMON\ntops this level.") +-- ../pokecrystal/mobile/mobile_46.asm:5464-5471 +local UBER_TEXT = Strings.source( + "%s may go\nonly to BATTLE\nROOMS that are\nLv.70 or higher.") +-- ../pokecrystal/mobile/mobile_46.asm:5473-5476 +local QUIT_TEXT = Strings.source("Cancel your BATTLE\nROOM challenge?") +-- ../pokecrystal/constants/charmap.asm:89 and :192, tiles $61 and $ee. +local UP_ARROW = "\xe2\x96\xb2" +local DOWN_ARROW = "\xe2\x96\xbc" + +-- ../pokecrystal/mobile/mobile_46.asm:3880 and :4623-4627 +local CANCEL_LABEL = Strings.source("CANCEL") +local YES_LABEL = Strings.source("YES") +local NO_LABEL = Strings.source("NO") + +-- ../pokecrystal/mobile/mobile_46.asm:3869-3879 Strings_L10ToL100, six tiles +-- a row. +function BattleTowerMenu.levelLabel(group) + return string.format(" L:%-3d", group * 10):sub(1, 6) +end + +-- opts: save, party, rows (BattleTower.levelGroupRows), monName, +-- onDone(levelGroup) with nil for the cancel +function BattleTowerMenu.new(game, opts) + opts = opts or {} + local self = setmetatable({}, BattleTowerMenu) + self.game = game + self.data = (game and game.data) or {} + self.save = opts.save or (game and game.save) + self.party = opts.party or (self.save and self.save.party) or {} + self.rows = opts.rows or BattleTower.levelGroupRows(self.save) + self.monName = opts.monName + self.onDone = opts.onDone + -- `ld a, $1 / ld [wcd4f], a` (../pokecrystal/mobile/mobile_46.asm:1152-1153) + self.cursor = 1 + self.phase = "pick" + self.message = PICK_TEXT + return self +end + +function BattleTowerMenu:wantsFillScale() return true end + +function BattleTowerMenu:playSfx(name) + local sfx = self.data.audio and self.data.audio.sfx + if sfx and sfx[Sound.resolve(self.data, name)] then + Sound.play(self.data, name) + end +end + +-- The last row is CANCEL (../pokecrystal/mobile/mobile_46.asm:1160, :1165). +function BattleTowerMenu:rowCount() + return #self.rows + 1 +end + +function BattleTowerMenu:finish(group) + if self.done then return end + self.done = true + local game = self.game + if game and game.stack and game.stack:top() == self then game.stack:pop() end + if self.onDone then self.onDone(group) end +end + +-- ../pokecrystal/mobile/mobile_46.asm:3794-3816: the refusal is printed, held +-- for $80 frames and the menu restarts at jumptable index 0. +function BattleTowerMenu:refuse(text) + self.phase = "message" + self.message = text + self.wait = MESSAGE_FRAMES +end + +-- ../pokecrystal/mobile/mobile_46.asm:1258-1286 `.a_button` +function BattleTowerMenu:confirm() + local row = self.rows[self.cursor] + if not row then + -- ../pokecrystal/mobile/mobile_46.asm:1291-1303 `.asm_118a3c` + self.phase = "quit" + self.message = QUIT_TEXT + self.yes = true + return + end + if BattleTower.levelCheck(self.party, row.group) then + return self:refuse(TOPS_TEXT) + end + local uber = BattleTower.ubersCheck(self.party, row.group) + if uber then + local name = (self.monName and self.monName(uber)) or uber + return self:refuse(string.format(UBER_TEXT, name)) + end + self:finish(row.group) +end + +-- ../pokecrystal/mobile/mobile_46.asm:1240-1256: UP walks the level up and +-- rolls over to the first row, DOWN walks it back and rolls over to CANCEL. +function BattleTowerMenu:updatePick() + local input = self.game and self.game.input + if not input then return end + local count = self:rowCount() + if input:wasPressed("up") then + self.cursor = (self.cursor < count) and self.cursor + 1 or 1 + elseif input:wasPressed("down") then + self.cursor = (self.cursor > 1) and self.cursor - 1 or count + elseif input:wasPressed("a") then + self:playSfx("Sfx_ReadText2") + self:confirm() + elseif input:wasPressed("b") then + self:playSfx("Sfx_ReadText2") + self.phase = "quit" + self.message = QUIT_TEXT + self.yes = true + end +end + +-- ../pokecrystal/mobile/mobile_46.asm:4535-4621 +-- BattleTowerRoomMenu2_UpdateYesNoMenu. +function BattleTowerMenu:updateQuit() + local input = self.game and self.game.input + if not input then return end + if input:wasPressed("up") then + self.yes = true + elseif input:wasPressed("down") then + self.yes = false + elseif input:wasPressed("a") then + self:playSfx("Sfx_ReadText2") + if self.yes then return self:finish(nil) end + self.phase = "pick" + self.message = PICK_TEXT + self.cursor = 1 + elseif input:wasPressed("b") then + self:playSfx("Sfx_ReadText2") + self.phase = "pick" + self.message = PICK_TEXT + self.cursor = 1 + end +end + +function BattleTowerMenu:update(_dt) + if self.done then return end + if self.phase == "message" then + self.wait = (self.wait or 0) - 1 + if self.wait > 0 then return end + self.phase = "pick" + self.message = PICK_TEXT + self.cursor = 1 + return + end + if self.phase == "quit" then return self:updateQuit() end + return self:updatePick() +end + +function BattleTowerMenu:drawPanel() + Chrome.textbox(SAY_X, SAY_Y, SAY_W, SAY_H) + Chrome.printWrapped(self.message, SAY_TEXT_X, SAY_TEXT_Y, SAY_W, SAY_H) + if self.phase == "message" then + love.graphics.setColor(1, 1, 1, 1) + return + end + if self.phase == "quit" then + Chrome.box(YN_X, YN_Y, YN_W, YN_H) + Chrome.print(YES_LABEL, YN_TEXT_X, YES_Y) + Chrome.print(NO_LABEL, YN_TEXT_X, NO_Y) + Chrome.cursor(YN_TEXT_X - 1, self.yes and YES_Y or NO_Y) + love.graphics.setColor(1, 1, 1, 1) + return + end + Chrome.box(PICK_X, PICK_Y, PICK_W, PICK_H) + local row = self.rows[self.cursor] + Chrome.print(row and BattleTowerMenu.levelLabel(row.group) or CANCEL_LABEL, + ROW_X, ROW_Y) + Chrome.print(UP_ARROW, ARROW_X, UP_Y) + Chrome.print(DOWN_ARROW, ARROW_X, DOWN_Y) + love.graphics.setColor(1, 1, 1, 1) +end + +function BattleTowerMenu:draw() + self:drawPanel() +end + +return BattleTowerMenu diff --git a/src/ui/gen2/BoxMenu.lua b/src/ui/gen2/BoxMenu.lua index 1d7d28e4..027ccf42 100644 --- a/src/ui/gen2/BoxMenu.lua +++ b/src/ui/gen2/BoxMenu.lua @@ -432,7 +432,7 @@ function BoxMenu:insertMon() table.insert(dest, math.max(1, math.min(target, #dest + 1)), mon) -- .CopyToBox is InsertPokemonIntoBox, which tails into -- RestorePPOfDepositedPokemon (engine/pokemon/move_mon_wo_mail.asm:35-37). - if not self:isParty(destIndex) then Boxes.restorePP(mon) end + if not self:isParty(destIndex) then Boxes.enterBox(mon) end self.phase = nil self.moveFrom, self.backup = nil, nil self.index, self.scroll = 1, 0 @@ -453,9 +453,8 @@ function BoxMenu:cancelMove() self:clampIndex() end --- BillsPC_PressLeft / BillsPC_PressRight. The move screen wraps through box 0 --- (the PARTY); the withdraw list has no party to walk into, so it wraps inside --- the fourteen boxes. +-- BillsPC_PressLeft / BillsPC_PressRight, reached only from +-- MoveMonWithoutMail_DPad: the move screen wraps through box 0 (the PARTY). function BoxMenu:stepBox(delta) local low = self.mode == "move" and PARTY_BOX or 1 local span = Boxes.NUM_BOXES - low + 1 @@ -531,9 +530,11 @@ function BoxMenu:update(_dt) elseif input:wasPressed("down") then self.index = self.index < total and self.index + 1 or 1 self:ensureVisible() - elseif input:wasPressed("left") and self.mode ~= "deposit" then + -- Withdraw_UpDown reads PAD_UP and PAD_DOWN and nothing else; only + -- MoveMonWithoutMail_DPad walks the boxes (bills_pc.asm:806-820, :822-845). + elseif input:wasPressed("left") and self.mode == "move" then self:stepBox(-1) - elseif input:wasPressed("right") and self.mode ~= "deposit" then + elseif input:wasPressed("right") and self.mode == "move" then self:stepBox(1) elseif input:wasPressed("a") then self:act() diff --git a/src/ui/gen2/BuenaPassword.lua b/src/ui/gen2/BuenaPassword.lua new file mode 100644 index 00000000..c60adc9f --- /dev/null +++ b/src/ui/gen2/BuenaPassword.lua @@ -0,0 +1,170 @@ +-- Buena's two windows, Crystal only, chosen by `mode`: +-- +-- password ../pokecrystal/engine/events/buena.asm:1 BuenasPassword, whose +-- .MenuHeader is `menu_coords 0, 0, 10, 7` with the right edge +-- moved to left + the category's points byte + 2 (:9-12), and +-- whose .PasswordIndices answer is zero based (:44-49). +-- prize :64 BuenaPrize -- Buena_PlacePrizeMenuBox (:219), +-- Buena_PrizeMenu's scrolling list (:249-261) and +-- PrintBlueCardBalance's "Points" box (:210). 0 is a B press +-- (:236-245). +-- +-- Neither is opaque: engine/events/buena.asm:71-83 prints the prize question +-- BEFORE the list goes up over it, and the password menu stands on the map. + +local Chrome = require("src.ui.gen2.Chrome") +local Sound = require("src.core.Sound") +local Strings = require("src.core.Strings") + +local BuenaPassword = {} +BuenaPassword.__index = BuenaPassword +BuenaPassword.isOpaque = false + +-- GetMenuTextStartCoord (../pokecrystal/home/menu.asm:214) on STATICMENU_CURSOR +-- with no STATICMENU_NO_TOP_SPACING: labels at box + (2,2), cursor one left. +local WORD_X, WORD_Y, WORD_SPACING = 2, 2, 2 +local WORD_BOX_H = 8 + +-- engine/events/buena.asm:219 and :249, as menu_coords lays them down. +local FIELD_X, FIELD_Y, FIELD_W, FIELD_H = 0, 0, 18, 12 +local LIST_BOX_X, LIST_BOX_Y, LIST_BOX_W, LIST_BOX_H = 1, 1, 16, 9 +local LIST_X, LIST_Y, LIST_SPACING = 2, 2, 2 +-- ScrollingMenu_CallFunctions1and2's `add hl, de` on +-- wMenuData_ScrollingMenuWidth (engine/menus/scrolling_menu.asm:424-431). +local POINTS_X = LIST_X + 13 +local VISIBLE_ROWS = 4 +local ARROW_X = LIST_BOX_X + LIST_BOX_W - 1 +local ARROW_UP_Y, ARROW_DOWN_Y = LIST_BOX_Y, LIST_BOX_Y + LIST_BOX_H - 1 + +-- BlueCardBalanceMenuHeader (engine/events/buena.asm:208) and .DrawBox's +-- PlaceString / two spaces / `lb bc, 1, 2` PrintNum (:181-203). +local BALANCE_BOX_X, BALANCE_BOX_Y, BALANCE_BOX_W, BALANCE_BOX_H = 0, 11, 12, 3 +local BALANCE_LABEL_X, BALANCE_LABEL_Y = 1, 12 +local BALANCE_NUM_X = 8 +local POINTS_LABEL = Strings.source("Points") + +local UP_ARROW = "\xe2\x96\xb2" +local DOWN_ARROW = "\xe2\x96\xbc" + +-- opts: mode, and then words/width/onDone(0..2) or prizes/balance/onDone(row) +function BuenaPassword.new(game, opts) + opts = opts or {} + local self = setmetatable({}, BuenaPassword) + self.game = game + self.data = (game and game.data) or {} + self.mode = opts.mode == "prize" and "prize" or "password" + self.words = opts.words or {} + self.width = math.max(1, math.floor(tonumber(opts.width) or 10)) + self.prizes = opts.prizes or {} + self.balance = math.max(0, math.floor(tonumber(opts.balance) or 0)) + self.onDone = opts.onDone + -- engine/events/buena.asm:34 `db 1 ; default option` and :67-68 + -- `ld a, $1 / ld [wMenuSelection], a`. + self.index = 1 + self.scroll = 0 + return self +end + +function BuenaPassword:wantsFillScale() return true end + +function BuenaPassword:count() + if self.mode == "prize" then return #self.prizes end + return #self.words +end + +function BuenaPassword:playSfx(name) + local sfx = self.data.audio and self.data.audio.sfx + if sfx and sfx[Sound.resolve(self.data, name)] then + Sound.play(self.data, name) + end +end + +function BuenaPassword:finish(value) + if self.done then return end + self.done = true + local stack = self.game and self.game.stack + if stack then stack:pop() end + if self.onDone then self.onDone(value) end +end + +function BuenaPassword:ensureVisible() + local rows = math.min(VISIBLE_ROWS, self:count()) + if rows <= 0 then return end + if self.index <= self.scroll then + self.scroll = self.index - 1 + elseif self.index > self.scroll + rows then + self.scroll = self.index - rows + end + self.scroll = math.max(0, math.min(self.scroll, self:count() - rows)) +end + +function BuenaPassword:update(_dt) + if self.done then return end + local input = self.game and self.game.input + if not input then return end + local total = self:count() + if total <= 0 then return self:finish(self.mode == "prize" and 0 or -1) end + -- Neither header sets STATICMENU_WRAP (engine/events/buena.asm:39, :256). + if input:wasPressed("up") then + if self.index > 1 then self.index = self.index - 1 end + elseif input:wasPressed("down") then + if self.index < total then self.index = self.index + 1 end + elseif input:wasPressed("a") then + self:playSfx("Sfx_ReadText2") + if self.mode == "prize" then return self:finish(self.index) end + -- engine/events/buena.asm:44-49 .PasswordIndices is zero based. + return self:finish(self.index - 1) + elseif input:wasPressed("b") then + -- STATICMENU_DISABLE_B (engine/events/buena.asm:39). + if self.mode == "prize" then + self:playSfx("Sfx_ReadText2") + return self:finish(0) + end + end + self:ensureVisible() +end + +function BuenaPassword:drawPasswordPanel() + Chrome.box(0, 0, self.width + 3, WORD_BOX_H) + for i, word in ipairs(self.words) do + local ty = WORD_Y + (i - 1) * WORD_SPACING + if i == self.index then Chrome.cursor(WORD_X - 1, ty) end + Chrome.print(word, WORD_X, ty) + end +end + +function BuenaPassword:drawPrizePanel() + Chrome.box(FIELD_X, FIELD_Y, FIELD_W, FIELD_H) + Chrome.box(LIST_BOX_X, LIST_BOX_Y, LIST_BOX_W, LIST_BOX_H) + for row = 1, VISIBLE_ROWS do + local i = row + self.scroll + local prize = self.prizes[i] + if prize then + local ty = LIST_Y + (row - 1) * LIST_SPACING + if i == self.index then Chrome.cursor(LIST_X - 1, ty) end + Chrome.print(prize.name, LIST_X, ty) + -- .PrintPrizePoints writes one char, `'0' + cost` (buena.asm:281-289). + Chrome.print(tostring(prize.cost % 10), POINTS_X, ty) + end + end + -- SCROLLINGMENU_DISPLAY_ARROWS: the ▲ only once scrolled, the ▼ every pass + -- (engine/menus/scrolling_menu.asm:348-358, :387-395). + if self.scroll > 0 then Chrome.print(UP_ARROW, ARROW_X, ARROW_UP_Y) end + Chrome.print(DOWN_ARROW, ARROW_X, ARROW_DOWN_Y) + + Chrome.box(BALANCE_BOX_X, BALANCE_BOX_Y, BALANCE_BOX_W, BALANCE_BOX_H) + Chrome.print(Strings(POINTS_LABEL), BALANCE_LABEL_X, BALANCE_LABEL_Y) + Chrome.print(Chrome.number(self.balance, 2), BALANCE_NUM_X, BALANCE_LABEL_Y) +end + +function BuenaPassword:drawPanel() + if self.mode == "prize" then return self:drawPrizePanel() end + return self:drawPasswordPanel() +end + +function BuenaPassword:draw() + self:drawPanel() + love.graphics.setColor(1, 1, 1, 1) +end + +return BuenaPassword diff --git a/src/ui/gen2/CopyrightSplash.lua b/src/ui/gen2/CopyrightSplash.lua index abeed958..9b22ff15 100644 --- a/src/ui/gen2/CopyrightSplash.lua +++ b/src/ui/gen2/CopyrightSplash.lua @@ -23,6 +23,11 @@ local HOLD_FRAMES = 100 -- Only used when the extracted splash image is missing. local LINE_Y = { 48, 64, 80 } +-- Gold boots on the default BGP; Crystal inverts under SCGB_GAMEFREAK_LOGO +-- (../pokecrystal/engine/movie/splash.asm:20-30). +local DEFAULT_BACKDROP = { 1, 1, 1 } +local DEFAULT_INK = { 0, 0, 0 } + local function tryImage(path) if not path then return nil end local ok, image = pcall(Assets.image, path) @@ -43,6 +48,8 @@ function CopyrightSplash.new(game, opts) self.image = tryImage(opts.image) or tryImage(title.copyrightSplash) or tryImage("assets/generated/title/copyright_splash.png") + self.backdrop = opts.backdrop or title.copyrightBackdrop or DEFAULT_BACKDROP + self.ink = opts.ink or title.copyrightInk or DEFAULT_INK -- Text fallback only when the ROM extract is missing (tests / bare boots). self.lines = opts.lines self.frames = 0 @@ -78,17 +85,24 @@ function CopyrightSplash:update(_dt) end end +function CopyrightSplash:fillBackdrop(width, height) + local G = love.graphics + local c = self.backdrop + G.setColor(c[1], c[2], c[3], 1) + G.rectangle("fill", 0, 0, width, height) +end + function CopyrightSplash:drawPanel() local G = love.graphics - G.setColor(1, 1, 1, 1) - G.rectangle("fill", 0, 0, SCREEN_W, SCREEN_H) + self:fillBackdrop(SCREEN_W, SCREEN_H) if self.image then + G.setColor(1, 1, 1, 1) G.draw(self.image, 0, 0) return end if not self.lines then return end -- The Gen 2 font is a fixed 8px cell, so centring is a character count. - G.setColor(0, 0, 0, 1) + G.setColor(self.ink[1], self.ink[2], self.ink[3], 1) for index, line in ipairs(self.lines) do local y = LINE_Y[index] if y then @@ -104,8 +118,7 @@ end function CopyrightSplash:drawWidescreen(winW, winH) local G = love.graphics - G.setColor(1, 1, 1, 1) - G.rectangle("fill", 0, 0, winW, winH) + self:fillBackdrop(winW, winH) local scale = Chrome.fitScale(winW, winH) G.push() G.translate(Chrome.fitOrigin(winW, winH, scale)) diff --git a/src/ui/gen2/CrystalIntro.lua b/src/ui/gen2/CrystalIntro.lua new file mode 100644 index 00000000..50afead6 --- /dev/null +++ b/src/ui/gen2/CrystalIntro.lua @@ -0,0 +1,1282 @@ +-- The Crystal intro movie (pokecrystal engine/movie/intro.asm CrystalIntro), +-- transcribed: the 28-entry scene jumptable stepped once per frame +-- (engine/movie/intro.asm:58-94) over hSCX/hSCY, the two intro counters, live +-- wBGPals2 palettes and a real 32x32 BG map with CGB attributes. +-- Any button skips the whole thing (engine/movie/intro.asm:11-15). + +local bit = require("bit") + +local Assets = require("src.render.Assets") +local Chrome = require("src.ui.gen2.Chrome") +local GbcPalette = require("src.render.GbcPalette") +local Logger = require("src.core.Logger") +local Music = require("src.core.Music") +local Runtime = require("src.mods.Runtime") +local Sound = require("src.core.Sound") +local SpriteAnims = require("src.ui.gen2.SpriteAnims") + +local CrystalIntro = {} +CrystalIntro.__index = CrystalIntro +CrystalIntro.isOpaque = true + +local SCREEN_W, SCREEN_H = 160, 144 +local BG_TILES = 32 +local BG_PIXELS = BG_TILES * 8 + +local INTRO_MUSIC = "Music_CrystalOpening" + +local BLACK = { 0, 0, 0 } +local WHITE = { 255, 255, 255 } + +local OAM_PRIO = SpriteAnims.OAM_PRIO +local OAM_XFLIP = SpriteAnims.OAM_XFLIP +local OAM_YFLIP = SpriteAnims.OAM_YFLIP +-- OAM_BANK1 (constants/hardware.inc:997). +local OAM_BANK1 = 0x08 + +-------------------------------------------------------------------------- +-- Sprite-anim data, registered into SpriteAnims' shared tables +-------------------------------------------------------------------------- + +-- dbsprite (macros/gfx.asm:67-70): y byte first, tile counts mod $100. +local function s(xTile, yTile, xPixel, yPixel, tile, attr) + return { + y = (yTile * 8 + yPixel) % 256, + x = (xTile * 8 + xPixel) % 256, + tile = tile, + attr = attr, + } +end + +-- data/sprite_anims/oam.asm:815-852 .OAMData_IntroSuicune1. +local SUICUNE_1 = { + s( 1, -3, 0, 0, 0x05, 0), s( 2, -3, 0, 0, 0x06, 0), s( 3, -3, 0, 0, 0x07, 0), + s(-3, -2, 0, 0, 0x11, 0), s(-2, -2, 0, 0, 0x12, 0), s(-1, -2, 0, 0, 0x13, 0), + s( 0, -2, 0, 0, 0x14, 0), s( 1, -2, 0, 0, 0x15, 0), s( 2, -2, 0, 0, 0x16, 0), + s( 3, -2, 0, 0, 0x17, 0), + s(-4, -1, 0, 0, 0x20, 0), s(-3, -1, 0, 0, 0x21, 0), s(-2, -1, 0, 0, 0x22, 0), + s(-1, -1, 0, 0, 0x23, 0), s( 0, -1, 0, 0, 0x24, 0), s( 1, -1, 0, 0, 0x25, 0), + s( 2, -1, 0, 0, 0x26, 0), s( 3, -1, 0, 0, 0x27, 0), + s(-4, 0, 0, 0, 0x30, 0), s(-3, 0, 0, 0, 0x31, 0), s(-2, 0, 0, 0, 0x32, 0), + s(-1, 0, 0, 0, 0x33, 0), s( 0, 0, 0, 0, 0x34, 0), s( 1, 0, 0, 0, 0x35, 0), + s( 2, 0, 0, 0, 0x36, 0), + s(-4, 1, 0, 0, 0x40, 0), s(-3, 1, 0, 0, 0x41, 0), s(-2, 1, 0, 0, 0x42, 0), + s(-1, 1, 0, 0, 0x43, 0), s( 0, 1, 0, 0, 0x44, 0), s( 1, 1, 0, 0, 0x45, 0), + s( 2, 1, 0, 0, 0x46, 0), s( 3, 1, 0, 0, 0x47, 0), + s(-4, 2, 0, 0, 0x50, 0), s(-3, 2, 0, 0, 0x51, 0), s( 3, 2, 0, 0, 0x57, 0), +} + +-- data/sprite_anims/oam.asm:854-883 .OAMData_IntroSuicune2. +local SUICUNE_2 = { + s( 0, -3, 0, 0, 0x04, 0), s( 1, -3, 0, 0, 0x05, 0), s( 2, -3, 0, 0, 0x06, 0), + s(-3, -2, 0, 0, 0x11, 0), s(-2, -2, 0, 0, 0x12, 0), s(-1, -2, 0, 0, 0x13, 0), + s( 0, -2, 0, 0, 0x14, 0), s( 1, -2, 0, 0, 0x15, 0), s( 2, -2, 0, 0, 0x16, 0), + s(-3, -1, 0, 0, 0x21, 0), s(-2, -1, 0, 0, 0x22, 0), s(-1, -1, 0, 0, 0x23, 0), + s( 0, -1, 0, 0, 0x24, 0), s( 1, -1, 0, 0, 0x25, 0), s( 2, -1, 0, 0, 0x26, 0), + s(-4, 0, 0, 0, 0x30, 0), s(-3, 0, 0, 0, 0x31, 0), s(-2, 0, 0, 0, 0x32, 0), + s(-1, 0, 0, 0, 0x33, 0), s( 0, 0, 0, 0, 0x34, 0), s( 1, 0, 0, 0, 0x35, 0), + s(-2, 1, 0, 0, 0x42, 0), s(-1, 1, 0, 0, 0x43, 0), s( 0, 1, 0, 0, 0x44, 0), + s( 1, 1, 0, 0, 0x45, 0), + s(-1, 2, 0, 0, 0x53, 0), s( 0, 2, 0, 0, 0x54, 0), s( 1, 2, 0, 0, 0x55, 0), +} + +-- data/sprite_anims/oam.asm:885-916 .OAMData_IntroSuicune3. +local SUICUNE_3 = { + s( 0, -3, 0, 0, 0x04, 0), s( 1, -3, 0, 0, 0x05, 0), + s(-3, -2, 0, 0, 0x11, 0), s(-2, -2, 0, 0, 0x12, 0), s(-1, -2, 0, 0, 0x13, 0), + s( 0, -2, 0, 0, 0x14, 0), s( 1, -2, 0, 0, 0x15, 0), s( 2, -2, 0, 0, 0x16, 0), + s( 3, -2, 0, 0, 0x17, 0), + s(-4, -1, 0, 0, 0x20, 0), s(-3, -1, 0, 0, 0x21, 0), s(-2, -1, 0, 0, 0x22, 0), + s(-1, -1, 0, 0, 0x23, 0), s( 0, -1, 0, 0, 0x24, 0), s( 1, -1, 0, 0, 0x25, 0), + s( 2, -1, 0, 0, 0x26, 0), + s(-4, 0, 0, 0, 0x30, 0), s(-3, 0, 0, 0, 0x31, 0), s(-2, 0, 0, 0, 0x32, 0), + s(-1, 0, 0, 0, 0x33, 0), s( 0, 0, 0, 0, 0x34, 0), s( 1, 0, 0, 0, 0x35, 0), + s(-2, 1, 0, 0, 0x42, 0), s(-1, 1, 0, 0, 0x43, 0), s( 0, 1, 0, 0, 0x44, 0), + s( 1, 1, 0, 0, 0x45, 0), + s(-2, 2, 0, 0, 0x52, 0), s(-1, 2, 0, 0, 0x53, 0), s( 0, 2, 0, 0, 0x54, 0), + s( 1, 2, 0, 0, 0x55, 0), +} + +-- data/sprite_anims/oam.asm:918-950 .OAMData_IntroSuicune4. +local SUICUNE_4 = { + s(-3, -2, 0, 0, 0x11, 0), s(-2, -2, 0, 0, 0x12, 0), s(-1, -2, 0, 0, 0x13, 0), + s( 0, -2, 0, 0, 0x14, 0), s( 1, -2, 0, 0, 0x15, 0), s( 2, -2, 0, 0, 0x16, 0), + s( 3, -2, 0, 0, 0x17, 0), + s(-4, -1, 0, 0, 0x20, 0), s(-3, -1, 0, 0, 0x21, 0), s(-2, -1, 0, 0, 0x22, 0), + s(-1, -1, 0, 0, 0x23, 0), s( 0, -1, 0, 0, 0x24, 0), s( 1, -1, 0, 0, 0x25, 0), + s( 2, -1, 0, 0, 0x26, 0), s( 3, -1, 0, 0, 0x27, 0), + s(-4, 0, 0, 0, 0x30, 0), s(-3, 0, 0, 0, 0x31, 0), s(-2, 0, 0, 0, 0x32, 0), + s(-1, 0, 0, 0, 0x33, 0), s( 0, 0, 0, 0, 0x34, 0), s( 1, 0, 0, 0, 0x35, 0), + s( 2, 0, 0, 0, 0x36, 0), + s(-3, 1, 0, 0, 0x41, 0), s(-2, 1, 0, 0, 0x42, 0), s(-1, 1, 0, 0, 0x43, 0), + s( 0, 1, 0, 0, 0x44, 0), s( 1, 1, 0, 0, 0x45, 0), + s(-3, 2, 0, 0, 0x51, 0), s(-2, 2, 0, 0, 0x52, 0), s( 0, 2, 0, 0, 0x54, 0), + s( 1, 2, 0, 0, 0x55, 0), +} + +-- data/sprite_anims/oam.asm:952-978 .OAMData_IntroPichu: 5x5 block, +-- OBJ pal 1, VRAM bank 1. +local PICHU = {} +for row = 0, 4 do + for col = 0, 4 do + PICHU[#PICHU + 1] = s(col - 3, row - 3, 4, 4, row * 16 + col, + 1 + OAM_BANK1) + end +end + +-- data/sprite_anims/oam.asm:980-997 .OAMData_IntroWooper: 4x4 block, +-- OBJ pal 2, VRAM bank 1. +local WOOPER = {} +for row = 0, 3 do + for col = 0, 3 do + WOOPER[#WOOPER + 1] = s(col - 3, row - 2, 4, 0, row * 4 + col, + 2 + OAM_BANK1) + end +end + +-- data/sprite_anims/oam.asm:999-1017 .OAMData_IntroUnown1/2/3. +local UNOWN_1 = { s(-1, -1, 4, 4, 0x00, 0) } +local UNOWN_2 = { + s(-1, 0, 0, 0, 0x00, 0), s(-1, -1, 0, 0, 0x01, 0), s( 0, -1, 0, 0, 0x02, 0), +} +local UNOWN_3 = { + s(-2, 1, 0, 0, 0x00, 0), s(-2, 0, 0, 0, 0x01, 0), s(-2, -1, 0, 0, 0x02, 0), + s(-1, -1, 0, 0, 0x03, 0), s(-1, -2, 0, 0, 0x04, 0), s( 0, -2, 0, 0, 0x05, 0), + s( 1, -2, 0, 0, 0x06, 0), +} + +-- data/sprite_anims/oam.asm:177-182 .OAMData_IntroUnownF2_1. +local UNOWN_F2_1 = { + s(-1, -1, 0, 0, 0x00, 0), s( 0, -1, 0, 0, 0x00, OAM_XFLIP), + s(-1, 0, 0, 0, 0x00, OAM_YFLIP), s( 0, 0, 0, 0, 0x00, OAM_XFLIP + OAM_YFLIP), +} + +-- data/sprite_anims/oam.asm:1019-1028 .OAMData_IntroUnownF2_2. +local UNOWN_F2_2 = { + s(-2, -1, 0, 0, 0x00, 0), s(-1, -1, 0, 0, 0x01, 0), + s( 0, -1, 0, 0, 0x01, OAM_XFLIP), s( 1, -1, 0, 0, 0x00, OAM_XFLIP), + s(-2, 0, 0, 0, 0x00, OAM_YFLIP), s(-1, 0, 0, 0, 0x01, OAM_YFLIP), + s( 0, 0, 0, 0, 0x01, OAM_XFLIP + OAM_YFLIP), + s( 1, 0, 0, 0, 0x00, OAM_XFLIP + OAM_YFLIP), +} + +-- data/sprite_anims/oam.asm:1030-1043 .OAMData_IntroUnownF2_3. +local UNOWN_F2_3 = { + s(-1, -3, 0, 0, 0x00, 0), s(-1, -2, 0, 0, 0x01, 0), s(-1, -1, 0, 0, 0x02, 0), + s( 0, -3, 0, 0, 0x00, OAM_XFLIP), s( 0, -2, 0, 0, 0x01, OAM_XFLIP), + s( 0, -1, 0, 0, 0x02, OAM_XFLIP), + s(-1, 0, 0, 0, 0x02, OAM_YFLIP), s(-1, 1, 0, 0, 0x01, OAM_YFLIP), + s(-1, 2, 0, 0, 0x00, OAM_YFLIP), + s( 0, 0, 0, 0, 0x02, OAM_XFLIP + OAM_YFLIP), + s( 0, 1, 0, 0, 0x01, OAM_XFLIP + OAM_YFLIP), + s( 0, 2, 0, 0, 0x00, OAM_XFLIP + OAM_YFLIP), +} + +-- data/sprite_anims/oam.asm:1045-1066 .OAMData_IntroUnownF2_4_5: 4x5 block. +local UNOWN_F2_45 = {} +for row = 0, 4 do + for col = 0, 3 do + UNOWN_F2_45[#UNOWN_F2_45 + 1] = s(col - 2, row - 3, 0, 4, row * 4 + col, 0) + end +end + +-- data/sprite_anims/oam.asm:1068-1089 .OAMData_IntroSuicuneAway: a zigzag of +-- twenty grass tiles, OBJ pal 1, behind the BG. +local AWAY = {} +do + local ys = { 0, 1, 2, 3, 4, 3, 2, 1, 0, 1, 2, 3, 4, 3, 2, 1 } + for index, y in ipairs(ys) do + AWAY[#AWAY + 1] = s(index, y, 0, 0, 0x00, 1 + OAM_PRIO) + end + local tail = { { -15, 0 }, { -14, 1 }, { -13, 2 }, { -12, 3 } } + for _, spot in ipairs(tail) do + AWAY[#AWAY + 1] = s(spot[1], spot[2], 0, 0, 0x00, 1 + OAM_PRIO) + end +end + +-- spriteanimoam bases (data/sprite_anims/oam.asm:120-136). +local OAMSETS = { + INTRO_SUICUNE_1 = { 0x00, SUICUNE_1 }, + INTRO_SUICUNE_2 = { 0x08, SUICUNE_2 }, + INTRO_SUICUNE_3 = { 0x60, SUICUNE_3 }, + INTRO_SUICUNE_4 = { 0x68, SUICUNE_4 }, + INTRO_PICHU_1 = { 0x00, PICHU }, + INTRO_PICHU_2 = { 0x05, PICHU }, + INTRO_PICHU_3 = { 0x0a, PICHU }, + INTRO_WOOPER = { 0x50, WOOPER }, + INTRO_UNOWN_1 = { 0x00, UNOWN_1 }, + INTRO_UNOWN_2 = { 0x01, UNOWN_2 }, + INTRO_UNOWN_3 = { 0x04, UNOWN_3 }, + INTRO_UNOWN_F_2_1 = { 0x00, UNOWN_F2_1 }, + INTRO_UNOWN_F_2_2 = { 0x01, UNOWN_F2_2 }, + INTRO_UNOWN_F_2_3 = { 0x03, UNOWN_F2_3 }, + INTRO_UNOWN_F_2_4 = { 0x08, UNOWN_F2_45 }, + INTRO_UNOWN_F_2_5 = { 0x1c, UNOWN_F2_45 }, + INTRO_SUICUNE_AWAY = { 0x80, AWAY }, +} + +local function f(oamset, duration, flags) + return { oamset = oamset, duration = duration, flags = flags or 0 } +end + +-- data/sprite_anims/framesets.asm:429-489. +local FRAMESETS = { + IntroSuicune = { + f("INTRO_SUICUNE_1", 3), f("INTRO_SUICUNE_2", 3), + f("INTRO_SUICUNE_3", 3), f("INTRO_SUICUNE_4", 3), "restart", + }, + IntroSuicune2 = { f("INTRO_SUICUNE_4", 3), f("INTRO_SUICUNE_1", 7), "end" }, + IntroPichu = { + f("INTRO_PICHU_1", 32), f("INTRO_PICHU_2", 7), f("INTRO_PICHU_3", 7), "end", + }, + IntroWooper = { f("INTRO_WOOPER", 3), "end" }, + IntroUnown1 = { + f("INTRO_UNOWN_1", 3), f("INTRO_UNOWN_2", 3), f("INTRO_UNOWN_3", 7), + f("delete", 0), + }, + IntroUnown2 = { + f("INTRO_UNOWN_1", 3, OAM_XFLIP), f("INTRO_UNOWN_2", 3, OAM_XFLIP), + f("INTRO_UNOWN_3", 7, OAM_XFLIP), f("delete", 0), + }, + IntroUnown3 = { + f("INTRO_UNOWN_1", 3, OAM_YFLIP), f("INTRO_UNOWN_2", 3, OAM_YFLIP), + f("INTRO_UNOWN_3", 7, OAM_YFLIP), f("delete", 0), + }, + IntroUnown4 = { + f("INTRO_UNOWN_1", 3, OAM_XFLIP + OAM_YFLIP), + f("INTRO_UNOWN_2", 3, OAM_XFLIP + OAM_YFLIP), + f("INTRO_UNOWN_3", 7, OAM_XFLIP + OAM_YFLIP), f("delete", 0), + }, + IntroUnownF2 = { + f("INTRO_UNOWN_F_2_1", 3), f("INTRO_UNOWN_F_2_2", 3), + f("INTRO_UNOWN_F_2_3", 3), f("INTRO_UNOWN_F_2_4", 7), + f("INTRO_UNOWN_F_2_5", 7), "end", + }, + IntroSuicuneAway = { f("INTRO_SUICUNE_AWAY", 3), "end" }, + IntroUnownF = { f("wait", 0), "end" }, +} + +local function reinit(st, framesetId) + st.framesetId = framesetId + st.duration = 0 + st.frame = -1 +end + +local SEQUENCES = {} + +-- SpriteAnimFunc_IntroSuicune (engine/sprite_anims/functions.asm:750-776): +-- idle while wIntroSceneTimer is 0, then the jump arc on a negated sine. +SEQUENCES.IntroSuicune = function(sys, st) + if (sys.timer or 0) == 0 then return end + st.yOffset = 0 + st.var2 = (st.var2 + 2) % 256 + st.yOffset = SpriteAnims.sine((256 - st.var2) % 256, 32) + reinit(st, "IntroSuicune2") +end + +-- SpriteAnimFunc_IntroPichuWooper (engine/sprite_anims/functions.asm:778-795). +SEQUENCES.IntroPichuWooper = function(_, st) + if st.var1 >= 20 then return end + st.var1 = st.var1 + 2 + st.yOffset = SpriteAnims.sine((256 - st.var1) % 256, 32) +end + +-- SpriteAnimFunc_IntroUnown (engine/sprite_anims/functions.asm:797-821): +-- VAR1 is the angle, the counter is the radius (core.asm:543-545). +SEQUENCES.IntroUnown = function(_, st) + local radius = st.jt + st.jt = (st.jt + 3) % 256 + st.yOffset = SpriteAnims.sine(st.var1, radius) + st.xOffset = SpriteAnims.cosine(st.var1, radius) +end + +-- SpriteAnimFunc_IntroUnownF (engine/sprite_anims/functions.asm:823-829): +-- wSlotsDelay aliases wIntroSceneFrameCounter (ram/wram.asm:1602,1649). +SEQUENCES.IntroUnownF = function(sys, st) + if (sys.counter or 0) ~= 0x40 then return end + reinit(st, "IntroUnownF2") +end + +-- SpriteAnimFunc_IntroSuicuneAway (engine/sprite_anims/functions.asm:831-837). +SEQUENCES.IntroSuicuneAway = function(_, st) + st.y = (st.y + 16) % 256 +end + +-- data/sprite_anims/objects.asm:81-92. +local OBJECTS = { + INTRO_SUICUNE = { "IntroSuicune", "IntroSuicune" }, + INTRO_PICHU = { "IntroPichu", "IntroPichuWooper" }, + INTRO_WOOPER = { "IntroWooper", "IntroPichuWooper" }, + INTRO_UNOWN = { "IntroUnown1", "IntroUnown" }, + INTRO_UNOWN_F = { "IntroUnownF", "IntroUnownF" }, + INTRO_SUICUNE_AWAY = { "IntroSuicuneAway", "IntroSuicuneAway" }, +} + +local function register(target, entries) + for name, value in pairs(entries) do + if target[name] == nil then target[name] = value end + end +end + +register(SpriteAnims.OAMSETS, OAMSETS) +register(SpriteAnims.FRAMESETS, FRAMESETS) +register(SpriteAnims.OBJECTS, OBJECTS) +register(SpriteAnims.SEQUENCES, SEQUENCES) + +-------------------------------------------------------------------------- +-- Palettes +-------------------------------------------------------------------------- + +local function scale31(value) return math.floor(value * 255 / 31 + 0.5) end + +-- CrystalIntro_UnownFade's three 32-step ramps +-- (engine/movie/intro.asm:1312-1328). +local BW_FADE, LBLUE_FADE, BLUE_FADE = {}, {}, {} +for hue = 0, 31 do + BW_FADE[hue + 1] = { scale31(hue), scale31(hue), scale31(hue) } + LBLUE_FADE[hue + 1] = { 0, scale31(math.floor(hue / 2)), scale31(hue) } + BLUE_FADE[hue + 1] = { 0, 0, scale31(hue) } +end + +local function palCopy(source) + local out = {} + for index = 1, 4 do + local color = source and source[index] + out[index] = color and { color[1], color[2], color[3] } or BLACK + end + return out +end + +local function flatPals(color) + local out = {} + for pal = 1, 8 do out[pal] = { color, color, color, color } end + return out +end + +-------------------------------------------------------------------------- +-- Construction +-------------------------------------------------------------------------- + +function CrystalIntro:wantsFillScale() return true end +function CrystalIntro:drawsWidescreen() return true end + +function CrystalIntro.new(game, opts) + opts = opts or {} + local self = setmetatable({}, CrystalIntro) + self.game = game + self.onDone = opts.onDone + local data = (game and game.data) or {} + self.assets = opts.intro or data.gen2Intro or (game and game.introData) or nil + if not (self.assets and self.assets.acts) then + Logger.warn("crystal intro: no intro.lua in the cache -- re-import " + .. "this version or the movie plays blank") + end + self.images = {} + self.sheets = {} + + self.anims = SpriteAnims.new() + self.scene = 1 + self.phase = 0 + self.hold = 0 + self.done = false + self.frames = 0 + + self.scx, self.scy = 0, 0 + self.counter, self.timer = 0, 0 + self.treeScroll, self.grassScroll = 0, 0 + self.lyActive = false + self.grassFrame = nil + + self.bgPals = flatPals(BLACK) + self.obPals = flatPals(BLACK) + self.map, self.attr = {}, {} + for index = 1, BG_TILES * BG_TILES do + self.map[index] = 0 + self.attr[index] = 0 + end + self.act = nil + self.mapDirty, self.palsDirty = true, true + return self +end + +-------------------------------------------------------------------------- +-- Scene plumbing +-------------------------------------------------------------------------- + +local function actData(self) + local acts = self.assets and self.assets.acts + return acts and self.act and acts[self.act] or nil +end + +-- ClearSpriteAnims zeroes wSpriteAnimData through wGlobalAnimXOffset +-- (engine/sprite_anims/core.asm:1-11, ram/wram.asm:202-272). +local function clearAnims(self) + self.anims:clear() + self.anims.globalX, self.anims.globalY = 0, 0 +end + +local function fades(self) + return (self.assets and self.assets.fades) or {} +end + +local function markDirty(self) + self.palsDirty = true +end + +local function loadAct(self, key) + self.act = key + self.grassFrame = nil + local act = actData(self) + for index = 1, BG_TILES * BG_TILES do + self.map[index] = act and act.tilemap and act.tilemap[index] or 0 + self.attr[index] = act and act.attrmap and act.attrmap[index] or 0 + end + local palettes = act and act.palettes or {} + for pal = 1, 8 do + self.bgPals[pal] = palCopy(palettes.bg and palettes.bg[pal]) + self.obPals[pal] = palCopy(palettes.obj and palettes.obj[pal]) + end + self.mapDirty, self.palsDirty = true, true +end + +-- Intro_ClearBGPals blacks all 16 palettes and burns two frames +-- (engine/movie/intro.asm:1554-1571); IntroScene26 clears to white instead +-- (engine/movie/intro.asm:1058, home/tilemap.asm:168-196). +local function setup(self, white, fn) + if self.phase == 0 then + self.phase = 1 + local color = white and WHITE or BLACK + self.bgPals = flatPals(color) + self.obPals = flatPals(color) + markDirty(self) + self.hold = 1 + return + end + self.phase = 0 + fn(self) + self.scene = self.scene + 1 +end + +function CrystalIntro:playMusic(song) + local data = self.game and self.game.data + local audio = data and data.audio + if audio and audio.runtime and audio.songs and audio.songs[song] then + Music.play(data, song) + end +end + +function CrystalIntro:playSfx(name) + local data = self.game and self.game.data + local audio = data and data.audio + if audio and audio.runtime and audio.sfx and audio.sfx[name] then + Sound.play(data, name) + end +end + +-- CrystalIntro_UnownFade (engine/movie/intro.asm:1231-1310): zero every BG +-- palette, then write the triangle-wave step of the three ramps into colours +-- 1-3 of palette `pal`. +local function unownFade(self, pal, t) + local step = t % 64 + if step > 31 then step = 63 - step end + self.bgPals = flatPals(BLACK) + local target = self.bgPals[pal + 1] + target[2] = BW_FADE[step + 1] + target[3] = LBLUE_FADE[step + 1] + target[4] = BLUE_FADE[step + 1] + markDirty(self) +end + +-- CrystalIntro_InitUnownAnim (engine/movie/intro.asm:1191-1229): four structs +-- at one spot, angles $08/$18/$28/$38, framesets 4/3/1/2. +local UNOWN_SWIRL = { + { 0x08, "IntroUnown4" }, { 0x18, "IntroUnown3" }, + { 0x28, "IntroUnown1" }, { 0x38, "IntroUnown2" }, +} + +local function initUnownAnim(self, x, y) + for _, spec in ipairs(UNOWN_SWIRL) do + local st = self.anims:init("INTRO_UNOWN", x, y) + if st then + st.var1 = spec[1] + reinit(st, spec[2]) + end + end +end + +-- Intro_ResetLYOverrides / Intro_PerspectiveScrollBG +-- (engine/movie/intro.asm:1630-1676): trees on lines 0-94 at half speed, +-- grass on lines 95-143 at double, hSCX following the tree band. +local function resetLYOverrides(self) + self.treeScroll, self.grassScroll = 0, 0 + self.lyActive = true +end + +local function perspectiveScroll(self) + if self.counter % 2 == 1 then + self.treeScroll = (self.treeScroll + 1) % 256 + end + self.grassScroll = (self.grassScroll + 2) % 256 + self.scx = self.treeScroll +end + +-- Intro_RustleGrass (engine/movie/intro.asm:1521-1547): the four tiles at +-- vTiles2 $09 cycle grass1/grass2/grass3/grass2 for the first 36 frames. +local RUSTLE = { 1, 2, 3, 2 } + +local function rustleGrass(self) + if self.counter >= 36 then return end + local frame = RUSTLE[math.floor(self.counter / 4) % 4 + 1] + if frame ~= self.grassFrame then + self.grassFrame = frame + self.mapDirty = true + end +end + +-- Intro_ColoredSuicuneFrameSwap (engine/movie/intro.asm:1500-1519): toggle +-- bit 3 of every non-zero id below $80 in the visible 20x18 window. +local function frameSwap(self) + for row = 0, 17 do + for col = 0, 19 do + local index = row * BG_TILES + col + 1 + local id = self.map[index] + if id ~= 0 and id < 0x80 then + self.map[index] = bit.bxor(id, 8) + end + end + end + self.mapDirty = true +end + +-------------------------------------------------------------------------- +-- Scenes (engine/movie/intro.asm:96-1153) +-------------------------------------------------------------------------- + +local Scenes = {} + +-- IntroScene1 (engine/movie/intro.asm:96-146). +Scenes[1] = function(self) + setup(self, false, function() + loadAct(self, "unownA") + clearAnims(self) + self.scx, self.scy = 0, 0 + self.counter, self.timer = 0, 0 + end) +end + +-- IntroScene2 (engine/movie/intro.asm:148-170). +Scenes[2] = function(self) + local a = self.counter + self.counter = (a + 1) % 256 + if a >= 0x80 then + self.scene = 3 + return + end + if a == 0x60 then + initUnownAnim(self, 11 * 8, 11 * 8) + self:playSfx("Sfx_IntroUnown1") + end + self.timer = a + unownFade(self, 0, a) +end + +-- IntroScene3 (engine/movie/intro.asm:172-218). +Scenes[3] = function(self) + setup(self, false, function() + loadAct(self, "background") + resetLYOverrides(self) + self.scx, self.scy = 0, 0 + self.counter = 0 + end) +end + +-- IntroScene4 (engine/movie/intro.asm:220-232). +Scenes[4] = function(self) + perspectiveScroll(self) + if self.counter == 0x80 then + self.scene = 5 + return + end + self.counter = (self.counter + 1) % 256 +end + +-- IntroScene5 (engine/movie/intro.asm:234-285). +Scenes[5] = function(self) + setup(self, false, function() + loadAct(self, "unownHI") + self.lyActive = false + clearAnims(self) + self.scx, self.scy = 0, 0 + self.counter, self.timer = 0, 0 + end) +end + +-- IntroScene6 (engine/movie/intro.asm:287-330). +Scenes[6] = function(self) + local a = self.counter + self.counter = (a + 1) % 256 + if a >= 0x80 then + self.scene = 7 + return + end + if a == 0x60 then + initUnownAnim(self, 6 * 8, 14 * 8) + self:playSfx("Sfx_IntroUnown1") + self.timer = a + unownFade(self, 1, a) + return + end + if a >= 0x40 then + self.timer = a + unownFade(self, 1, a) + return + end + if a == 0x20 then + initUnownAnim(self, 15 * 8, 7 * 8) + self:playSfx("Sfx_IntroUnown2") + end + self.timer = a + unownFade(self, 0, a) +end + +-- IntroScene7 (engine/movie/intro.asm:332-401). +Scenes[7] = function(self) + setup(self, false, function() + loadAct(self, "background") + resetLYOverrides(self) + clearAnims(self) + self.anims:init("INTRO_SUICUNE", 27 * 8, 13 * 8 + 4) + self.anims.globalX = 0xf0 + self.scx, self.scy = 0, 0 + self.counter, self.timer = 0, 0 + end) +end + +-- IntroScene8 (engine/movie/intro.asm:403-430). +Scenes[8] = function(self) + local a = self.counter + self.counter = (a + 1) % 256 + if a == 0x40 then + self:playSfx("Sfx_IntroSuicune3") + elseif a < 0x40 then + perspectiveScroll(self) + return + end + if self.anims.globalX == 0 then + self:playSfx("Sfx_IntroSuicune2") + self.anims:clear() + self.scene = 9 + return + end + self.anims.globalX = (self.anims.globalX - 8) % 256 +end + +-- IntroScene9 (engine/movie/intro.asm:432-467): palette bands over the +-- whole map, six blocking frames. +Scenes[9] = function(self) + self.lyActive = false + for row = 0, 17 do + local pal = (row < 12 and 1) or (row < 15 and 2) or 3 + for col = 0, BG_TILES - 1 do + self.attr[row * BG_TILES + col + 1] = pal + end + end + self.anims.globalX = 0 + self.counter = 0 + self.mapDirty = true + self.hold = 6 + self.scene = 10 +end + +-- IntroScene10 (engine/movie/intro.asm:469-500). +Scenes[10] = function(self) + rustleGrass(self) + local a = self.counter + self.counter = (a + 1) % 256 + if a == 0xc0 then + self.scene = 11 + return + end + if a == 0x20 then + self.anims:init("INTRO_WOOPER", 6 * 8, 22 * 8) + self:playSfx("Sfx_IntroPichu") + elseif a == 0x40 then + self.anims:init("INTRO_PICHU", 16 * 8, 21 * 8 + 1) + self:playSfx("Sfx_IntroPichu") + end +end + +-- IntroScene11 (engine/movie/intro.asm:502-550). +Scenes[11] = function(self) + setup(self, false, function() + loadAct(self, "unowns") + self.lyActive = false + clearAnims(self) + self.scx, self.scy = 0, 0 + self.counter, self.timer = 0, 0 + end) +end + +-- IntroScene12's .UnownSounds (engine/movie/intro.asm:615-624). +local UNOWN_SOUNDS = { + [0x00] = "Sfx_IntroUnown3", [0x20] = "Sfx_IntroUnown2", + [0x40] = "Sfx_IntroUnown1", [0x60] = "Sfx_IntroUnown2", + [0x80] = "Sfx_IntroUnown3", [0x90] = "Sfx_IntroUnown2", + [0xa0] = "Sfx_IntroUnown1", [0xb0] = "Sfx_IntroUnown2", +} + +-- IntroScene12 (engine/movie/intro.asm:552-613). +Scenes[12] = function(self) + local a = self.counter + local sound = UNOWN_SOUNDS[a] + if sound then self:playSfx(sound) end + self.counter = (a + 1) % 256 + if a >= 0xc0 then + self.scene = 13 + return + end + if a < 0x80 then + self.timer = (a % 0x20) * 2 + unownFade(self, math.floor(a / 0x20), self.timer) + else + self.timer = (a % 0x10) * 4 + unownFade(self, 4 + math.floor((a - 0x80) / 0x10), self.timer) + end +end + +-- IntroScene13 (engine/movie/intro.asm:626-683). +Scenes[13] = function(self) + setup(self, false, function() + loadAct(self, "background") + clearAnims(self) + self.anims:init("INTRO_SUICUNE", 11 * 8, 13 * 8 + 4) + self:playMusic(INTRO_MUSIC) + self.scx, self.scy = 0, 0 + self.counter, self.timer = 0, 0 + end) +end + +-- IntroScene14 (engine/movie/intro.asm:685-728). +Scenes[14] = function(self) + self.scx = (self.scx - 10) % 256 + local a = self.counter + self.counter = (a + 1) % 256 + if a == 0x80 then + self.scene = 15 + return + end + if a == 0x60 then + self:playSfx("Sfx_IntroSuicune4") + end + if a >= 0x60 then + self.timer = 1 + local gx = self.anims.globalX + if gx < 0x88 then + self.anims:clear() + else + self.anims.globalX = (gx - 8) % 256 + end + elseif a >= 0x40 then + self.anims.globalX = (self.anims.globalX - 2) % 256 + end +end + +-- IntroScene15 (engine/movie/intro.asm:730-792). +Scenes[15] = function(self) + setup(self, false, function() + loadAct(self, "suicuneJump") + clearAnims(self) + self.anims:init("INTRO_UNOWN_F", 5 * 8, 8 * 8) + self.anims:init("INTRO_SUICUNE_AWAY", 0, 12 * 8) + self.scx, self.scy = 0, SCREEN_H + self.counter, self.timer = 0, 0 + end) +end + +-- IntroScene16 (engine/movie/intro.asm:794-810). +Scenes[16] = function(self) + local a = self.counter + self.counter = (a + 1) % 256 + if a >= 0x80 then + self.scene = 17 + return + end + if a % 4 == 0 then frameSwap(self) end + if self.scy ~= 0 then + self.scy = (self.scy + 8) % 256 + end +end + +-- IntroScene17 (engine/movie/intro.asm:812-859). +Scenes[17] = function(self) + setup(self, false, function() + loadAct(self, "suicuneClose") + clearAnims(self) + self.scx, self.scy = 0, 0 + self.counter, self.timer = 0, 0 + end) +end + +-- IntroScene18 (engine/movie/intro.asm:861-876). +Scenes[18] = function(self) + local a = self.counter + self.counter = (a + 1) % 256 + if a >= 0x60 then + self.scene = 19 + return + end + if self.scx ~= 0x60 then + self.scx = (self.scx + 8) % 256 + end +end + +-- IntroScene19 (engine/movie/intro.asm:878-941). +Scenes[19] = function(self) + setup(self, false, function() + loadAct(self, "suicuneBack") + clearAnims(self) + self.anims:init("INTRO_SUICUNE_AWAY", 0, 12 * 8) + self.scx, self.scy = 0, (-5 * 8) % 256 + self.counter, self.timer = 0, 0 + end) +end + +-- IntroScene20 (engine/movie/intro.asm:943-988). +Scenes[20] = function(self) + local a = self.counter + self.counter = (a + 1) % 256 + if a >= 0x98 then + self.scene = 21 + return + end + if a >= 0x58 then return end + if a >= 0x40 then + local b = a - 0x18 + if b % 4 == 3 then + local slot = math.floor(b % 0x20 / 4) + self.timer = slot + self.bgPals[slot + 1] = palCopy(fades(self).unownAppear) + markDirty(self) + end + return + end + if a >= 0x28 then return end + self.scy = (self.scy + 1) % 256 +end + +-- IntroScene21 (engine/movie/intro.asm:990-1000). +Scenes[21] = function(self) + frameSwap(self) + self.hold = 3 + self.counter, self.timer = 0, 0 + self.scene = 22 +end + +-- IntroScene22 (engine/movie/intro.asm:1002-1012). +Scenes[22] = function(self) + local a = self.counter + self.counter = (a + 1) % 256 + if a >= 0x8 then + self.anims:clear() + self.scene = 23 + end +end + +-- IntroScene23 (engine/movie/intro.asm:1014-1018). +Scenes[23] = function(self) + self.counter = 0 + self.scene = 24 +end + +-- IntroScene24 (engine/movie/intro.asm:1020-1042). +Scenes[24] = function(self) + local a = self.counter + self.counter = (a + 1) % 256 + if a >= 0x20 then + self.counter = 0x40 + self.scene = 25 + return + end + if a % 4 ~= 0 then return end + local toWhite = fades(self).toWhite + local pal = toWhite and toWhite[math.floor(a % 0x20 / 4) + 1] + if not pal then return end + for slot = 1, 8 do + self.bgPals[slot] = palCopy(pal) + end + markDirty(self) +end + +-- IntroScene25 (engine/movie/intro.asm:1044-1054). +Scenes[25] = function(self) + if self.counter - 1 == 0 then + self.scene = 26 + return + end + self.counter = self.counter - 1 +end + +-- IntroScene26 (engine/movie/intro.asm:1056-1103). +Scenes[26] = function(self) + setup(self, true, function() + loadAct(self, "crystalUnowns") + clearAnims(self) + self.scx, self.scy = 0, 0 + self.counter, self.timer = 0, 0 + end) +end + +-- IntroScene27 / Intro_FadeUnownWordPals +-- (engine/movie/intro.asm:1105-1128, :1390-1455). +Scenes[27] = function(self) + local a = self.counter + self.counter = (a + 1) % 256 + if a >= 0x80 then + self.scene = 28 + self.counter = 0x80 + return + end + local t = a % 0x10 + local pal = math.floor(a % 0x80 / 0x10) + local fade = fades(self) + local target = self.bgPals[pal + 1] + if fade.wordFast and fade.wordFast[t + 1] then + target[3] = { unpack(fade.wordFast[t + 1]) } + end + if fade.wordSlow and fade.wordSlow[t + 1] then + target[4] = { unpack(fade.wordSlow[t + 1]) } + end + markDirty(self) +end + +-- IntroScene28 (engine/movie/intro.asm:1130-1153). +Scenes[28] = function(self) + local a = self.counter + if a == 0 then + self.done = true + return + end + self.counter = a - 1 + if a == 0x18 then + self.bgPals = flatPals(WHITE) + self.obPals = flatPals(WHITE) + markDirty(self) + elseif a == 0x8 then + self:playSfx("Sfx_IntroWhoosh") + end +end + +-------------------------------------------------------------------------- +-- Frame loop +-------------------------------------------------------------------------- + +-- CrystalIntro's .loop: the scene function, then PlaySpriteAnimations, then +-- DelayFrame (engine/movie/intro.asm:11-22); in-scene DelayFrames hold the +-- sprites still, so a pending `hold` skips the anim step. +function CrystalIntro:step() + if self.done then return true end + self.frames = self.frames + 1 + if self.hold > 0 then + self.hold = self.hold - 1 + return false + end + local scene = Scenes[self.scene] + if scene then scene(self) end + if self.done then return true end + if self.hold > 0 then return false end + self.anims.timer = self.timer + self.anims.counter = self.counter + self.anims:playFrame() + return self.done +end + +function CrystalIntro:enter() + if Runtime.wants("intro.boot.movie") then + Runtime.emit("intro.boot.movie", { screen = self, game = self.game }) + end +end + +function CrystalIntro:finish() + if self.finished then return end + self.finished = true + self.done = true + if Runtime.wants("intro.boot.movie_ended") then + Runtime.emit("intro.boot.movie_ended", { + screen = self, game = self.game, + skipped = self.skipped and true or false, + frames = self.frames, + }) + end + if self.onDone then self.onDone() end +end + +-- .ShutOffMusic (engine/movie/intro.asm:24-26). +function CrystalIntro:skip() + self.skipped = true + Music.stop() + self:finish() +end + +function CrystalIntro:update(_dt) + if self.finished then return end + local input = self.game and self.game.input + if input then + for _, button in ipairs({ "a", "b", "start", "select" }) do + if input:wasPressed(button) then + self:skip() + return + end + end + end + if self:step() then self:finish() end +end + +-------------------------------------------------------------------------- +-- Drawing +-------------------------------------------------------------------------- + +function CrystalIntro:image(path) + if not path then return nil end + local cached = self.images[path] + if cached == nil then + local ok, img = pcall(Assets.image, path) + cached = ok and img or false + if cached then + cached:setFilter("nearest", "nearest") + end + self.images[path] = cached + end + return cached or nil +end + +function CrystalIntro:sheet(path) + if not path then return nil end + local entry = self.sheets[path] + if entry ~= nil then return entry or nil end + local image = self:image(path) + if not image then + self.sheets[path] = false + return nil + end + local width, height = image:getDimensions() + local quads = {} + for tile = 0, math.floor(width / 8) * math.floor(height / 8) - 1 do + quads[tile] = love.graphics.newQuad( + tile % 16 * 8, math.floor(tile / 16) * 8, 8, 8, width, height) + end + entry = { image = image, quads = quads } + self.sheets[path] = entry + return entry +end + +-- The three layer canvases the CGB attribute byte splits the map into: +-- per-tile colour 0 (the backdrop), colours 1-3 of ordinary tiles, and +-- colours 1-3 of PRIORITY tiles, which beat every OBJ. +local function bakeLayers(self) + if not (self.mapDirty or self.palsDirty) then + return self.backdropCanvas ~= nil + end + local G = love.graphics + if not self.backdropCanvas then + local ok, a = pcall(G.newCanvas, BG_PIXELS, BG_PIXELS) + local ok2, b = pcall(G.newCanvas, BG_PIXELS, BG_PIXELS) + local ok3, c = pcall(G.newCanvas, BG_PIXELS, BG_PIXELS) + if not (ok and ok2 and ok3) then return false end + for _, canvas in ipairs({ a, b, c }) do + canvas:setFilter("nearest", "nearest") + end + self.backdropCanvas, self.lowCanvas, self.highCanvas = a, b, c + end + local act = actData(self) + local sheet = act and self:sheet(act.tiles) + local grass = self.grassFrame + and self:sheet(self.assets and self.assets.grassFrames) + local previous = G.getCanvas() + G.push() + G.origin() + + G.setCanvas(self.backdropCanvas) + G.clear(0, 0, 0, 1) + for pal = 0, 7 do + local color = GbcPalette.color(self.bgPals[pal + 1], 1) or BLACK + G.setColor(color[1] / 255, color[2] / 255, color[3] / 255, 1) + for row = 0, BG_TILES - 1 do + for col = 0, BG_TILES - 1 do + if self.attr[row * BG_TILES + col + 1] % 8 == pal then + G.rectangle("fill", col * 8, row * 8, 8, 8) + end + end + end + end + G.setColor(1, 1, 1, 1) + + local shader = GbcPalette.available() + for _, layer in ipairs({ + { canvas = self.lowCanvas, prio = false }, + { canvas = self.highCanvas, prio = true }, + }) do + G.setCanvas(layer.canvas) + G.clear(0, 0, 0, 0) + if sheet then + for pal = 0, 7 do + if shader then GbcPalette.use(self.bgPals[pal + 1]) end + for row = 0, BG_TILES - 1 do + for col = 0, BG_TILES - 1 do + local index = row * BG_TILES + col + 1 + local attr = self.attr[index] + if attr % 8 == pal and (attr >= 0x80) == layer.prio then + local id = self.map[index] + local quad + local image = sheet.image + if grass and id >= 0x09 and id <= 0x0c then + quad = grass.quads[(self.grassFrame - 1) * 4 + id - 0x09] + image = grass.image + else + quad = sheet.quads[id] + end + if quad then G.draw(image, quad, col * 8, row * 8) end + end + end + end + end + if shader then GbcPalette.clear() end + end + end + G.setCanvas(previous) + G.pop() + self.mapDirty, self.palsDirty = false, false + return true +end + +-- Present a layer at (hSCX, hSCY); with the perspective bands live each +-- scanline takes the tree or grass SCX (engine/movie/intro.asm:1647-1676). +function CrystalIntro:drawLayer(canvas) + local G = love.graphics + G.setColor(1, 1, 1, 1) + if not self.lyActive then + local scx, scy = self.scx % BG_PIXELS, self.scy % BG_PIXELS + for _, ox in ipairs({ 0, BG_PIXELS }) do + for _, oy in ipairs({ 0, BG_PIXELS }) do + G.draw(canvas, -scx + ox, -scy + oy) + end + end + return + end + self.lineQuad = self.lineQuad + or love.graphics.newQuad(0, 0, BG_PIXELS, 1, BG_PIXELS, BG_PIXELS) + for line = 0, SCREEN_H - 1 do + local srcY = (self.scy + line) % BG_PIXELS + local scx = (line < 0x5f and self.treeScroll or self.grassScroll) + % BG_PIXELS + self.lineQuad:setViewport(0, srcY, BG_PIXELS, 1, BG_PIXELS, BG_PIXELS) + G.draw(canvas, self.lineQuad, -scx, line) + G.draw(canvas, self.lineQuad, -scx + BG_PIXELS, line) + end +end + +function CrystalIntro:drawObjects(priority) + local act = actData(self) + local sheet0 = act and self:sheet(act.sprites) + local sheet1 = act and act.sprites1 and self:sheet(act.sprites1) + if not (sheet0 or sheet1) then return end + local G = love.graphics + local oam = self.anims.oam + local shader = GbcPalette.available() + local current = nil + for index = #oam, 1, -1 do + local entry = oam[index] + local behind = bit.band(entry.attr, OAM_PRIO) ~= 0 + if behind == priority then + local sheet = bit.band(entry.attr, OAM_BANK1) ~= 0 and sheet1 or sheet0 + local quad = sheet and sheet.quads[entry.tile] + if quad then + local slot = entry.attr % 8 + if shader and current ~= slot then + GbcPalette.use(self.obPals[slot + 1]) + current = slot + end + local flipX = bit.band(entry.attr, OAM_XFLIP) ~= 0 + local flipY = bit.band(entry.attr, OAM_YFLIP) ~= 0 + G.setColor(1, 1, 1, 1) + G.draw(sheet.image, quad, + entry.x - 8 + (flipX and 8 or 0), entry.y - 16 + (flipY and 8 or 0), + 0, flipX and -1 or 1, flipY and -1 or 1) + end + end + end + if current then GbcPalette.clear() end +end + +function CrystalIntro:renderFrame() + local G = love.graphics + local baked = bakeLayers(self) + if not self.frameCanvas then + local ok, canvas = pcall(G.newCanvas, SCREEN_W, SCREEN_H) + if not ok then return nil end + canvas:setFilter("nearest", "nearest") + self.frameCanvas = canvas + end + local previous = G.getCanvas() + G.push() + G.origin() + G.setCanvas(self.frameCanvas) + G.clear(0, 0, 0, 1) + if baked then + self:drawLayer(self.backdropCanvas) + self:drawObjects(true) + self:drawLayer(self.lowCanvas) + self:drawObjects(false) + self:drawLayer(self.highCanvas) + end + G.setCanvas(previous) + G.pop() + return self.frameCanvas +end + +function CrystalIntro:drawPanel() + local G = love.graphics + local canvas = self:renderFrame() + G.setColor(1, 1, 1, 1) + if canvas then + G.draw(canvas, 0, 0) + return + end + G.setColor(0, 0, 0, 1) + G.rectangle("fill", 0, 0, SCREEN_W, SCREEN_H) + G.setColor(1, 1, 1, 1) +end + +function CrystalIntro:draw() + self:drawPanel() +end + +-- The widescreen surround follows the backdrop: colour 0 of the palette the +-- top-left visible tile's attribute selects, live through the fades. +function CrystalIntro:surroundColor() + local row = math.floor(self.scy / 8) % BG_TILES + local col = math.floor(self.scx / 8) % BG_TILES + local attr = self.attr[row * BG_TILES + col + 1] or 0 + return GbcPalette.color(self.bgPals[attr % 8 + 1], 1) or BLACK +end + +function CrystalIntro:drawWidescreen(winW, winH) + local G = love.graphics + local fill = self:surroundColor() + G.setColor(fill[1] / 255, fill[2] / 255, fill[3] / 255, 1) + G.rectangle("fill", 0, 0, winW, winH) + local scale = Chrome.fitScale(winW, winH) + G.push() + G.translate(Chrome.fitOrigin(winW, winH, scale)) + G.scale(scale, scale) + self:drawPanel() + G.pop() +end + +CrystalIntro.Scenes = Scenes +CrystalIntro.OAMSETS = OAMSETS +CrystalIntro.FRAMESETS = FRAMESETS +CrystalIntro.OBJECTS = OBJECTS +CrystalIntro.SEQUENCES = SEQUENCES +CrystalIntro.unownFade = unownFade + +return CrystalIntro diff --git a/src/ui/gen2/CrystalSplash.lua b/src/ui/gen2/CrystalSplash.lua new file mode 100644 index 00000000..6436a6fa --- /dev/null +++ b/src/ui/gen2/CrystalSplash.lua @@ -0,0 +1,423 @@ +-- The Crystal GAME FREAK splash (pokecrystal engine/movie/splash.asm:1-342): +-- Ditto bounces in, rests, transforms into the logo, then GAME FREAK / presents. + +local Chrome = require("src.ui.gen2.Chrome") +local GbcPalette = require("src.render.GbcPalette") +local Music = require("src.core.Music") +local Runtime = require("src.mods.Runtime") +local Sound = require("src.core.Sound") +local SpriteAnims = require("src.ui.gen2.SpriteAnims") +local TileSheet = require("src.ui.gen2.TileSheet") + +local CrystalSplash = {} +CrystalSplash.__index = CrystalSplash +CrystalSplash.isOpaque = true + +local SCREEN_W, SCREEN_H = 160, 144 + +-- splash.asm:91 `depixel 10, 11, 4, 0` is y-first: x=e, y=d +-- (pokecrystal engine/sprite_anims/core.asm:113). +local LOGO_X, LOGO_Y = 11 * 8 + 0, 10 * 8 + 4 +-- pokecrystal constants/gfx_constants.asm:36 OAM_YCOORD_HIDDEN. +local YCOORD_HIDDEN = 160 + +-- splash.asm:161-164 and :183-186; tile $0d is the logo's own first tile +-- borrowed as the space. +local GAME_FREAK = { 0x00, 0x01, 0x02, 0x03, 0x0d, 0x04, 0x05, 0x03, 0x01, 0x06 } +local GAME_FREAK_X, GAME_FREAK_Y = 5, 10 +local PRESENTS = { 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c } +local PRESENTS_X, PRESENTS_Y = 7, 11 + +-- splash.asm:121-122 `ld c, 16 / call DelayFrames`. +local EXIT_FRAMES = 16 + +local BLACK = { 0, 0, 0 } +local WHITE = { 255, 255, 255 } + +-- pokecrystal gfx/splash/ditto.pal, loaded into OBJ pals 0 and 1 by +-- _CGB_GamefreakLogo (engine/gfx/cgb_layouts.asm:876-893). +local DITTO_OB = { WHITE, { 107, 90, 0 }, { 189, 99, 230 }, BLACK } +-- pokecrystal gfx/sgb/predef.pal:79 PREDEFPAL_GAMEFREAK_LOGO_BG. +local DEFAULT_BG = { BLACK, { 66, 90, 90 }, { 173, 173, 173 }, WHITE } + +-------------------------------------------------------------------------- +-- Sprite-anim data, registered into SpriteAnims' shared tables +-------------------------------------------------------------------------- + +-- dbsprite (pokecrystal macros/gfx.asm): y byte first. +local function s(xTile, yTile, xPixel, yPixel, tile, attr) + return { + y = (yTile * 8 + yPixel) % 256, + x = (xTile * 8 + xPixel) % 256, + tile = tile, + attr = attr, + } +end + +-- pokecrystal data/sprite_anims/oam.asm:1098-1108 .OAMData_GameFreakLogo1_3. +local DITTO_SMALL = {} +for row = 0, 2 do + for col = 0, 2 do + DITTO_SMALL[#DITTO_SMALL + 1] = + s(col - 2, row - 2, 4, 0, row * 0x10 + col, 1) + end +end + +-- pokecrystal data/sprite_anims/oam.asm:1110-1135 .OAMData_GameFreakLogo4_11. +local DITTO_MORPH = {} +for row = 0, 5 do + for col = 0, 3 do + DITTO_MORPH[#DITTO_MORPH + 1] = + s(col - 2, row - 5, 4, 0, row * 0x10 + col, 1) + end +end + +-- pokecrystal data/sprite_anims/oam.asm:139-149: vtile bases into the +-- 256-tile Ditto sheet GameFreakPresentsInit loads whole (splash.asm:72-85). +local OAMSETS = { + CRYSTAL_GAMEFREAK_LOGO_1 = { 0xd0, DITTO_SMALL }, + CRYSTAL_GAMEFREAK_LOGO_2 = { 0xd3, DITTO_SMALL }, + CRYSTAL_GAMEFREAK_LOGO_3 = { 0xd6, DITTO_SMALL }, + CRYSTAL_GAMEFREAK_LOGO_4 = { 0x6c, DITTO_MORPH }, + CRYSTAL_GAMEFREAK_LOGO_5 = { 0x68, DITTO_MORPH }, + CRYSTAL_GAMEFREAK_LOGO_6 = { 0x64, DITTO_MORPH }, + CRYSTAL_GAMEFREAK_LOGO_7 = { 0x60, DITTO_MORPH }, + CRYSTAL_GAMEFREAK_LOGO_8 = { 0x0c, DITTO_MORPH }, + CRYSTAL_GAMEFREAK_LOGO_9 = { 0x08, DITTO_MORPH }, + CRYSTAL_GAMEFREAK_LOGO_10 = { 0x04, DITTO_MORPH }, + CRYSTAL_GAMEFREAK_LOGO_11 = { 0x00, DITTO_MORPH }, +} + +local function f(oamset, duration, flags) + return { oamset = oamset, duration = duration, flags = flags or 0 } +end + +-- pokecrystal data/sprite_anims/framesets.asm:142-158 .Frameset_GameFreakLogo. +local FRAMESETS = { + CrystalGameFreakLogo = { + f("CRYSTAL_GAMEFREAK_LOGO_1", 12), f("CRYSTAL_GAMEFREAK_LOGO_2", 1), + f("CRYSTAL_GAMEFREAK_LOGO_3", 1), f("CRYSTAL_GAMEFREAK_LOGO_2", 4), + f("CRYSTAL_GAMEFREAK_LOGO_1", 12), f("CRYSTAL_GAMEFREAK_LOGO_2", 12), + f("CRYSTAL_GAMEFREAK_LOGO_3", 4), f("CRYSTAL_GAMEFREAK_LOGO_4", 32), + f("CRYSTAL_GAMEFREAK_LOGO_5", 3), f("CRYSTAL_GAMEFREAK_LOGO_6", 3), + f("CRYSTAL_GAMEFREAK_LOGO_7", 4), f("CRYSTAL_GAMEFREAK_LOGO_8", 4), + f("CRYSTAL_GAMEFREAK_LOGO_9", 4), f("CRYSTAL_GAMEFREAK_LOGO_10", 10), + f("CRYSTAL_GAMEFREAK_LOGO_11", 7), + "end", + }, +} + +local SEQUENCES = {} + +-- GameFreakLogoSpriteAnim (splash.asm:201-342): VAR1 jump height, VAR2 sine +-- offset / frame counter; sys.flag carries the transform's NextScene call. +SEQUENCES.CrystalGameFreakLogo = function(sys, st) + local splash = sys.splash + if st.jt == 0 then + -- GameFreakLogo_Init (splash.asm:221-225). + st.jt = 1 + elseif st.jt == 1 then + -- GameFreakLogo_Bounce (splash.asm:227-283). + if st.var1 == 0 then + st.jt = 2 + st.var2 = 0 + if splash then splash:playSfx("Sfx_DittoPopUp") end + return + end + local angle = st.var2 % 0x40 + if angle < 32 then angle = angle + 32 end + st.yOffset = SpriteAnims.sine(angle, st.var1) + local before = st.var2 + st.var2 = (st.var2 - 1) % 256 + if before % 0x20 == 0 then + st.var1 = (st.var1 - 48) % 256 + if splash then splash:playSfx("Sfx_DittoBounce") end + end + elseif st.jt == 2 then + -- GameFreakLogo_Ditto (splash.asm:285-304). + if st.var2 >= 32 then + st.jt = 3 + st.var2 = 0 + if splash then splash:playSfx("Sfx_DittoTransform") end + else + st.var2 = st.var2 + 1 + end + elseif st.jt == 3 then + -- GameFreakLogo_Transform (splash.asm:306-340). + if st.var2 == 64 then + st.jt = 4 + sys.flag = 1 + else + local step = math.floor(st.var2 / 4) + st.var2 = st.var2 + 1 + if splash then splash:fadeTo(step) end + end + end +end + +-- pokecrystal data/sprite_anims/objects.asm:11-12 SPRITE_ANIM_OBJ_GAMEFREAK_LOGO. +local OBJECTS = { + CRYSTAL_GAMEFREAK_LOGO = { "CrystalGameFreakLogo", "CrystalGameFreakLogo" }, +} + +local function register(target, entries) + for name, value in pairs(entries) do + if target[name] == nil then target[name] = value end + end +end + +register(SpriteAnims.OAMSETS, OAMSETS) +register(SpriteAnims.FRAMESETS, FRAMESETS) +register(SpriteAnims.OBJECTS, OBJECTS) +register(SpriteAnims.SEQUENCES, SEQUENCES) + +-------------------------------------------------------------------------- +-- The screen +-------------------------------------------------------------------------- + +function CrystalSplash:wantsFillScale() return true end +function CrystalSplash:drawsWidescreen() return true end + +-- opts: oakSpeech (data/generated/oak_speech.lua, for its `splash` table), +-- onDone(skipped) +function CrystalSplash.new(game, opts) + opts = opts or {} + local self = setmetatable({}, CrystalSplash) + self.game = game + self.onDone = opts.onDone + local splash = (opts.oakSpeech or {}).splash or {} + local ob = splash.dittoPalette or DITTO_OB + self.obColors = { ob[1], ob[2], ob[3], ob[4] } + self.bgColors = splash.bgPalette or DEFAULT_BG + self.dittoFade = splash.dittoFade + + -- GameFreakLogoGFX's 28 1bpp tiles land at vTiles2 $00 (splash.asm:62-65): + -- letters $00-$0c, the 3x5 logo $0d-$1b. + self.sheets = { + TileSheet.new({ path = splash.presents + or "assets/generated/splash/presents.png", wide = 13, firstTile = 0x00 }), + TileSheet.new({ path = splash.logo + or "assets/generated/splash/logo.png", wide = 3, firstTile = 0x0d }), + } + self.ditto = TileSheet.new({ path = splash.ditto + or "assets/generated/splash/ditto.png", + wide = splash.dittoTilesWide or 16, firstTile = 0x00 }) + + self.anims = SpriteAnims.new() + self.anims.splash = self + -- splash.asm:91-102: spawn hidden, VAR1=96, VAR2=48. + local st = self.anims:init("CRYSTAL_GAMEFREAK_LOGO", LOGO_X, LOGO_Y) + if st then + st.yOffset = YCOORD_HIDDEN + st.var1 = 96 + st.var2 = 48 + end + + self.scene = 0 + self.timer = 0 + self.tiles = {} -- the BG tilemap, sparse: [y][x] = tile id + self.frames = 0 + self.exitTail = nil + self.done = false + self.skipped = nil + return self +end + +function CrystalSplash:enter() + local data = self.game and self.game.data + if data and data.audio and data.audio.runtime then + Music.stop() + end + if Runtime.wants("intro.boot.gamefreak") then + Runtime.emit("intro.boot.gamefreak", { screen = self, game = self.game }) + end +end + +function CrystalSplash:finish() + if self.done then return end + self.done = true + if self.onDone then self.onDone(self.skipped) end +end + +function CrystalSplash:playSfx(name) + local data = self.game and self.game.data + if data and data.audio and data.audio.sfx and data.audio.sfx[name] then + Sound.play(data, name) + end +end + +-- The transform walks OBJ pal 1 colour 2 down GameFreakDittoPaletteFade +-- (splash.asm:306-334, gfx/splash/ditto_fade.pal). +function CrystalSplash:fadeTo(step) + local fade = self.dittoFade + local color = fade and fade[step + 1] + if color then self.obColors[3] = color end +end + +function CrystalSplash:placeString(tiles, tx, ty) + local row = self.tiles[ty] + if not row then + row = {} + self.tiles[ty] = row + end + for index, tile in ipairs(tiles) do + row[tx + index - 1] = tile + end +end + +-------------------------------------------------------------------------- +-- GameFreakPresentsScene (splash.asm:125-199) +-------------------------------------------------------------------------- + +function CrystalSplash:sceneWaitSpriteAnim() + if self.anims.flag == 0 then return end + self.scene = 1 + self.timer = 0 +end + +function CrystalSplash:scenePlaceGameFreak() + if self.timer < 32 then + self.timer = self.timer + 1 + return + end + self.timer = 0 + self:placeString(GAME_FREAK, GAME_FREAK_X, GAME_FREAK_Y) + self.scene = 2 + self:playSfx("Sfx_GameFreakPresents") +end + +function CrystalSplash:scenePlacePresents() + if self.timer < 64 then + self.timer = self.timer + 1 + return + end + self.timer = 0 + self:placeString(PRESENTS, PRESENTS_X, PRESENTS_Y) + self.scene = 3 +end + +function CrystalSplash:sceneWaitForTimer() + if self.timer < 128 then + self.timer = self.timer + 1 + return + end + self:beginExit() +end + +local SCENES = { + CrystalSplash.sceneWaitSpriteAnim, + CrystalSplash.scenePlaceGameFreak, + CrystalSplash.scenePlacePresents, + CrystalSplash.sceneWaitForTimer, +} + +-- GameFreakPresentsEnd (splash.asm:117-123). +function CrystalSplash:beginExit() + if self.exitTail then return end + self.anims:clear() + self.tiles = {} + self.exitTail = 0 +end + +function CrystalSplash:update(_dt) + self.frames = self.frames + 1 + if self.exitTail then + self.exitTail = self.exitTail + 1 + if self.exitTail > EXIT_FRAMES then self:finish() end + return + end + local input = self.game and self.game.input + if input and (input:wasPressed("a") or input:wasPressed("b") + or input:wasPressed("start") or input:wasPressed("select")) then + -- SplashScreen.pressed_button (splash.asm:51-54) returns carry. + self.skipped = true + self:beginExit() + return + end + local scene = SCENES[self.scene + 1] + if scene then scene(self) end + self.anims:playFrame() +end + +-------------------------------------------------------------------------- +-- Drawing +-------------------------------------------------------------------------- + +function CrystalSplash:sheetFor(tile) + for _, sheet in ipairs(self.sheets) do + if tile >= sheet.firstTile and sheet:available() then + local index = tile - sheet.firstTile + local image = sheet:image() + local _, height = image:getDimensions() + if index < sheet.wide * (height / 8) then return sheet end + end + end + return nil +end + +function CrystalSplash:drawTile(tile, tx, ty, colors) + local sheet = self:sheetFor(tile) + if not sheet then return end + sheet.palette = colors + sheet:draw(tile, tx, ty) +end + +function CrystalSplash:drawObjects() + local G = love.graphics + local sheet = self.ditto + if not sheet:available() then return end + local oam = self.anims.oam + for index = #oam, 1, -1 do + local entry = oam[index] + local quad = sheet:quad(entry.tile) + if quad then + local flipX = math.floor(entry.attr / SpriteAnims.OAM_XFLIP) % 2 == 1 + local flipY = math.floor(entry.attr / SpriteAnims.OAM_YFLIP) % 2 == 1 + local function body() + G.setColor(1, 1, 1, 1) + G.draw(sheet:image(), quad, + entry.x - 8 + (flipX and 8 or 0), entry.y - 16 + (flipY and 8 or 0), + 0, flipX and -1 or 1, flipY and -1 or 1) + end + if GbcPalette.available() then + GbcPalette.with(self.obColors, body) + else + body() + end + end + end +end + +function CrystalSplash:drawPanel() + local G = love.graphics + local backdrop = GbcPalette.color(self.bgColors, 1) or BLACK + G.setColor(backdrop[1] / 255, backdrop[2] / 255, backdrop[3] / 255, 1) + G.rectangle("fill", 0, 0, SCREEN_W, SCREEN_H) + G.setColor(1, 1, 1, 1) + for ty, row in pairs(self.tiles) do + for tx, tile in pairs(row) do + self:drawTile(tile, tx, ty, self.bgColors) + end + end + self:drawObjects() + G.setColor(1, 1, 1, 1) +end + +function CrystalSplash:draw() + self:drawPanel() +end + +function CrystalSplash:drawWidescreen(winW, winH) + local G = love.graphics + local backdrop = GbcPalette.color(self.bgColors, 1) or BLACK + G.setColor(backdrop[1] / 255, backdrop[2] / 255, backdrop[3] / 255, 1) + G.rectangle("fill", 0, 0, winW, winH) + local scale = Chrome.fitScale(winW, winH) + G.push() + G.translate(Chrome.fitOrigin(winW, winH, scale)) + G.scale(scale, scale) + self:drawPanel() + G.pop() +end + +return CrystalSplash diff --git a/src/ui/gen2/Diploma.lua b/src/ui/gen2/Diploma.lua index 3bc80928..a8dd55ce 100644 --- a/src/ui/gen2/Diploma.lua +++ b/src/ui/gen2/Diploma.lua @@ -2,9 +2,9 @@ -- reached through `special Diploma` after Celadon Mansion 3F's game designer -- checks VAR_DEXCAUGHT == 251). Only page 1 is ever shown in play -- -- PrintDiplomaPage2 is the Game Boy Printer's second sheet --- (engine/printer/printer.asm _PrintDiploma), stubbed separately as --- "printer: no Game Boy Printer" -- so this transcribes PlaceDiplomaOnScreen --- alone, not diploma2.asm. +-- (engine/printer/printer.asm:420), built with hBGMapMode zeroed and undone by +-- SafeLoadTempTilemapToTilemap, so it never reaches the screen on the cart +-- either -- so this transcribes PlaceDiplomaOnScreen alone, not diploma2.asm. -- -- THE CERTIFICATE IS A TILEMAP, NOT A TEXT BOX. PlaceDiplomaOnScreen -- decompresses DiplomaGFX into vTiles2 and then CopyBytes' DiplomaPage1Tilemap diff --git a/src/ui/gen2/GameFreakPresents.lua b/src/ui/gen2/GameFreakPresents.lua index 7eab4456..c7a4650f 100644 --- a/src/ui/gen2/GameFreakPresents.lua +++ b/src/ui/gen2/GameFreakPresents.lua @@ -45,9 +45,9 @@ local SCREEN_W, SCREEN_H = 160, 144 -- wSpriteAnimDict[SPRITE_ANIM_DICT_GS_SPLASH] = $8d (GameFreakPresentsInit). local DICT_VTILE = 0x8d --- `depixel 10, 11, 4, 0` and `depixel 11, 11`: the macro is --- (x tile, y tile, x pixel, y pixel) and lands in de as x, y. -local LOGO_X, LOGO_Y = 10 * 8 + 4, 11 * 8 + 0 +-- `depixel 10, 11, 4, 0` and `depixel 11, 11` are y-first: x=e, y=d +-- (engine/sprite_anims/core.asm:113). +local LOGO_X, LOGO_Y = 11 * 8 + 0, 10 * 8 + 4 local SPARKLE_X, SPARKLE_Y = 11 * 8, 11 * 8 -- GameFreakPresents_PlaceGameFreak / _PlacePresents. $8d is the logo's own @@ -152,7 +152,7 @@ end function GameFreakPresents:finish() if self.done then return end self.done = true - if self.onDone then self.onDone() end + if self.onDone then self.onDone(self.skipped) end end -- PlaceString into the sparse tilemap. @@ -280,6 +280,7 @@ function GameFreakPresents:update(_dt) if input and (input:wasPressed("a") or input:wasPressed("b") or input:wasPressed("start") or input:wasPressed("select")) then -- .pressed_button: everything is torn down and the splash is over. + self.skipped = true self:beginExit() return end diff --git a/src/ui/gen2/GenderSelect.lua b/src/ui/gen2/GenderSelect.lua new file mode 100644 index 00000000..07aca9b9 --- /dev/null +++ b/src/ui/gen2/GenderSelect.lua @@ -0,0 +1,139 @@ +-- ../pokecrystal/engine/menus/init_gender.asm:23-41 InitGender, which +-- PlayerProfileSetup runs before OakSpeech (engine/menus/intro_menu.asm:61-83). + +local Chrome = require("src.ui.gen2.Chrome") +local Music = require("src.core.Music") +local RomText = require("src.core.RomText") +local Sound = require("src.core.Sound") +local Strings = require("src.core.Strings") + +local GenderSelect = {} +GenderSelect.__index = GenderSelect +GenderSelect.isOpaque = true + +-- .MenuData's two items (../pokecrystal/engine/menus/init_gender.asm:50-53), +-- and the wPlayerGender byte each writes (`ld a, [wMenuCursorY] / dec a`). +GenderSelect.OPTIONS = { + { label = "Boy", gender = "male" }, + { label = "Girl", gender = "female" }, +} + +-- menu_coords 6, 4, 12, 9 -- inclusive, so 7 columns by 6 rows. +local BOX_X, BOX_Y, BOX_W, BOX_H = 6, 4, 7, 6 +local TEXT_X, TEXT_Y = BOX_X + 2, BOX_Y + 2 +local CURSOR_X = TEXT_X - 1 +local ROW_STEP = 2 + +-- TEXTBOX_X / TEXTBOX_Y / TEXTBOX_INNERX / TEXTBOX_INNERY +-- (../pokecrystal/constants/text_constants.asm:25-32), the box PrintText fills. +local SAY_X, SAY_Y, SAY_W, SAY_H = 0, 12, 18, 4 +local SAY_TEXT_X, SAY_TEXT_Y = 1, 14 + +-- gfx/new_game/gender_screen.pal:1-4, the one palette LoadGenderScreenPal +-- writes; colour 1 is the tile the screen is filled with. +GenderSelect.GROUND = { 74, 247, 255 } + +-- `ld a, $10 / ld [wMusicFade]` with MUSIC_NONE as the fade target +-- (../pokecrystal/engine/menus/init_gender.asm:59-65). +local FADE_CONTROL = 0x10 +-- `ld c, 10 / call DelayFrames` on the way out +-- (../pokecrystal/engine/menus/init_gender.asm:39-40). +local EXIT_FRAMES = 10 + +local FALLBACK = Strings.source("Are you a boy?\nOr are you a girl?") + +function GenderSelect:wantsFillScale() return true end +function GenderSelect:drawsWidescreen() return true end + +-- opts: onDone(gender), save +function GenderSelect.new(game, opts) + opts = opts or {} + local self = setmetatable({}, GenderSelect) + self.game = game + self.save = opts.save or (game and game.save) + self.onDone = opts.onDone + self.data = (game and game.data) or {} + -- `db 1 ; default option`: the cursor opens on Boy. + self.cursor = 1 + self.exit = nil + self.text = RomText(self.data, "_AreYouABoyOrAreYouAGirlText", + FALLBACK) + return self +end + +function GenderSelect:enter() + Music.fadeOut(FADE_CONTROL) +end + +function GenderSelect:playSfx(name) + local sfx = self.data.audio and self.data.audio.sfx + if sfx and sfx[Sound.resolve(self.data, name)] then + Sound.play(self.data, name) + end +end + +function GenderSelect:choose(index) + local option = GenderSelect.OPTIONS[index] or GenderSelect.OPTIONS[1] + if self.save and self.save.player then + self.save.player.gender = option.gender + end + self.chosen = option.gender + self.exit = EXIT_FRAMES +end + +function GenderSelect:update(_dt) + if self.exit then + self.exit = self.exit - 1 + if self.exit > 0 then return end + self.exit = nil + if self.onDone then self.onDone(self.chosen) end + return + end + local input = self.game and self.game.input + if not input then return end + -- STATICMENU_WRAP, and STATICMENU_DISABLE_B: no `b` arm at all. + if input:wasPressed("up") then + self.cursor = self.cursor > 1 and self.cursor - 1 or #GenderSelect.OPTIONS + elseif input:wasPressed("down") then + self.cursor = self.cursor < #GenderSelect.OPTIONS and self.cursor + 1 or 1 + elseif input:wasPressed("a") or input:wasPressed("start") then + -- MenuClickSound (../pokecrystal/home/menu.asm:793-803). + self:playSfx("Sfx_ReadText2") + self:choose(self.cursor) + end +end + +function GenderSelect:drawPanel() + local G = love.graphics + local ground = GenderSelect.GROUND + G.setColor(ground[1] / 255, ground[2] / 255, ground[3] / 255, 1) + G.rectangle("fill", 0, 0, Chrome.SCREEN_W * 8, Chrome.SCREEN_H * 8) + G.setColor(1, 1, 1, 1) + Chrome.textbox(SAY_X, SAY_Y, SAY_W, SAY_H) + Chrome.printWrapped(self.text, SAY_TEXT_X, SAY_TEXT_Y, SAY_W, SAY_H - 1) + Chrome.box(BOX_X, BOX_Y, BOX_W, BOX_H) + for i, option in ipairs(GenderSelect.OPTIONS) do + local row = TEXT_Y + (i - 1) * ROW_STEP + Chrome.print(option.label, TEXT_X, row) + if i == self.cursor then Chrome.cursor(CURSOR_X, row) end + end +end + +function GenderSelect:draw() + self:drawPanel() +end + +function GenderSelect:drawWidescreen(winW, winH) + local G = love.graphics + G.setColor(1, 1, 1, 1) + G.rectangle("fill", 0, 0, winW, winH) + local scale = Chrome.fitScale(winW, winH) + local ox, oy = Chrome.fitOrigin(winW, winH, scale) + G.push() + G.translate(ox, oy) + G.scale(scale, scale) + self:drawPanel() + G.pop() +end + +return GenderSelect diff --git a/src/ui/gen2/HallOfFame.lua b/src/ui/gen2/HallOfFame.lua index 23ee87ce..d1940f67 100644 --- a/src/ui/gen2/HallOfFame.lua +++ b/src/ui/gen2/HallOfFame.lua @@ -31,10 +31,11 @@ -- can assert them without a graphics device (the same shape -- src/ui/gen2/SummaryMenu.lua uses). -- --- WHAT THE CACHE DOES NOT HAVE. GetTrainerPic for TRAINER_CLASS CAL -- the --- 7x7 picture of the player HOF_AnimatePlayerPic ends on -- is not extracted. --- The player's own 5x7 portrait from the trainer card is, and it is the same --- character, so that stands in and is padded into the 7x7 block the way +-- WHAT THE CACHE MAY NOT HAVE. Gold's HOF_AnimatePlayerPic ends on +-- GetTrainerPic for TRAINER_CLASS CAL, which is not extracted; Crystal's +-- HOF_LoadTrainerFrontpic ends on ChrisPic / KrisPic, which are extracted when +-- the cache is new enough. Failing both, the player's own 5x7 portrait from +-- the trainer card stands in and is padded into the 7x7 block the way -- PlaceGraphic would. ProfOaksPCRating, which the cart prints into the bottom -- box afterwards, needs Oak's PC (still a stub in src/script/gen2/Specials.lua) -- and so the box is drawn empty, exactly as it is before that farcall. @@ -45,6 +46,7 @@ local CommonText = require("src.core.gen2.CommonText") local Core = require("src.core.gen2.HallOfFame") local Font = require("src.render.Font") local GbcPalette = require("src.render.GbcPalette") +local Gen2Save = require("src.core.gen2.Save") local Music = require("src.core.Music") local Palettes = require("src.world.gen2.Palettes") local Sound = require("src.core.Sound") @@ -250,15 +252,26 @@ function HallOfFame.new(game, opts) -- The player's own two pictures. The backpic is a plain image (the battle -- HUD's), the front is the trainer card's 5x7 portrait tile sheet. local menuGfx = data.gen2MenuGfx or {} + -- HOF_AnimatePlayerPic calls GetPlayerBackpic (../pokecrystal/engine/events/ + -- halloffame.asm:520-530, ../pokecrystal/engine/gfx/player_gfx.asm:123-128). + local female = Gen2Save.isFemale(self.save) + local hud = menuGfx.battleHud or {} -- player.sprite, the same hook Gen 1's Hall of Fame raises through -- Sprites.playerPath (src/ui/HallOfFame.lua:100). self.playerBackPath = require("src.pokemon.Sprites").playerPic( - (menuGfx.battleHud or {}).playerBack, + (female and hud.playerBackFemale) or hud.playerBack, { side = "back", kind = "hof", data = data }) + -- HOF_LoadTrainerFrontpic's ChrisPic / KrisPic under class CHRIS / KRIS + -- (../pokecrystal/engine/gfx/player_gfx.asm:138-168). + local pics = hud.trainerPics or {} + self.trainerPicPath = (female and pics.KRIS) or pics.CHRIS local card = menuGfx.trainerCard if card and card.card then + -- GetCardPic's KrisCardPic arm + -- (../pokecrystal/engine/gfx/player_gfx.asm:96-101). self.portrait = TileSheet.new({ - path = card.card, wide = card.cardTilesWide or 16, firstTile = 0, + path = (female and card.cardFemale) or card.card, + wide = card.cardTilesWide or 16, firstTile = 0, }) self.portraitWide = card.portraitWide or 5 self.portraitTiles = card.portraitTiles or 35 @@ -559,6 +572,8 @@ end -- run of tile ids TrainerCard_PrintTopHalfOfCard uses, translated by the -- scroll rather than drawn at a tile coordinate. function HallOfFame:drawPortrait(tileX, tileY) + local pic = self:image(self.trainerPicPath) + if pic then return self:drawScrolled(pic, tileX, tileY, nil) end if not (self.portrait and self.portrait:available()) then return end local G = love.graphics local wide = self.portraitWide diff --git a/src/ui/gen2/MagnetTrainRide.lua b/src/ui/gen2/MagnetTrainRide.lua index 5a3f6a0f..f9f40830 100644 --- a/src/ui/gen2/MagnetTrainRide.lua +++ b/src/ui/gen2/MagnetTrainRide.lua @@ -33,6 +33,7 @@ local Assets = require("src.render.Assets") local Chrome = require("src.ui.gen2.Chrome") +local FieldMoves = require("src.world.gen2.FieldMoves") local GbcPalette = require("src.render.GbcPalette") local MagnetTrain = require("src.core.gen2.MagnetTrain") local Music = require("src.core.Music") @@ -82,6 +83,8 @@ function MagnetTrainRide.new(game, opts) self.data = game and game.data self.onDone = opts.onDone self.finished = false + local save = opts.save or (game and game.save) + self.gender = save and save.player and save.player.gender or nil local field = self.data and self.data.gen2Field local gfx = field and field.magnetTrain @@ -155,8 +158,7 @@ end -- are cut per 8x8 sub-tile rather than by the sheet's 16-pixel width, because -- the OAM data addresses the four tiles of a frame individually. function MagnetTrainRide:playerSheet() - local sprites = self.data and self.data.gen2Sprites - local def = sprites and (sprites.SPRITE_CHRIS or sprites.SPRITE_KRIS) + local def = self:playerSpriteDef() local path = def and def.image if not path then return nil end local ok, image = pcall(Assets.image, path) @@ -357,14 +359,23 @@ function MagnetTrainRide:drawBands(canvas) end end +-- GetPlayerIcon's sheet, which .InitPlayerSpriteAnim pairs with +-- SPRITE_ANIM_OBJ_MAGNET_TRAIN_RED or _BLUE +-- (../pokecrystal/engine/events/magnet_train.asm:131-141, :291-305). +function MagnetTrainRide:playerSpriteDef() + local sprites = self.data and self.data.gen2Sprites + if not sprites then return nil end + return sprites[FieldMoves.playerSprite(self.gender)] + or sprites.SPRITE_CHRIS or sprites.SPRITE_KRIS +end + -- MapObjectPals' PAL_OW_RED, the palette every .OAMData_MagnetTrainRed entry --- names. +-- names, or PAL_OW_BLUE for the _BLUE object Kris rides under. function MagnetTrainRide:playerPalette() local palettes = self.data and self.data.gen2Palettes - local sprites = self.data and self.data.gen2Sprites if not palettes then return nil end return Palettes.spritePalette(palettes, Palettes.clockDaytime(), - sprites and sprites.SPRITE_CHRIS) + self:playerSpriteDef()) end function MagnetTrainRide:drawPlayer() diff --git a/src/ui/gen2/MoveTutor.lua b/src/ui/gen2/MoveTutor.lua new file mode 100644 index 00000000..ad6f0dc4 --- /dev/null +++ b/src/ui/gen2/MoveTutor.lua @@ -0,0 +1,163 @@ +-- ../pokecrystal/engine/events/move_tutor.asm:1 MoveTutor, the submenu +-- FadeToMenu / ChooseMonToLearnTMHM / CloseSubmenu wraps, and :54 +-- CheckCanLearnMoveTutorMove, whose refusals print into a Textbox over the +-- party list (:101-103 `menu_coords 0, 12, SCREEN_WIDTH - 1, SCREEN_HEIGHT - 1`). +-- +-- ChooseMonToLearnTMHM (../pokecrystal/engine/items/tmhm.asm:73) is +-- InitPartyMenuWithCancel with PARTYMENUACTION_TEACH_TMHM, which is the list +-- src/ui/gen2/PartyMenu.lua already draws when it is handed `tmhm`. + +local Chrome = require("src.ui.gen2.Chrome") +local Happiness = require("src.core.gen2.Happiness") +local Mon = require("src.battle.gen2.Mon") +local PartyMenu = require("src.ui.gen2.PartyMenu") +local Strings = require("src.core.Strings") + +local MoveTutor = {} +MoveTutor.__index = MoveTutor +MoveTutor.isOpaque = true + +-- ../pokecrystal/data/text/common_2.asm:187 _TMHMNotCompatibleText and +-- common_3.asm:1424 _KnowsMoveText. CopyName1 leaves the MOVE in +-- wStringBuffer2 and GetNickname the nickname in wStringBuffer1. +local NOT_COMPATIBLE = Strings.source("%s is\nnot compatible\vwith %s.") +local KNOWS_MOVE = Strings.source("%s knows\n%s.") + +-- CanLearnTMHMMove's bitfield: `add_mt` gives the three tutor moves TMHM +-- flags 58-60 (../pokecrystal/constants/item_constants.asm:295-307), which +-- the cache splits out as `tutorMoves`. +function MoveTutor.canLearn(species, moveId) + if type(species) ~= "table" or not moveId then return false end + for _, id in ipairs(species.tmhm or {}) do + if id == moveId then return true end + end + for _, id in ipairs(species.tutorMoves or {}) do + if id == moveId then return true end + end + return false +end + +-- The same bitfield as a pokemon.lua view, for PartyMenu's ABLE / NOT ABLE +-- column (src/ui/gen2/PartyMenu.lua:735, which walks `tmhm` only). +function MoveTutor.speciesView(pokemon) + local cache = {} + return setmetatable({}, { __index = function(_, key) + local hit = cache[key] + if hit ~= nil then return hit or nil end + local def = pokemon and pokemon[key] + if type(def) ~= "table" then + cache[key] = false + return nil + end + local merged = {} + for field, value in pairs(def) do merged[field] = value end + local list = {} + for _, id in ipairs(def.tmhm or {}) do list[#list + 1] = id end + for _, id in ipairs(def.tutorMoves or {}) do list[#list + 1] = id end + merged.tmhm = list + cache[key] = merged + return merged + end }) +end + +-- opts: move, moveName, onDone(learned) +function MoveTutor.new(game, opts) + opts = opts or {} + local self = setmetatable({}, MoveTutor) + self.game = game + local data = (game and game.data) or {} + self.move = opts.move + self.moveName = opts.moveName or opts.move or "?" + self.onDone = opts.onDone + self.pokemon = opts.pokemon or data.pokemon + self.view = MoveTutor.speciesView(self.pokemon) + self.list = self:buildList() + return self +end + +function MoveTutor:wantsFillScale() return true end +function MoveTutor:drawsWidescreen() return true end + +function MoveTutor:buildList() + return PartyMenu.new(self.game, { + prompt = "teach", + tmhm = { move = self.move }, + pokemon = self.view, + onChoose = function(_, mon) self:picked(mon) end, + onCancel = function() self:finish(false) end, + }) +end + +function MoveTutor:say(body, onDone) + if self.game and self.game.say then return self.game:say(body, onDone) end + if onDone then onDone() end +end + +function MoveTutor:playSfx(name) + local world = self.game and self.game.world + if world and world.playSfxNamed then world:playSfxNamed(name) end +end + +-- CheckCanLearnMoveTutorMove (../pokecrystal/engine/events/move_tutor.asm:54); +-- all three failures fall back into the list, `jr nc, .loop` at :24. +function MoveTutor:picked(mon) + if not mon then return end + local name = Mon.displayName(mon) + if not MoveTutor.canLearn(self.view[mon.species], self.move) then + self:playSfx("Sfx_Wrong") + return self:say(Strings(NOT_COMPATIBLE, self.moveName, name)) + end + -- ../pokecrystal/engine/pokemon/knows_move.asm:16 sets carry and prints, + -- which is the same .didnt_learn arm. + for _, move in ipairs(mon.moves or {}) do + if move.id == self.move then + return self:say(Strings(KNOWS_MOVE, name, self.moveName)) + end + end + local game = self.game + if not (game and game.learnMoveOn) then return self:finish(false) end + game:learnMoveOn(mon, self.move, function(learned) + if not learned then return end + -- :87-88 `ld c, HAPPINESS_LEARNMOVE / callfar ChangeHappiness`. The 4000 + -- coins are the script's own takecoins (maps/GoldenrodCity.asm:124). + Happiness.change(mon, "LEARNMOVE") + self:finish(true) + end) +end + +function MoveTutor:finish(learned) + if self.done then return end + self.done = true + local stack = self.game and self.game.stack + if stack then stack:pop() end + if self.onDone then self.onDone(learned) end +end + +function MoveTutor:update(dt) + if self.done then return end + self.list:update(dt) +end + +function MoveTutor:drawPanel() + self.list:drawPanel() +end + +function MoveTutor:draw() + self.list:draw() +end + +-- PartyMenu:drawWidescreen's own blit: the party page is white to the window +-- edge, at the one integer scale Chrome.fitScale picks. +function MoveTutor:drawWidescreen(winW, winH) + local G = love.graphics + G.setColor(1, 1, 1, 1) + G.rectangle("fill", 0, 0, winW, winH) + local scale = Chrome.fitScale(winW, winH) + G.push() + G.translate(Chrome.fitOrigin(winW, winH, scale)) + G.scale(scale, scale) + self:drawPanel() + G.pop() +end + +return MoveTutor diff --git a/src/ui/gen2/NamePick.lua b/src/ui/gen2/NamePick.lua index 3f2dbee6..dec13836 100644 --- a/src/ui/gen2/NamePick.lua +++ b/src/ui/gen2/NamePick.lua @@ -34,13 +34,25 @@ local NamePick = {} NamePick.__index = NamePick NamePick.isOpaque = true --- data/player_names.asm PlayerNameArray, one half of the IF per edition. -local PRESETS = { "GOLD", "HIRO", "TAYLOR", "KARL" } -local PRESETS_SILVER = { "SILVER", "KAMON", "OSCAR", "MAX" } +-- data/player_names.asm PlayerNameArray, one half of the IF per edition, and +-- Crystal's two arrays at ../pokecrystal/data/player_names.asm:12-16, :31-35. +local PRESETS = { + gold = { "GOLD", "HIRO", "TAYLOR", "KARL" }, + silver = { "SILVER", "KAMON", "OSCAR", "MAX" }, + crystal = { "CHRIS", "MAT", "ALLAN", "JON" }, +} +local PRESETS_FEMALE = { + crystal = { "KRIS", "AMANDA", "JUANA", "JODI" }, +} -local function presetsFor() - local silver = require("src.core.GameVersion").get() == "silver" - return silver and PRESETS_SILVER or PRESETS +-- ShowPlayerNamingChoices picks the header off wPlayerGender +-- (../pokecrystal/engine/gfx/player_gfx.asm:57-62). +local function presetsFor(gender) + local version = require("src.core.GameVersion").get() + if gender == "female" and PRESETS_FEMALE[version] then + return PRESETS_FEMALE[version] + end + return PRESETS[version] or PRESETS.gold end -- menu_coords 0, 0, 10, TEXTBOX_Y - 1 (TEXTBOX_Y = 12). @@ -59,14 +71,16 @@ function NamePick:wantsFillScale() return true end function NamePick:drawsWidescreen() return true end -- opts: onDone(name), font, pic (the CAL frontpic already loaded by the Oak --- speech), picColors, presets +-- speech), picColors, presets, gender function NamePick.new(game, opts) opts = opts or {} local self = setmetatable({}, NamePick) self.game = game self.onDone = opts.onDone + self.gender = opts.gender + or (game and game.save and game.save.player and game.save.player.gender) self.items = { "NEW NAME" } - for _, name in ipairs(opts.presets or presetsFor()) do + for _, name in ipairs(opts.presets or presetsFor(self.gender)) do self.items[#self.items + 1] = name end -- `db 1 ; default option`: the cursor starts on NEW NAME, not on a preset. @@ -96,15 +110,19 @@ end function NamePick:openNaming() local data = self.game and self.game.data or {} local sprites = data.gen2Sprites - local chris = sprites and sprites.SPRITE_CHRIS + local NamingScreen = require("src.ui.gen2.NamingScreen") + local def = sprites and sprites[NamingScreen.playerSprite(self.gender)] local Palettes = require("src.world.gen2.Palettes") Screens.push(self.game, "Gen2NamingScreen", { type = "player", + gender = self.gender, menuGfx = data.gen2MenuGfx, - iconPath = chris and chris.image or nil, - -- Chris is PAL_OW_RED; the naming screen is lit like day. + iconPath = def and def.image or nil, + -- Chris is PAL_OW_RED and Kris PAL_OW_BLUE + -- (../pokecrystal/engine/overworld/player_object.asm:32-39); the naming + -- screen is lit like day. iconColors = data.gen2Palettes - and Palettes.spritePalette(data.gen2Palettes, "DAY", chris) or nil, + and Palettes.spritePalette(data.gen2Palettes, "DAY", def) or nil, onDone = function(name) -- An empty name keeps the default, the way ending entry with nothing -- typed leaves wPlayerName at its preset. @@ -112,7 +130,7 @@ function NamePick:openNaming() if name and #name > 0 then self:choose(name) else - self:choose(self.items[2] or presetsFor()[1]) + self:choose(self.items[2] or presetsFor(self.gender)[1]) end end, }) @@ -152,7 +170,7 @@ function NamePick:update(_dt) if self.fontOk then self:openNaming() else - self:choose(self.items[2] or presetsFor()[1]) + self:choose(self.items[2] or presetsFor(self.gender)[1]) end else -- A preset returns through MovePlayerPicLeft, so the pic walks back @@ -232,7 +250,7 @@ function NamePick:drawWidescreen(winW, winH) end NamePick.PRESETS = PRESETS -NamePick.PRESETS_SILVER = PRESETS_SILVER +NamePick.PRESETS_FEMALE = PRESETS_FEMALE NamePick.presetsFor = presetsFor return NamePick diff --git a/src/ui/gen2/NamingScreen.lua b/src/ui/gen2/NamingScreen.lua index f60084c4..6cca1deb 100644 --- a/src/ui/gen2/NamingScreen.lua +++ b/src/ui/gen2/NamingScreen.lua @@ -89,19 +89,28 @@ local BOTTOM_CURSOR_TILES = 5 -- NAME_* types (constants/menu_constants.asm order) as prompts + field sizes. -- Lengths are the ASM's *_NAME_LENGTH - 1, i.e. usable characters. NamingScreen.TYPES = { - player = { prompt = Strings.source("YOUR NAME?"), maxLength = 7, sprite = "SPRITE_CHRIS" }, + player = { prompt = Strings.source("YOUR NAME?"), maxLength = 7, + sprite = "SPRITE_CHRIS", spriteFemale = "SPRITE_KRIS" }, rival = { prompt = Strings.source("RIVAL'S NAME?"), maxLength = 7, sprite = "SPRITE_RIVAL" }, mom = { prompt = Strings.source("MOTHER'S NAME?"), maxLength = 7, sprite = "SPRITE_MOM" }, box = { prompt = Strings.source("BOX NAME?"), maxLength = 8, isBox = true }, nickname = { prompt = nil, maxLength = 10 }, } +-- GetPlayerIcon's two sheets (../pokecrystal/engine/gfx/player_gfx.asm:85-93), +-- which is also the pair the header icon here is cut from. +function NamingScreen.playerSprite(gender) + local kind = NamingScreen.TYPES.player + if gender == "female" and kind.spriteFemale then return kind.spriteFemale end + return kind.sprite +end + function NamingScreen:wantsFillScale() return true end function NamingScreen:drawsWidescreen() return true end -- opts: type ("player"/"rival"/"mom"/"box"/"nickname"), prompt, maxLength, --- initial, monName (nickname header), icon/sprite image path, onDone(name), --- onCancel(). +-- initial, monName (nickname header), icon/sprite image path, gender, +-- onDone(name), onCancel(). function NamingScreen.new(game, opts) opts = opts or {} local self = setmetatable({}, NamingScreen) @@ -109,6 +118,8 @@ function NamingScreen.new(game, opts) local kind = NamingScreen.TYPES[opts.type or "player"] or NamingScreen.TYPES.player self.kind = kind + self.gender = opts.gender + or (game and game.save and game.save.player and game.save.player.gender) self.isBox = opts.isBox or kind.isBox or false self.maxLength = opts.maxLength or kind.maxLength or 7 self.prompt = opts.prompt or kind.prompt or Strings.source("NICKNAME?") diff --git a/src/ui/gen2/OakSpeech.lua b/src/ui/gen2/OakSpeech.lua index a68f6ebb..230693a1 100644 --- a/src/ui/gen2/OakSpeech.lua +++ b/src/ui/gen2/OakSpeech.lua @@ -37,7 +37,9 @@ -- love.graphics.newImage skips overrides/ and AssetTransform output. local Assets = require("src.render.Assets") local Chrome = require("src.ui.gen2.Chrome") +local FieldMoves = require("src.world.gen2.FieldMoves") local Font = require("src.render.Font") +local Gen2Save = require("src.core.gen2.Save") local GbcPalette = require("src.render.GbcPalette") local Logger = require("src.core.Logger") local Music = require("src.core.Music") @@ -98,6 +100,11 @@ function OakSpeech.new(game, opts) self.playerPic = tryImage(require("src.pokemon.Sprites").playerPic( data.playerPic or "assets/generated/intro/cal.png", { side = "front", kind = "intro", data = game and game.data })) + -- DrawIntroPlayerPic's other arm, KrisPic under trainer class KRIS + -- (../pokecrystal/engine/gfx/player_gfx.asm:170-188). + self.playerPicFemale = tryImage(require("src.pokemon.Sprites").playerPic( + data.playerPicFemale or "assets/generated/intro/kris.png", + { side = "front", kind = "intro", data = game and game.data })) self.marillPic = tryImage(data.marillPic or "assets/generated/battle/front/marill.png") self.shrinkPic1 = tryImage(data.shrink1 or "assets/generated/intro/shrink1.png") @@ -114,6 +121,9 @@ function OakSpeech.new(game, opts) self.palettes = palettes self.oakColors = Palettes.trainerColors(palettes, "POKEMON_PROF") self.playerColors = Palettes.trainerColors(palettes, "CAL") + -- KrisPalette is Falkner's row, shared rather than shipped + -- (../pokecrystal/data/trainers/palettes.asm:11-12). + self.playerColorsFemale = Palettes.trainerColors(palettes, "FALKNER") self.marillColors = Palettes.monColors(palettes, self.demoSpecies) self.picColors = nil self.fontOk = false @@ -134,6 +144,23 @@ function OakSpeech.new(game, opts) return self end +-- wPlayerGender, the byte the gender step below writes +-- (../pokecrystal/engine/menus/init_gender.asm:36-38). +function OakSpeech:gender() + local save = self.game and self.game.save + return save and save.player and save.player.gender or nil +end + +-- DrawIntroPlayerPic reads the byte every time it runs, so the pic follows the +-- answer rather than whatever was loaded when the speech opened +-- (../pokecrystal/engine/gfx/player_gfx.asm:170-188). +function OakSpeech:playerPicNow() + if self:gender() == "female" and self.playerPicFemale then + return self.playerPicFemale, self.playerColorsFemale or self.playerColors + end + return self.playerPic, self.playerColors +end + function OakSpeech:text(key) local t = self.texts[key] if type(t) == "string" and #t > 0 then return t end @@ -144,8 +171,8 @@ end -- The vanilla beat list. Ids are the stable anchors a build wrapper inserts -- around; see the header for which of them are shared with Gen 1. -function OakSpeech.defaultSteps(_speech) - return { +function OakSpeech.defaultSteps(speech) + local steps = { -- `farcall InitClock` is the first line of OakSpeech. { id = "init_clock", kind = "initclock" }, -- Intro_PrepTrainerPic POKEMON_PROF, FadeInIntroPic, OakText1. @@ -170,6 +197,14 @@ function OakSpeech.defaultSteps(_speech) { id = "legend", kind = "say", textKey = "_OakText7", pic = "player" }, { id = "shrink", kind = "shrink", textKey = "_OakText7" }, } + -- PlayerProfileSetup's `farcall InitGender` runs before OakSpeech is called. + -- ../pokecrystal/engine/menus/intro_menu.asm:61-67, :79-83 + local data = speech and speech.game and speech.game.data + if FieldMoves.hasGenderChoice(data and data.gen2Sprites) then + table.insert(steps, 1, { id = "gender_select", kind = "gender", + saveKey = "gender" }) + end + return steps end local function sameSteps(steps) return steps end @@ -190,14 +225,24 @@ function OakSpeech:buildSteps() return hooked end -function OakSpeech:enter() +-- `ld de, MUSIC_ROUTE_30 / call PlayMusic`, after InitGender's fade. +-- ../pokecrystal/engine/menus/intro_menu.asm:632-633, init_gender.asm:59-65 +function OakSpeech:startMusic() + if self.musicStarted then return end + self.musicStarted = true local data = self.game and self.game.data if data and data.audio and data.audio.runtime then Music.play(data, self.music, true, { reason = "oak_speech" }) end +end + +function OakSpeech:enter() self.step = 0 self.answers = {} self.steps = self:buildSteps() + if not (self.steps[1] and self.steps[1].kind == "gender") then + self:startMusic() + end if Runtime.wants("intro.oak_speech.started") then Runtime.emit("intro.oak_speech.started", { speech = self, steps = self.steps }) end @@ -211,7 +256,7 @@ end -- same way every pic on this screen does (two shipped colours, bracketed). function OakSpeech:resolvePic(desc) if desc == "oak" then return self.oakPic, self.oakColors end - if desc == "player" then return self.playerPic, self.playerColors end + if desc == "player" then return self:playerPicNow() end if desc == "demo" then return self.marillPic, self.marillColors end if type(desc) ~= "table" then return nil, nil end if desc.type == "pokemon" then @@ -339,21 +384,59 @@ function OakSpeech:openInitClock() end end +-- InitGender, pushed where PlayerProfileSetup farcalls it: before the speech +-- says anything and before its music starts +-- (../pokecrystal/engine/menus/intro_menu.asm:79-83). +function OakSpeech:openGenderSelect(step) + local game = self.game + if not (game and game.stack) then + self:startMusic() + return self:advance() + end + self.busy = true + local pushed = Screens.push(game, "Gen2GenderSelect", { + save = game.save, + onDone = function(gender) + game.stack:pop() + self.busy = false + -- `ld hl, wPlayerName / ld de, .Chris|.Kris / call InitName`, the default + -- NamePlayer lays down before the menu opens + -- (../pokecrystal/engine/menus/intro_menu.asm:768-781). + local save = game.save + if save and save.player then + save.player.name = Gen2Save.defaultPlayerName(save.version, gender) + end + self:recordAnswer(step, gender == "female" and 2 or 1, + gender == "female" and "Girl" or "Boy", gender) + self:startMusic() + self:advance() + end, + }) + if not pushed then + self.busy = false + self:startMusic() + self:advance() + end +end + function OakSpeech:openNamePick(step) self.busy = true local NamePick = require("src.ui.gen2.NamePick") + local gender = self:gender() + local pic, picColors = self:playerPicNow() Screens.push(self.game, "Gen2NamePick", { font = self.game.fontData, + gender = gender, -- NamePlayer opens with MovePlayerPicRight, so the name menu owns the -- pic while it is up: it is the same CAL frontpic this speech has been -- showing, walked over to make room for the box. - pic = self.playerPic, - picColors = self.playerColors, + pic = pic, + picColors = picColors, presets = step.presets or namePresets(self.game, step.presetsWho or step.who or "player", - step.presetsFallback or NamePick.presetsFor()), + step.presetsFallback or NamePick.presetsFor(gender)), onDone = function(name) - name = name or NamePick.presetsFor()[1] + name = name or NamePick.presetsFor(gender)[1] self.game.save.player.name = name self.game.stack:pop() -- NamePick self.busy = false @@ -450,7 +533,9 @@ end function OakSpeech:runStep(step) local kind = step.kind or "say" - if kind == "initclock" then + if kind == "gender" then + self:openGenderSelect(step) + elseif kind == "initclock" then self:openInitClock() elseif kind == "say" then self:applyPic(step) diff --git a/src/ui/gen2/OptionsMenu.lua b/src/ui/gen2/OptionsMenu.lua index 5d8d62c8..e21f2b8a 100644 --- a/src/ui/gen2/OptionsMenu.lua +++ b/src/ui/gen2/OptionsMenu.lua @@ -20,6 +20,7 @@ local Chrome = require("src.ui.gen2.Chrome") local Logger = require("src.core.Logger") +local Performance = require("src.core.Performance") local Runtime = require("src.mods.Runtime") local Save = require("src.core.gen2.Save") local Strings = require("src.core.Strings") @@ -130,6 +131,22 @@ local ROWS = { text = function(options) return FILTERS[(options.musicFilter or 0) + 1] end }, + -- Heads the port's display group, same spot src/ui/OptionsMenu.lua's own + -- PERFORMANCE row occupies relative to ZOOM/VOID FILL/TILT/SHADER FX below + -- (the extras this tier scales). Gen 1 row shape (`value`/`step`, not this + -- file's own `text`/`cycle`) works here unmodified: OptionsMenu:cycle + -- answers `row.step` first, and drawPanel already reads a function + -- `row.value` -- both written for exactly this kind of shared mod row. + { id = "performance", label = Strings.source("PERFORMANCE"), port = true, + value = function(g) + return Performance.label(g.options and g.options.performance) + end, + step = function(g, dir) + local o = g.options + o.performance = Performance.cycle(o.performance, dir) + g:applyOptions() + return true + end }, { label = Strings.source("GAME SPEED"), key = "speed", port = true, cycle = function(options, delta) local GameSpeed = require("src.core.GameSpeed") @@ -201,6 +218,34 @@ local ROWS = { text = function(options) return require("src.render.GBCFX").levelLabel(options.gbcfx or 0) end }, + -- SHADER FX reaches Gen 2 too, not just Gen 1. Same "activate" shape as + -- CONTROLS/TOUCH LAYOUT below (a pushed screen, not a `cycle` ladder) -- + -- ShaderFXScreen is the shared list screen both generations push, `id` + -- matching the Gen 1 row's so a mod filtering "shaderfx" on Red also + -- reaches Gold. + { id = "shaderfx", label = Strings.source("SHADER FX"), port = true, + text = function(options) + local ShaderFX = require("src.render.ShaderFX") + local entry = ShaderFX.activeEntry("main") + if not entry then return "OFF" end + return (entry.name:gsub("%.slangp$", "")):upper() + end, + activate = function(game) + require("src.ui.Screens").push(game, "ShaderFXScreen", "main") + end }, + -- Dual-shader secondary slot, same shared ShaderFXScreen as the row + -- above, opened on "secondary" instead -- see src/ui/OptionsMenu.lua's + -- mirror of this row for the full rationale. + { id = "shaderfx2", label = Strings.source("SHADER FX 2"), port = true, + text = function(options) + local ShaderFX = require("src.render.ShaderFX") + local entry = ShaderFX.activeEntry("secondary") + if not entry then return "OFF" end + return (entry.name:gsub("%.slangp$", "")):upper() + end, + activate = function(game) + require("src.ui.Screens").push(game, "ShaderFXScreen", "secondary") + end }, { label = Strings.source("VIDEO MODE"), key = "videoMode", port = true, cycle = function(options, delta) local VideoMode = require("src.core.VideoMode") @@ -258,6 +303,11 @@ local ROWS = { text = function(options) return require("src.core.FrameCap").label(options.fpsCap) end }, + -- BATTLE BG (#1709): the void around the battle screen. Gold has no WIDE + -- layout and no WORLD backdrop, so the ladder is the WHITE/BLACK pair only. + { label = Strings.source("BATTLE BG"), key = "battleBg", port = true, + values = { "white", "black" }, + display = { white = "WHITE", black = "BLACK" } }, { label = Strings.source("CANCEL"), cancel = true }, } @@ -275,7 +325,7 @@ local function sameRows(_, rows) return rows end -- edit into the next opening -- the Gen 1 site rebuilds its descriptors for the -- same reason (src/ui/OptionsMenu.lua buildRows). -- --- `id` is the key a Gen 1 mod filters a row on ("gbcfx", "speed", "musicVol") +-- `id` is the key a Gen 1 mod filters a row on ("shaderfx", "speed", "musicVol") -- and is added ALONGSIDE this file's own `key`, never in place of it: a mod -- written against Red's OPTION screen finds the shared rows where it expects -- them, and the rows Gold has that Red does not (PRINT, MENU ACCOUNT, FRAME, diff --git a/src/ui/gen2/PackGfx.lua b/src/ui/gen2/PackGfx.lua index bb8ed195..68103dc8 100644 --- a/src/ui/gen2/PackGfx.lua +++ b/src/ui/gen2/PackGfx.lua @@ -12,8 +12,9 @@ -- which DrawPackGFX swaps per pocket out of PackGFX -- DrawPocketName lays a 5x3 block at (0,7) from its own 5x12 tilemap -- _CGB_PackPals loads six BG palettes and colours five rectangles with --- them: the two header halves, the quantity column, the --- pocket plaque (red) and the bag picture (green) +-- them: the two header halves, the CURSOR column (7,2) 1x9 +-- whose colour 3 is red, the pocket plaque (red) and the bag +-- picture (green) -- engine/gfx/cgb_layouts.asm:715-734 -- -- A cache from before the pack stage simply has no `pack` table; PackMenu -- falls back to its plain boxes then. diff --git a/src/ui/gen2/PackMenu.lua b/src/ui/gen2/PackMenu.lua index 5217f3ca..3a5d970c 100644 --- a/src/ui/gen2/PackMenu.lua +++ b/src/ui/gen2/PackMenu.lua @@ -12,6 +12,7 @@ local Bag = require("src.inventory.Bag") local Chrome = require("src.ui.gen2.Chrome") +local Gen2Save = require("src.core.gen2.Save") local PackGfx = require("src.ui.gen2.PackGfx") local Screens = require("src.ui.Screens") local Strings = require("src.core.Strings") @@ -78,6 +79,21 @@ local ASK_ITEM_MOVE = { "Where should this", "be moved to?" } local NO_POKEMON = { "You don't have a", "#MON!" } local EGG_CANT_HOLD = { "An EGG can't hold", "an item." } +-- _CGB_PackPals' .KrisPackPals arm, and the BATTLETYPE_TUTORIAL test above it +-- that forces the DUDE's (../pokecrystal/engine/gfx/cgb_layouts.asm:770-786). +local function packGfxFor(menuGfx, save, tutorial) + local pack = menuGfx and menuGfx.pack + if not (pack and pack.palettesFemale) then return menuGfx end + if tutorial or not Gen2Save.isFemale(save) then return menuGfx end + local female = {} + for key, value in pairs(pack) do female[key] = value end + female.palettes = pack.palettesFemale + local out = {} + for key, value in pairs(menuGfx) do out[key] = value end + out.pack = female + return out +end + -- The PACK's cursor bytes. Every pocket menu restores its own cursor and -- scroll before ScrollingMenu and writes them back after -- `ld a, -- [wItemsPocketCursor] / ld [wMenuCursorPosition], a` ... `ld a, [wMenuCursorY] @@ -157,7 +173,8 @@ function PackMenu.new(game, opts) end self:restoreCursor() -- The cart's own PACK tiles, when the cache has them. - self.gfx = PackGfx.new(game and game.data and game.data.gen2MenuGfx) + self.gfx = PackGfx.new(packGfxFor( + game and game.data and game.data.gen2MenuGfx, self.save, opts.tutorial)) self:rebuild() return self end @@ -212,9 +229,9 @@ function PackMenu.label(itemId, def) return (tostring(itemId):gsub("_", " ")) end --- TMHMPocket (engine/items/tmhm.asm) writes GetMoveName's string under the --- TM's own name, so the second line of a TM row is the MOVE's name and not the --- constant the attributes row carries. +-- TMHM_DisplayPocketItems (engine/items/tmhm.asm:381-385) places GetMoveName's +-- string three tiles right of the row's number, so a TM/HM row reads as the +-- MOVE's name and the TM's own item name is never printed. function PackMenu:moveLabel(moveId) if not moveId then return nil end local moves = self.game and self.game.data and self.game.data.moves @@ -222,6 +239,17 @@ function PackMenu:moveLabel(moveId) return (def and def.name) or (tostring(moveId):gsub("_", " ")) end +-- engine/items/tmhm.asm:357-375 -- the number a TM/HM row prints: a TM with +-- PRINTNUM_LEADINGZEROS, an HM as 'H' and its own left-aligned ordinal. +local function tmhmLabelFor(itemId, def) + local digits = tostring((def and def.tmLabel) or (def and def.name) + or itemId):match("(%d+)") + local n = tonumber(digits) + if not n then return nil end + if tostring(itemId):sub(1, 3) == "HM_" then return "H" .. n end + return ("%02d"):format(n) +end + function PackMenu:rebuild() local pocket = self:pocket().id local rows = {} @@ -243,6 +271,7 @@ function PackMenu:rebuild() name = PackMenu.label(itemId, def), teaches = self:moveLabel(def and def.teaches), tmNumber = def and def.tmNumber, + tmhmLabel = (pocket == "TM_HM" and tmhmLabelFor(itemId, def)) or nil, -- A KEY_ITEM never shows one, and engine/items/tmhm.asm:390 skips the -- count for an HM only -- a TM prints ×NN like any other stack. showCount = pocket == "ITEM" or pocket == "BALL" @@ -912,13 +941,27 @@ function PackMenu:description() return def and def.description or nil end +-- engine/gfx/cgb_layouts.asm:723-726 -- the cursor column (7,2) 1x9 takes +-- palette $3, whose colour 3 is red (gfx/pack/pack.pal). +function PackMenu:cursorAt(tx, ty, hollow) + local palette = self.gfx and self.gfx:available() + and self.gfx:colorsAt(tx, ty) + if palette then + Chrome.cursorThrough(tx, ty, palette, false, hollow) + else + Chrome.cursor(tx, ty, hollow) + end +end + -- The list, description and cursor, on top of whatever chrome was drawn. -- --- PlaceMenuItemQuantity (engine/menus/menu_2.asm:10) writes the ×N one row --- DOWN and one column RIGHT of the name -- the quantity is the entry's second --- line, not a right-aligned column, which is why every PACK row is two tiles --- tall. Its `lb bc, 1, 2` is a TWO-digit field with the leading digit blanked, --- so the ones digit sits at name + 3 whether the count is 5 or 50. +-- ScrollingMenu_CallFunctions1and2 (engine/menus/scrolling_menu.asm:424-429) +-- steps the coord on by the header's `db 5, 8` COLUMN count before +-- PlaceMenuItemQuantity (engine/menus/menu_2.asm:19-25) adds SCREEN_WIDTH + 1, +-- so the ×N sits at name + 9 on the row BELOW the name, flush right in every +-- pocket (#1425, #1693). Its `lb bc, 1, 2` is a TWO-digit field with the +-- leading digit blanked. engine/items/tmhm.asm:392-403 writes that same +-- column for a TM. -- -- ScrollingMenu_PlaceCursor (engine/menus/scrolling_menu.asm:438) marks the -- row SELECT armed with the hollow ▷ while the solid ▶ goes on looking. @@ -928,27 +971,23 @@ function PackMenu:drawList(listX, listY) local ty = listY + (row - 1) * LIST_SPACING if i <= #self.rows then local entry = self.rows[i] - if i == self.index then - Chrome.cursor(listX - 1, ty) - elseif i == self.switching then - Chrome.cursor(listX - 1, ty, true) + -- engine/items/tmhm.asm:355-385 (#1695) + if entry.tmhmLabel then + Chrome.print(entry.tmhmLabel, listX - 3, ty) end - Chrome.print(entry.name, listX, ty) - if entry.teaches then - -- The TM pocket puts the move the TM teaches on that second line, and - -- its count at listX + 9 (engine/items/tmhm.asm:392) -- on the LABEL's - -- line here, since the move name owns the one below it. - Chrome.print(entry.teaches, listX + 1, ty + 1) - if entry.showCount then - Chrome.print("\xc3\x97" .. Chrome.number(entry.count, 2), - listX + 9, ty) - end - elseif entry.showCount then + if i == self.index then + self:cursorAt(listX - 1, ty) + elseif i == self.switching then + self:cursorAt(listX - 1, ty, true) + end + Chrome.print((entry.tmhmLabel and entry.teaches) or entry.name, + listX, ty) + if entry.showCount then Chrome.print("\xc3\x97" .. Chrome.number(entry.count, 2), - listX + 1, ty + 1) + listX + 9, ty + 1) end elseif i == self:total() then - if i == self.index then Chrome.cursor(listX - 1, ty) end + if i == self.index then self:cursorAt(listX - 1, ty) end Chrome.print("CANCEL", listX, ty) end end @@ -962,8 +1001,12 @@ function PackMenu:drawDescription(ty) local lines = self.message or (self.confirm and self.confirm.prompt) if lines then local name = self:playerName() + -- TEXTBOX_INNERY and home/text.asm:397 LineChar: rows 14 and 16 (#1725). + -- Only a message too tall for those two packs its rows one apart. + local top, step = ty, 2 + if #lines > 2 then top, step = ty - 1, 1 end for i, line in ipairs(lines) do - Chrome.print((line:gsub("{PLAYER}", name)), 1, ty + i - 2) + Chrome.print((line:gsub("{PLAYER}", name)), 1, top + (i - 1) * step) end return end @@ -1039,7 +1082,7 @@ function PackMenu:drawPanel() Chrome.box(0, 0, 20, 3) Chrome.print(self:pocket().label, 2, 1) Chrome.box(0, 3, 20, 12) - self:drawList(2, 4) + self:drawList(5, 4) Chrome.box(0, 12, 20, 6) self:drawDescription(14) self:drawOverlays() diff --git a/src/ui/gen2/PhotoStudio.lua b/src/ui/gen2/PhotoStudio.lua index 2be2d785..d719e709 100644 --- a/src/ui/gen2/PhotoStudio.lua +++ b/src/ui/gen2/PhotoStudio.lua @@ -9,7 +9,7 @@ -- then a second card (PrintPartyMonPage2, the mon's other three moves and -- its non-HP stats) goes out behind it. There is no Game Boy Printer here -- -- same reason src/ui/gen2/UnownPrinter.lua takes its A press nowhere and --- PrintDiploma is stubbed in Specials.lua +-- PrintDiploma's own print goes nowhere -- -- so only page 1 is worth transcribing: it is the portrait itself, the -- part a player actually watches happen, while page 2 only ever existed as -- ink on a strip of thermal paper nobody in this port owns. H.PhotoStudio diff --git a/src/ui/gen2/Pokegear.lua b/src/ui/gen2/Pokegear.lua index 14c54177..242a3515 100644 --- a/src/ui/gen2/Pokegear.lua +++ b/src/ui/gen2/Pokegear.lua @@ -21,6 +21,8 @@ -- itself at (0,0) from $46. local Chrome = require("src.ui.gen2.Chrome") +local FieldMoves = require("src.world.gen2.FieldMoves") +local Gen2Save = require("src.core.gen2.Save") local Clock = require("src.core.gen2.Clock") local Font = require("src.render.Font") local Palettes = require("src.world.gen2.Palettes") @@ -57,6 +59,10 @@ local CARDS = { -- card over. One row, so `#self.cards` stays 1 and nothing pages. local FLY_MAP_CARD = { id = "map", label = "FLY" } +-- ../pokecrystal/engine/events/specials.asm:102 OverworldTownMap -> _TownMap +-- (:1757): the wall map and the DECO_TOWN_MAP poster, no strip and no card gate. +local TOWN_MAP_CARD = { id = "map", label = "MAP" } + -- ---------------------------------------------------------------- the radio -- -- engine/pokegear/radio.asm is not a text table: it is a jumptable of code. @@ -878,7 +884,14 @@ function Pokegear.new(game, opts) self.save = opts.save or (game and game.save) local data = game and game.data or {} self.landmarks = opts.landmarks or data.gen2Landmarks + -- TownMap_GetCurrentLandmark (../pokecrystal/engine/pokegear/pokegear.asm:1783) + -- reads the map header itself, so a caller that has no landmark to hand still + -- gets one. self.currentLandmark = opts.currentLandmark + if self.currentLandmark == nil and game and game.currentLandmark then + local ok, id = pcall(game.currentLandmark, game) + if ok then self.currentLandmark = id end + end self.clock = opts.clock self.onClose = opts.onClose self.cards = self:visibleCards() @@ -921,6 +934,7 @@ function Pokegear.new(game, opts) -- reject already dropped, so the cursor's skip loop is just "next row". self.fly = opts.fly self.onFly = opts.onFly + self.flyMon = opts.flyMon if self.fly and #self.fly > 0 then self.cards = { FLY_MAP_CARD } self.cardIndex = 1 @@ -931,13 +945,29 @@ function Pokegear.new(game, opts) self.flyIndex = (self:region() == "kanto") and #self.fly or 1 end + -- _TownMap (../pokecrystal/engine/pokegear/pokegear.asm:1757): the same map, + -- one card, no ENGINE_MAP_CARD test and no strip to page. + self.townMap = (not self.fly) and opts.townMap and true or nil + if self.townMap then + self.cards = { TOWN_MAP_CARD } + self.cardIndex = 1 + self.mode = "card" + end + -- _CGB_PokegearPals writes wBGPals1 only (engine/gfx/cgb_layouts.asm:157), -- so the RED_WALK icon keeps the overworld's own OBJ palette. self.sprites = opts.sprites or data.gen2Sprites self.palettes = opts.palettes or data.gen2Palettes + -- TownMapMon reads wCurPartyMon's icon + -- (../pokecrystal/engine/pokegear/pokegear.asm:2708-2721). + self.icons = opts.icons or data.gen2Icons local gfx = (opts.menuGfx or data.gen2MenuGfx or {}).pokegear self.gfx = gfx + -- _CGB_PokegearPals picks FemalePokegearPals off wPlayerGender + -- (../pokecrystal/engine/gfx/cgb_layouts.asm:179-190). + self.gearPals = gfx and ((Gen2Save.isFemale(self.save) + and gfx.palettesFemale) or gfx.palettes) or nil if gfx then self.sheet = TileSheet.new({ path = gfx.tiles, wide = gfx.tilesWide or 16, firstTile = 0, @@ -951,19 +981,25 @@ function Pokegear:styled() return self.sheet ~= nil and self.sheet:available() end +-- MalePokegearPals or FemalePokegearPals, whichever _CGB_PokegearPals would +-- have copied into wBGPals1 (../pokecrystal/engine/gfx/cgb_layouts.asm:179-190). +function Pokegear:pals() + return self.gearPals +end + -- TownMapPals: a nybble per tile id for $00..$5f, palette 0 above that. function Pokegear:colorsFor(tile) - local gfx = self.gfx - if not (gfx and gfx.palettes) then return nil end - if tile >= 0x60 then return gfx.palettes[1] end - return gfx.palettes[(gfx.palMap and gfx.palMap[tile + 1]) or 1] + local pals = self:pals() + if not pals then return nil end + if tile >= 0x60 then return pals[1] end + return pals[(self.gfx.palMap and self.gfx.palMap[tile + 1]) or 1] end -- Every string on a Pokegear card is a run of font tiles laid straight into -- the tilemap, so it wears BG palette 0 (PokegearPals' first entry) rather -- than drawing as black ink over whatever was underneath. function Pokegear:text(str, tx, ty) - local pals = self.gfx and self.gfx.palettes + local pals = self:pals() return Chrome.printThrough(str, tx, ty, pals and pals[1]) end @@ -1080,6 +1116,7 @@ function Pokegear:update(_dt) -- The fly picker owns the whole screen: no strip, no card paging, and B -- answers -1 rather than backing out to the strip. if self.fly then return self:updateFlyMap(input) end + if self.townMap then return self:updateTownMap(input) end if self.mode == "strip" then local stripCard = self:card() if not (stripCard and stripCard.id == "phone") then @@ -1161,8 +1198,9 @@ function Pokegear:region() and landmarks[self.currentLandmark] local index = current and current.index or 0 -- LANDMARK_FAST_SHIP is $5e = 94, past every Kanto landmark and still Johto. - if index == 94 then return "johto" end - if index >= 46 then return "kanto" end + if index == self:landmarkIndex("FAST_SHIP", 0x5e) then return "johto" end + -- `cp KANTO_LANDMARK`, which is PALLET_TOWN's own index. + if index >= self:landmarkIndex("PALLET_TOWN", 0x2e) then return "kanto" end return "johto" end @@ -1606,18 +1644,31 @@ local LANDMARK_PALLET_TOWN = 0x2e local LANDMARK_VICTORY_ROAD = 0x57 local LANDMARK_ROUTE_28 = 0x5d +-- ../pokecrystal/constants/landmark_constants.asm:34 inserts BATTLE_TOWER, so +-- every index above it is one higher than pokegold's; the cache's own record is +-- the authority and the numbers above are the fallback for a dataset without one. +function Pokegear:landmarkIndex(id, fallback) + local records = (self.landmarks or {}).landmarks + local record = records and (records["LANDMARK_" .. id] or records[id]) + local index = record and tonumber(record.index) + return index or fallback +end + -- Returns d (last) and e (first). Kanto's pair comes from -- TownMap_GetKantoLandmarkLimits, which withholds everything west of Victory -- Road until the Hall of Fame is on the record -- before that the Kanto map -- only walks the seven landmarks on the road to Indigo Plateau. function Pokegear:cursorLimits() if self:region() ~= "kanto" then - return LANDMARK_SILVER_CAVE, LANDMARK_NEW_BARK_TOWN + -- `ld d, KANTO_LANDMARK - 1`, which is SILVER_CAVE either way round. + return self:landmarkIndex("SILVER_CAVE", LANDMARK_SILVER_CAVE), + self:landmarkIndex("NEW_BARK_TOWN", LANDMARK_NEW_BARK_TOWN) end + local last = self:landmarkIndex("ROUTE_28", LANDMARK_ROUTE_28) if ((self.save or {}).flags or {}).HALL_OF_FAME then - return LANDMARK_ROUTE_28, LANDMARK_PALLET_TOWN + return last, self:landmarkIndex("PALLET_TOWN", LANDMARK_PALLET_TOWN) end - return LANDMARK_ROUTE_28, LANDMARK_VICTORY_ROAD + return last, self:landmarkIndex("VICTORY_ROAD", LANDMARK_VICTORY_ROAD) end -- The cursor's landmark index. Unset, it is the player's own, which is what @@ -1638,32 +1689,59 @@ end -- PokegearMap_ContinueMap's .DPad. Both branches share the increment or -- decrement that follows them, which is why the wrap writes e - 1 / d + 1 --- rather than e / d. -function Pokegear:moveMapCursor(input) +-- rather than e / d. _TownMap's own .pressed_up / .pressed_down +-- (../pokecrystal/engine/pokegear/pokegear.asm:1853-1877) are the same pair of +-- wraps against the same d/e, so the poster steps through here too. +function Pokegear:stepMapCursor(delta) local last, first = self:cursorLimits() local cursor = self:mapCursorIndex() - if input:wasPressed("up") then + if delta > 0 then -- `cp d / jr c, .wrap_around_up`: below the last landmark the value is -- left alone, at or past it the cursor is slammed to e - 1 first. if cursor >= last then cursor = first - 1 end cursor = cursor + 1 - elseif input:wasPressed("down") then + else -- `cp e / jr nz, .wrap_around_down`: only the first landmark wraps. if cursor == first then cursor = last + 1 end cursor = cursor - 1 + end + self.mapCursor = cursor +end + +function Pokegear:moveMapCursor(input) + if input:wasPressed("up") then + self:stepMapCursor(1) + elseif input:wasPressed("down") then + self:stepMapCursor(-1) elseif input:wasPressed("right") then -- Left and right do not move the cursor at all on this card: they page -- the POKeGEAR. .right takes the PHONE if it is owned and the RADIO if -- it is not; .left always takes the CLOCK. self:switchCard("phone", "radio") - return elseif input:wasPressed("left") then self:switchCard("clock") - return - else + end +end + +-- --------------------------------------------------------------- town map +-- +-- _TownMap's `.loop` (../pokecrystal/engine/pokegear/pokegear.asm:1831-1845) +-- reads hJoyPressed for B and hJoyLast for UP and DOWN, and nothing else: A +-- takes nothing, and left/right have no card to page to. +function Pokegear:updateTownMap(input) + if input:wasPressed("b") then + if self.townMapClosed then return end + self.townMapClosed = true + local stack = self.game and self.game.stack + if stack then stack:pop() end + if self.onClose then self.onClose() end return end - self.mapCursor = cursor + if input:wasPressed("up") then + self:stepMapCursor(1) + elseif input:wasPressed("down") then + self:stepMapCursor(-1) + end end -- ------------------------------------------------------------------ fly map @@ -1797,7 +1875,7 @@ function Pokegear:loadArrowSheet() -- pokegold data/sprite_anims/oam.asm .OAMData_RedWalk: STILL_CURSOR's -- oamset reuses RED_WALK's OAM data, so this wears PAL_OW_RED. palette = (self.playerIcon and self.playerIcon.objColors) - or (gfx.palettes and gfx.palettes[1]), + or (self:pals() and self:pals()[1]), }) end end @@ -1915,7 +1993,7 @@ end -- painted white underneath its strings is what puts a cream bar behind every -- run printThrough lays down. function Pokegear:paperColor() - local pals = self.gfx and self.gfx.palettes + local pals = self:pals() return (pals and pals[1] and pals[1][1]) or { 255, 255, 255 } end @@ -1930,7 +2008,7 @@ end -- space IS a font-page cell on colour 0, which is the contrast the day/time -- window and the map's KANTO label are drawn against. function Pokegear:groundColor() - local pals = self.gfx and self.gfx.palettes + local pals = self:pals() local pal = pals and pals[1] return (pal and pal[#pal]) or { 0, 0, 0 } end @@ -1990,6 +2068,20 @@ function Pokegear:drawFlyBubble() Chrome.cursor(18, 1) end +-- _TownMap.InitTilemap (../pokecrystal/engine/pokegear/pokegear.asm:1891-1918): +-- with no card strip above it the rule turns down at (7,0) and runs back out +-- along row 2, boxing the name plate into the top right corner instead. +function Pokegear:drawTownMapRule() + self:tile(0x06, 0, 0) + for x = 1, 6 do self:tile(0x07, x, 0) end + self:tile(0x17, 7, 0) + self:tile(0x16, 7, 1) + self:tile(0x26, 7, 2) + -- `ld bc, NAME_LENGTH` from (8,2), so the run stops one short of the cap. + for x = 8, 18 do self:tile(0x07, x, 2) end + self:tile(0x17, 19, 2) +end + function Pokegear:drawMap() -- The REGION follows the player (`cp KANTO_LANDMARK` in -- PokegearMap_CheckRegion); the name box follows the CURSOR, which the @@ -2001,11 +2093,15 @@ function Pokegear:drawMap() if self.fly then self:drawFlyBubble() else - self:drawStrip() - -- The header's own bottom rule: $07 across (1,2), with $06 and $17 as caps. - self:tile(0x06, 0, 2) - for x = 1, 18 do self:tile(0x07, x, 2) end - self:tile(0x17, 19, 2) + if self.townMap then + self:drawTownMapRule() + else + self:drawStrip() + -- The header's own bottom rule: $07 across (1,2), with $06 and $17 as caps. + self:tile(0x06, 0, 2) + for x = 1, 18 do self:tile(0x07, x, 2) end + self:tile(0x17, 19, 2) + end -- PokegearMap_UpdateLandmarkName: ClearBox(8,0) 2 rows by 12 columns -- -- with ' ', which is a font-page cell and so reads as BG palette 0's @@ -2039,7 +2135,11 @@ function Pokegear:drawMap() end end if current and current.x and current.y then - self:mapCursorSprite(current.x, current.y) + -- FlyMap's cursor is TownMapMon, the FlyMon's icon; only the MAP card's is + -- the POKEGEAR_ARROW (../pokecrystal/engine/pokegear/pokegear.asm:2326). + if not (self.fly and self:drawFlyMonCursor(current.x, current.y)) then + self:mapCursorSprite(current.x, current.y) + end end end @@ -2048,7 +2148,12 @@ end function Pokegear:loadPlayerIcon() if self.playerIcon ~= nil then return end self.playerIcon = false - local def = self.sprites and self.sprites.SPRITE_CHRIS + -- SPRITE_ANIM_OBJ_RED_WALK or _BLUE_WALK, which is the sheet GetPlayerIcon + -- loaded plus PAL_OW_RED or PAL_OW_BLUE + -- (../pokecrystal/engine/pokegear/pokegear.asm:2737, :2752-2757). + local def = self.sprites + and self.sprites[FieldMoves.playerSprite( + self.save and self.save.player and self.save.player.gender)] if not (def and def.image) then return end local ok, icon = pcall(SpriteRenderer.new, def, "player") if not (ok and icon) then return end @@ -2078,6 +2183,48 @@ function Pokegear:drawPlayerIcon(x, y) return true end +-- TownMapMon (../pokecrystal/engine/pokegear/pokegear.asm:2708-2721): the +-- FlyMon's party icon, on PAL_OW_RED like the RED_WALK icon beside it. +function Pokegear:loadFlyMonIcon() + if self.flyMonIcon ~= nil then return end + self.flyMonIcon = false + local mon = self.flyMon + if type(mon) ~= "table" then return end + local icons = self.icons + local iconId = mon.isEgg and "ICON_EGG" + or (icons and icons.species and mon.species + and icons.species[mon.species]) + local entry = iconId and icons and icons.icons and icons.icons[iconId] + if not (entry and entry.image) then return end + local def = { + id = "SPRITE_FLY_MON", image = entry.image, frames = 2, walker = false, + spriteType = "POKEMON_SPRITE", palette = "PAL_OW_RED", paletteId = 0, + species = mon.species, icon = iconId, + } + local ok, icon = pcall(SpriteRenderer.new, def, "flymon") + if not (ok and icon) then return end + local world = self.game and self.game.world + local daytime = (world and world.daytime) + or Palettes.clockDaytime(self.clock and self.clock.hour or nil) + local colors = self.palettes + and Palettes.spritePalette(self.palettes, daytime, def) + if colors then + icon:setObjPalette(colors, ("gen2:%s:0"):format(tostring(daytime))) + end + self.flyMonIcon = icon +end + +-- .Frameset_PartyMon is two 8-frame icon beats and no mirror +-- (data/sprite_anims/framesets.asm:66-69). +function Pokegear:drawFlyMonCursor(x, y) + self:loadFlyMonIcon() + if not self.flyMonIcon then return false end + love.graphics.setColor(1, 1, 1, 1) + self.flyMonIcon:draw(x - 8, y - 8, 0, -4, "down", 0, false, false, false, + math.floor((self.iconTimer or 0) / 8) % 2) + return true +end + -- The cursor arrow. It is the same PokegearSpritesGFX sheet the mode -- indicator uses, at tile $04; without the sheet the port falls back to -- Chrome's own cursor glyph so the card is still navigable. @@ -2206,6 +2353,14 @@ function Pokegear:drawPlain() end return end + if self.townMap then + -- No town-map art in this cache, so the name plate the cursor walks is all + -- there is to show (../pokecrystal/engine/pokegear/pokegear.asm:700). + Chrome.box(7, 0, 13, 3) + local current = self:mapLandmark() + Chrome.print(flatName(current and current.name), 9, 1) + return + end Chrome.box(0, 0, 20, 4) local card = self:card() Chrome.print(card and card.label or "", 2, 1) @@ -2283,8 +2438,9 @@ function Pokegear:drawPanel() end -- Last: the arrow is an OBJ and composites over whatever the card drew. -- _FlyMap has no card strip and never animates it - -- (engine/pokegear/pokegear.asm:1999). - if not self.fly then self:drawModeArrow() end + -- (engine/pokegear/pokegear.asm:1999); neither does _TownMap + -- (../pokecrystal/engine/pokegear/pokegear.asm:1757). + if not (self.fly or self.townMap) then self:drawModeArrow() end G.setColor(1, 1, 1, 1) end diff --git a/src/ui/gen2/SummaryMenu.lua b/src/ui/gen2/SummaryMenu.lua index 9ccc3723..431b778f 100644 --- a/src/ui/gen2/SummaryMenu.lua +++ b/src/ui/gen2/SummaryMenu.lua @@ -64,6 +64,7 @@ local GbcPalette = require("src.render.GbcPalette") local HpBar = require("src.battle.gen2.HpBar") local ItemEffects = require("src.core.gen2.ItemEffects") local Mon = require("src.battle.gen2.Mon") +local MonAnim = require("src.render.MonAnim") local Palettes = require("src.world.gen2.Palettes") local Pokerus = require("src.core.gen2.Pokerus") local Unown = require("src.core.gen2.Unown") @@ -102,6 +103,14 @@ local PAGE_PALETTES = { { { 255, 255, 255 }, { 140, 255, 255 }, { 140, 255, 255 }, { 0, 0, 0 } }, } +-- gfx/stats/stats.pal, the colour LoadStatsScreenPals writes over colour 0 of +-- BG palettes 0 and 2 (engine/gfx/color.asm:386-390). #1693 +local PAGE_TINTS = { + { 255, 156, 255 }, + { 173, 255, 115 }, + { 140, 255, 255 }, +} + -- PrintTempMonStats' .StatNames, and the wTempMon fields it prints beside -- them. steps two rows, so the five labels are 2 rows apart and the -- values start one row below the first label. @@ -314,6 +323,55 @@ function SummaryMenu:playCry() if ok and Sound and Sound.playCry then pcall(Sound.playCry, self.game.data, mon.species) end + self:startPicAnim() +end + +-- StatsScreen_PlaceFrontpic loads ANIM_MON_MENU, the longer scene. A cache +-- with no `anim` row -- every Gold and Silver one -- leaves picAnim nil. +-- ../pokecrystal/engine/pokemon/stats_screen.asm:889-901 +function SummaryMenu:startPicAnim() + self.picAnim = nil + local mon = self.mon + local def = mon and self.pokemon and self.pokemon[mon.species] + if not def then return end + local data = def.anim + if mon.species == Unown.SPECIES and def.letters then + local entry = def.letters[Unown.monLetter(mon)] + if entry and entry.anim then data = entry.anim end + end + if not data then return end + local sheet = self:picImage(data.sheet) + if not sheet then return end + local runner = MonAnim.new(data, "menu") + if not runner then return end + self.picAnim = { runner = runner, sheet = sheet, size = data.tiles * 8, + quads = {} } +end + +-- AnimateFrontpic's .loop, one scene command per frame. +-- ../pokecrystal/engine/gfx/pic_animation.asm:79-89 +function SummaryMenu:stepPicAnim() + local state = self.picAnim + if not state then return end + state.runner:update() + if state.runner:finished() then self.picAnim = nil end +end + +-- The sheet is one column of whole pictures, base picture first. +function SummaryMenu:picAnimFrame() + local state = self.picAnim + if not state then return nil end + local frame = state.runner:currentFrame() + if frame <= 0 then return nil end + local quad = state.quads[frame] + if not quad then + local w, h = state.sheet:getDimensions() + if (frame + 1) * state.size > h then return nil end + quad = love.graphics.newQuad(0, frame * state.size, state.size, state.size, + w, h) + state.quads[frame] = quad + end + return state.sheet, quad, state.size end function SummaryMenu:speciesDef() @@ -767,6 +825,7 @@ function SummaryMenu:updateMoveDetail(input) end function SummaryMenu:update(_dt) + self:stepPicAnim() local input = self.game and self.game.input if not input then return end if self.moveDetail then @@ -850,13 +909,20 @@ end -- A tile out of StatsScreenPageTilesGFX. The fallback arm draws each of the -- seven shapes by hand for a cache built before menu_gfx.stats existed. -function SummaryMenu:pageTile(id, tx, ty) +function SummaryMenu:pageTile(id, tx, ty, colors) local G = love.graphics local px, py = tx * 8, ty * 8 local sheet = self:statsTiles() if sheet and sheet.quads[id] then G.setColor(1, 1, 1, 1) - G.draw(sheet.image, sheet.quads[id], px, py) + local function body() + G.draw(sheet.image, sheet.quads[id], px, py) + end + if colors and GbcPalette.available() then + GbcPalette.with(colors, body) + else + body() + end return end G.setColor(0, 0, 0, 1) @@ -943,7 +1009,7 @@ end -- PrepMonFrontpic at hlcoord 0, 0: a 7x7 block with the pic padded into it and -- the rest of the block left at the palette's colour 0. -function SummaryMenu:drawPicBlock(image, colors) +function SummaryMenu:drawPicBlock(image, colors, quad, size) if not image then return end local G = love.graphics -- A fill behind the pic reads a palette colour directly, so it has to come @@ -952,10 +1018,16 @@ function SummaryMenu:drawPicBlock(image, colors) G.setColor(blank[1] / 255, blank[2] / 255, blank[3] / 255, 1) G.rectangle("fill", 0, 0, 7 * 8, 7 * 8) - local wide = math.floor(image:getWidth() / 8) + local wide = math.floor((size or image:getWidth()) / 8) local pad = PIC_PAD[wide] or PIC_PAD[7] G.setColor(1, 1, 1, 1) - local function body() G.draw(image, pad[1] * 8, pad[2] * 8) end + local function body() + if quad then + G.draw(image, quad, pad[1] * 8, pad[2] * 8) + else + G.draw(image, pad[1] * 8, pad[2] * 8) + end + end if colors and GbcPalette.available() then GbcPalette.with(colors, body) else @@ -970,6 +1042,8 @@ function SummaryMenu:drawPic() if not image then return end local colors = self.palettes and mon.species and Palettes.monColors(self.palettes, mon.species, mon.shiny) or nil + local sheet, quad, size = self:picAnimFrame() + if sheet then return self:drawPicBlock(sheet, colors, quad, size) end self:drawPicBlock(image, colors) end @@ -1047,8 +1121,26 @@ function SummaryMenu:drawHorizontalDivider() end end +-- BG palette 0 as the stats screen leaves it: the page tint in colour 0, black +-- ink in colour 3 (engine/gfx/color.asm:386-390). +function SummaryMenu:lowerColors() + local tint = PAGE_TINTS[self.page] or PAGE_TINTS[PINK_PAGE] + return { tint, tint, tint, { 0, 0, 0 } } +end + +-- StatsScreen_LoadGFX's .ClearBox: hlcoord 0, 8 / lb bc, 10, 20, the ten rows +-- LoadStatsScreenPals then tints (engine/pokemon/stats_screen.asm:549-557). +function SummaryMenu:drawPageBackground() + local G = love.graphics + local tint = GbcPalette.color(self:lowerColors(), 1) or { 255, 255, 255 } + G.setColor(tint[1] / 255, tint[2] / 255, tint[3] / 255, 1) + G.rectangle("fill", 0, 8 * 8, Chrome.SCREEN_W * 8, 10 * 8) + G.setColor(0, 0, 0, 1) +end + function SummaryMenu:drawVerticalDivider(tx) - for y = 8, 17 do self:pageTile(TILE_VERTICAL_DIVIDER, tx, y) end + local colors = self:lowerColors() + for y = 8, 17 do self:pageTile(TILE_VERTICAL_DIVIDER, tx, y, colors) end end function SummaryMenu:drawUpperHalf() @@ -1066,11 +1158,12 @@ end function SummaryMenu:drawPinkPage() local mon = self.mon or {} local maxHp = mon.maxHp or (mon.stats and mon.stats.hp) or 0 + local tint = PAGE_TINTS[self.page] or PAGE_TINTS[PINK_PAGE] -- DrawPlayerHP is DrawBattleHPBar with d = 6 and b = 0: "HP:" at (0,9), six -- bar cells, and the end cap at (8,9) -- which LoadPinkPage then rewrites as -- $41, the same shape from the stats sheet. if self.hud and self.hud:available() then - self.hud:drawHpBar(mon.hp, maxHp, 0, 9) + self.hud:drawHpBar(mon.hp, maxHp, 0, 9, tint) else HpBar.drawWithLabel(self.palettes, mon.hp, maxHp, 0, 9, Font) end @@ -1082,15 +1175,16 @@ function SummaryMenu:drawPinkPage() -- and (19,16). local fraction = HpBar.expFraction(mon, self:growth(), Mon.experienceForLevel) if self.hud and self.hud:available() then - self.hud:drawExpBar(fraction, 11, 16) + self.hud:drawExpBar(fraction, 11, 16, tint) else -- No HUD sheet in the cache: the plain rule, which is HP_BAR_LENGTH_PX -- (48) wide rather than the exp bar's 64, so it stops two tiles short of -- the $41 cap. A cache old enough to hit this has no bar tiles at all. HpBar.drawExp(self.palettes, fraction, 11 * 8, 16 * 8 + 3) end - self:pageTile(TILE_BAR_CAP_LEFT, 10, 16) - self:pageTile(TILE_BAR_CAP_RIGHT, 19, 16) + local colors = self:lowerColors() + self:pageTile(TILE_BAR_CAP_LEFT, 10, 16, colors) + self:pageTile(TILE_BAR_CAP_RIGHT, 19, 16, colors) end function SummaryMenu:drawGreenPage() @@ -1152,6 +1246,7 @@ function SummaryMenu:drawPanel() self:drawMoveDetail() else Chrome.clear() + self:drawPageBackground() self:drawUpperHalf() if self.page == GREEN_PAGE then self:drawGreenPage() @@ -1185,6 +1280,7 @@ SummaryMenu.STAT_LABELS = STAT_LABELS SummaryMenu.STAT_KEYS = STAT_KEYS SummaryMenu.TYPE_NAMES = TYPE_NAMES SummaryMenu.PAGE_PALETTES = PAGE_PALETTES +SummaryMenu.PAGE_TINTS = PAGE_TINTS SummaryMenu.levelText = levelText return SummaryMenu diff --git a/src/ui/gen2/TitleState.lua b/src/ui/gen2/TitleState.lua index 32b36d69..23d33dc5 100644 --- a/src/ui/gen2/TitleState.lua +++ b/src/ui/gen2/TitleState.lua @@ -11,6 +11,7 @@ local Chrome = require("src.ui.gen2.Chrome") local GbcPalette = require("src.render.GbcPalette") local Music = require("src.core.Music") local Runtime = require("src.mods.Runtime") +local Sound = require("src.core.Sound") local SpriteAnims = require("src.ui.gen2.SpriteAnims") local TitleState = {} @@ -121,15 +122,75 @@ function TitleState.new(game, opts) -- How far past the 160px frame trails may fly (GB pixels); set each draw. self.trailMaxX = 200 self.musicStarted = false + + -- engine/movie/title.asm:104-127,217-273,304-338 + self.suicuneColor, self.suicuneGray = {}, {} + if type(title.suicuneFrames) == "table" then + for i, path in ipairs(title.suicuneFrames) do + self.suicuneColor[i] = tryImage(path) + end + end + if type(title.suicuneFramesGray) == "table" then + for i, path in ipairs(title.suicuneFramesGray) do + self.suicuneGray[i] = tryImage(path) + end + end + self.suicuneX = tonumber(title.suicuneX) or 48 + self.suicuneY = tonumber(title.suicuneY) or 96 + -- SuicuneFrameIterator's `and %111` clock (engine/movie/title.asm:217-243). + self.suicuneEvery = tonumber(title.suicuneEvery) or 8 + self.suicuneTick = 0 + self.suicuneFrame = 1 + self.gemColor = tryImage(title.gem) + self.gemGray = tryImage(title.gemGray) + self.gemX = tonumber(title.gemX) or 56 + self.gemRestY = tonumber(title.gemY) or 6 + self.gemStep = tonumber(title.gemStep) or 2 + -- TitleScreenEntrance (engine/menus/intro_menu.asm:1078-1123): hSCX walks + -- to 0 while alternating logo lines converge and the gem descends. + local entrance = type(title.entrance) == "table" and title.entrance or nil + self.entrance = entrance + self.entranceScx = entrance and (tonumber(entrance.scx) or 112) or 0 + self.entranceStep = entrance and (tonumber(entrance.step) or 4) or 4 + self.entranceLines = entrance and (tonumber(entrance.lines) or 80) or 0 + self.entranceHideBelow = entrance and tonumber(entrance.hideBelow) or nil + self.gemY = entrance and (tonumber(title.gemFromY) or -50) or self.gemRestY + self.entranceSfx = title.entranceSfx + self.timeoutFrames = tonumber(title.timeoutFrames) + self.onTimeout = opts.onTimeout + -- The copyright window line is pal 7 (engine/movie/title.asm:40-43); its + -- colour 0 backs the whole band. + local pals = type(title.palettes) == "table" and title.palettes.bg or nil + local band = type(pals) == "table" and type(pals[8]) == "table" + and pals[8][1] or nil + self.bandColor = (type(band) == "table" and #band >= 3) + and { band[1] / 255, band[2] / 255, band[3] / 255, 1 } or { 0, 0, 0, 1 } return self end -function TitleState:enter() +function TitleState:startMusic() + if self.musicStarted then return end local data = self.game and self.game.data - if data and data.audio and data.audio.runtime and not self.musicStarted then + if data and data.audio and data.audio.runtime then Music.play(data, "Music_TitleScreen", true, { reason = "title" }) self.musicStarted = true end +end + +function TitleState:enter() + if self.entrance then + -- _TitleScreen silences the channels and plays the entrance sting; + -- MUSIC_TITLE waits for the entrance to land + -- (engine/movie/title.asm:184,210-213; engine/menus/intro_menu.asm:1118-1119). + Music.stop() + local data = self.game and self.game.data + if data and data.audio and data.audio.runtime and self.entranceSfx + and data.audio.sfx and data.audio.sfx[self.entranceSfx] then + Sound.play(data, self.entranceSfx) + end + else + self:startMusic() + end -- intro.boot.title: the last card of the GS boot cinema, up with its music -- started. Gen 2 only, like the rest of intro.boot.* (see -- src/ui/gen2/CopyrightSplash.lua for why the set is new rather than shared). @@ -197,15 +258,51 @@ function TitleState:stepTrails() self.trails = alive end +-- SuicuneFrameIterator (engine/movie/title.asm:217-249): advance once per +-- `suicuneEvery` frames, cycling the four frame bases. +function TitleState:advanceSuicune() + if #self.suicuneColor == 0 then return end + local c = self.suicuneTick + self.suicuneTick = (c + 1) % 256 + if c % self.suicuneEvery ~= 0 then return end + self.suicuneFrame = math.floor(c % (self.suicuneEvery * 4) + / self.suicuneEvery) + 1 +end + function TitleState:update(_dt) self.frameCounter = self.frameCounter + 1 self:advanceHooh() + self:advanceSuicune() if self.frameCounter % self.cloudScrollEvery == 0 then self.cloudScroll = (self.cloudScroll - 1) % 160 end self:spawnTrail() self:stepTrails() + if self.entranceScx > 0 then + -- TitleScreenEntrance polls no buttons and moves the gem by 2 a frame + -- (engine/menus/intro_menu.asm:1078-1107; engine/movie/title.asm:340-362). + self.entranceScx = math.max(0, self.entranceScx - self.entranceStep) + if self.gemY < self.gemRestY then + self.gemY = math.min(self.gemRestY, self.gemY + self.gemStep) + end + if self.entranceScx == 0 then + self:startMusic() + -- TitleScreenTimer only starts once the entrance scene hands over + -- (engine/menus/intro_menu.asm:1110-1136). + self.timeoutStart = self.frameCounter + end + return + end + + -- TitleScreenTimer / TitleScreenMain's run-down back into the attract + -- loop (engine/menus/intro_menu.asm:1125-1236). + if self.timeoutFrames and self.onTimeout + and self.frameCounter - (self.timeoutStart or 0) >= self.timeoutFrames then + self.onTimeout() + return + end + local input = self.game.input if input and (input:wasPressed("a") or input:wasPressed("start")) then if self.onContinue then self.onContinue() end @@ -242,10 +339,71 @@ function TitleState:drawCloudSpan(x0, x1) end end +-- TitleScreenEntrance's interlace: even lines slide in from the left, odd +-- from the right, converging as hSCX walks to 0 +-- (engine/menus/intro_menu.asm:1084-1103). +function TitleState:drawEntranceScreen(screen) + local G = love.graphics + local scx = self.entranceScx + if scx <= 0 then + G.draw(screen, 0, 0) + return + end + local w, h = screen:getDimensions() + local lines = math.min(self.entranceLines, h) + self.entranceQuad = self.entranceQuad or G.newQuad(0, 0, w, 1, w, h) + for line = 0, lines - 1 do + self.entranceQuad:setViewport(0, line, w, 1, w, h) + G.draw(screen, self.entranceQuad, line % 2 == 0 and -scx or scx, line) + end + -- hWY holds the copyright window off screen until the entrance lands + -- (engine/movie/title.asm:198-199; engine/menus/intro_menu.asm:1121-1122). + local bottom = (self.entranceHideBelow or h) - lines + if bottom > 0 then + self.entranceQuad:setViewport(0, lines, w, bottom, w, h) + G.draw(screen, self.entranceQuad, 0, lines) + end +end + function TitleState:drawContent() local G = love.graphics local screen, _, trail, hoohFrames = self:art() G.setColor(1, 1, 1, 1) + + local gem = self:gray() and (self.gemGray or self.gemColor) or self.gemColor + local suicuneFrames = nil + if #self.suicuneColor > 0 then + suicuneFrames = (self:gray() and #self.suicuneGray > 0) + and self.suicuneGray or self.suicuneColor + end + if gem or suicuneFrames then + -- Crystal's layering: the gem is OAM_PRIO so every BG colour 1-3 pixel + -- beats it; Suicune is BG; the window copyright covers Suicune's last + -- row (engine/movie/title.asm:81-85,334; engine/menus/intro_menu.asm:1121-1122). + local fill = self:gray() and SKY_GRAY or self.sky + G.setColor(fill[1], fill[2], fill[3], 1) + G.rectangle("fill", 0, 0, 160, 144) + G.setColor(1, 1, 1, 1) + if gem then G.draw(gem, self.gemX, self.gemY) end + if suicuneFrames then + local frame = suicuneFrames[self.suicuneFrame] or suicuneFrames[1] + if frame then G.draw(frame, self.suicuneX, self.suicuneY) end + end + if self.entranceScx <= 0 then + -- The pal-7 window line covers the BG once hWY lands at $88 + -- (engine/movie/title.asm:40-43; engine/menus/intro_menu.asm:1121-1122). + local bandTop = self.entranceHideBelow or 136 + local band = self.bandColor + G.setColor(band[1], band[2], band[3], 1) + G.rectangle("fill", 0, bandTop, 160, 144 - bandTop) + G.setColor(1, 1, 1, 1) + end + if screen then + self:drawEntranceScreen(screen) + end + return + end + if screen then -- title_screen.png already carries the cart's © GAME FREAK line on row 17. G.draw(screen, 0, 0) diff --git a/src/ui/gen2/TrainerCard.lua b/src/ui/gen2/TrainerCard.lua index 4b993c52..859fc424 100644 --- a/src/ui/gen2/TrainerCard.lua +++ b/src/ui/gen2/TrainerCard.lua @@ -39,6 +39,7 @@ local Chrome = require("src.ui.gen2.Chrome") local GbcPalette = require("src.render.GbcPalette") +local Gen2Save = require("src.core.gen2.Save") local TileSheet = require("src.ui.gen2.TileSheet") local TrainerCard = {} @@ -68,6 +69,19 @@ local KANTO_BADGES = { "EARTH", } +-- The two slots _CGB_TrainerCard swaps by gender: CHRIS and FALKNER/KrisPalette. +-- ../pokecrystal/engine/gfx/cgb_layouts.asm:619-624, data/trainers/palettes.asm:9-12 +local PLAYER_PALETTE, BORDER_PALETTE = 1, 2 + +-- Kris's attrmap: the border takes CHRIS's palette, the portrait takes hers, +-- the top-right corner follows the border, and Clair's face borrows hers +-- (../pokecrystal/engine/gfx/cgb_layouts.asm:649-666, :698-712). +local FEMALE_ZONES = { + { 14, 1, 5, 7, BORDER_PALETTE }, + { 18, 1, 1, 1, PLAYER_PALETTE }, + { 14, 14, 4, 2, BORDER_PALETTE }, +} + -- TrainerCard_JohtoBadgesOAM lists the badges in wJohtoBadges bit order, -- which is not the order they are drawn in: Mineral comes before Storm. local BADGE_OAM_ORDER = { @@ -92,10 +106,13 @@ function TrainerCard.new(game, opts) local gfx = (opts.menuGfx or data.gen2MenuGfx or {}).trainerCard self.gfx = gfx + self.female = Gen2Save.isFemale(self.save) and gfx ~= nil + and gfx.cardFemale ~= nil if gfx then -- One palette lookup per cell, flattened from the FillBoxCGB zones the -- way PackGfx does it. Anything outside a zone is palette 1. self.zone = {} + self.zoneDefault = BORDER_PALETTE for _, z in ipairs(gfx.paletteZones or {}) do for y = z[2], z[2] + z[4] - 1 do for x = z[1], z[1] + z[3] - 1 do @@ -103,11 +120,22 @@ function TrainerCard.new(game, opts) end end end + if self.female then + self.zoneDefault = PLAYER_PALETTE + for _, z in ipairs(FEMALE_ZONES) do + for y = z[2], z[2] + z[4] - 1 do + for x = z[1], z[1] + z[3] - 1 do + self.zone[y * SCREEN_W + x] = z[5] + end + end + end + end local function paletteFor(_, tx, ty) return self:colorsAt(tx, ty) end self.card = TileSheet.new({ - path = gfx.card, wide = gfx.cardTilesWide or 16, firstTile = 0, + path = (self.female and gfx.cardFemale) or gfx.card, + wide = gfx.cardTilesWide or 16, firstTile = 0, paletteFor = paletteFor, }) self.status = TileSheet.new({ @@ -152,7 +180,8 @@ function TrainerCard:pair(colors) end function TrainerCard:colorsAt(tx, ty) - local index = (self.zone and self.zone[ty * SCREEN_W + tx]) or 2 + local index = (self.zone and self.zone[ty * SCREEN_W + tx]) + or self.zoneDefault or BORDER_PALETTE return self:palette(index) end diff --git a/src/ui/gen2/UnownPrinter.lua b/src/ui/gen2/UnownPrinter.lua index 473a6061..332b4ab2 100644 --- a/src/ui/gen2/UnownPrinter.lua +++ b/src/ui/gen2/UnownPrinter.lua @@ -9,7 +9,7 @@ -- is drawn and driven entirely on the cartridge, and only the A press -- (`farcall PrintUnownStamp`, engine/printer/printer.asm) walks the screen -- out the serial port to a Game Boy Printer. There is no printer here, the --- same reason PrintDiploma stays stubbed in Specials.lua, so A lands on the +-- same reason PrintDiploma's own print goes nowhere, so A lands on the -- same arm a cartridge with nothing plugged into its link port takes: the -- stamp does not print and the screen stays up. Everything else is the -- cart's. diff --git a/src/ui/kit/Theme.lua b/src/ui/kit/Theme.lua index d786b85c..6cb3f525 100644 --- a/src/ui/kit/Theme.lua +++ b/src/ui/kit/Theme.lua @@ -65,6 +65,7 @@ local PAL = { railGold = { 255, 203, 5 }, -- Yellow cartridge (bright) railAmber = { 218, 145, 32 }, -- Gold cartridge (deeper metal) railSilver = { 190, 198, 210 }, -- Silver cartridge (cool light metal) + railCrystal = { 132, 196, 228 }, -- Crystal cartridge (translucent ice blue) } -- Semantic aliases kept so ported call sites read the same as before. PAL.cardBorder = PAL.line @@ -322,11 +323,13 @@ function Theme.meter(x, y, w, h, pct, c) end -- The 4px version rail across the top of both windows: the only brand --- colour on screen (Red / Blue / Yellow / Gold / Silver cartridge colours). +-- colour on screen (Red / Blue / Yellow / Gold / Silver / Crystal cartridge +-- colours). function Theme.versionRail(x, y, w, h) if not G then return end local bars = { PAL.railRed, PAL.railBlue, PAL.railGold, PAL.railAmber, PAL.railSilver, + PAL.railCrystal, } local seg = w / #bars for i, c in ipairs(bars) do diff --git a/src/world/NPC.lua b/src/world/NPC.lua index ef29b7fd..f8e41663 100644 --- a/src/world/NPC.lua +++ b/src/world/NPC.lua @@ -42,6 +42,23 @@ function NPC.new(data, mapId, objDef) return self end +-- LoadMapHeader .loadSpriteData zeroes the sprite state data and re-seeds +-- MAPY/MAPX from the map header -- home/overworld.asm:2133 +function NPC:resetToSpawn() + local def = self.def + self.cellX, self.cellY = def.x, def.y + self.px, self.py = self.cellX * 16, self.cellY * 16 + self.facing = FACING_FROM_RANGE[def.range] or "down" + self.targetX, self.targetY = nil, nil + self.moving = false + self.marching = false + self.hopStep = nil + self.progress = 0 + self.animClock = 0 + self.stepFlip = false + self.timer = love.math.random(30, 120) +end + function NPC:facePlayer(player) local dx = player.cellX - self.cellX local dy = player.cellY - self.cellY diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index a00a0a13..3c503fc9 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -81,6 +81,24 @@ local HEAL_BALL_XY = { -- swaps the two middle shades of the monitor/ball art in place local HEAL_FLASH_MAP = { [0] = 0, [1] = 2, [2] = 1, [3] = 3 } +-- scripts/VermilionDock.asm:39 VermilionDockSSAnneLeavesScript: her hull is +-- the four blocks (5..8, 1..2) of VermilionDock.blk +local SS_ANNE_BLOCK = { x = 5, y = 1, w = 4, h = 2 } +-- scripts/VermilionDock.asm:164 VermilionDock_SyncScrollWithLY splits rSCX at +-- LY $50, so her top 16px -- the player's own cell row -- never scrolls +local SS_ANNE_KEEP_PX = 16 +-- scripts/VermilionDock.asm:79 `ld e, $8`: eight 16px columns, and each +-- .delay_between_drifts pass is eight frames per pixel +local SS_ANNE_SAIL_PX, SS_ANNE_PX_FRAMES = 128, 8 +-- scripts/VermilionDock.asm:182 VermilionDock_EraseSSAnne: block 1 is the +-- shoreline row she sat in, block 13 the open water below it +local SS_ANNE_WATER = { 1, 13 } +-- scripts/VermilionDock.asm:142 VermilionDock_EmitSmokePuff: a puff per +-- column off the front smokestack, drifting east 2px per drift step +local SS_ANNE_SMOKE = { dx = 64, dy = 20, drift = 2, every = 16, count = 5 } +-- scripts/VermilionDock.asm:65 `ldh [rOBP1], a` with a = 0: the puff is white +local SS_ANNE_SMOKE_MAP = { [0] = 0, [1] = 0, [2] = 0, [3] = 0 } + -- Fishing rod placement (FishingRodOAM, engine/overworld/player_animations -- .asm). Those dbsprite rows are raw shadow-OAM bytes like HEAL_BALL_XY -- above (screen = tile*8 + pixel - 8/16), measured against the player @@ -325,6 +343,7 @@ function OverworldState:setMap(mapId, x, y, facing, opts) self.parallelQueue = {} end self.marchers = {} + self.shipAnim = nil local queue = self.pendingScripts if queue then for i = #queue, 1, -1 do @@ -418,12 +437,14 @@ function OverworldState:setMap(mapId, x, y, facing, opts) -- (home/overworld.asm), so an NPC who walked up to the player stands -- on her spawn cell again the next time that map loads (#1028). Only -- the save-side spawn flags survive, and those live in Game.save, not - -- here. Warps rebuild the whole pool from scratch. + -- here. Warps rebuild the whole pool from scratch; a seam crossing arms + -- the re-seed and applyPendingSpawnResets lands it off camera (#1755). if not (opts and opts.seamless and self.npcPool) then self.npcPool = {} elseif fromMapId ~= mapId then for _, obj in ipairs(self.map.def.objects or {}) do - self.npcPool[mapId .. "_obj_" .. obj.index] = nil + local npc = self.npcPool[mapId .. "_obj_" .. obj.index] + if npc then npc.pendingSpawnReset = true end end end self.npcs = {} @@ -629,6 +650,41 @@ function OverworldState:rebuildNeighbors() end end +-- the deferred half of the seam re-seed armed in setMap (#1755): the cart's +-- connected map has no sprites to be seen snapping -- home/overworld.asm:2133 +function OverworldState:applyPendingSpawnResets() + local pool = self.npcPool + if not (pool and self.camera) then return end + local due + for _, npc in pairs(pool) do + if npc.pendingSpawnReset then + due = due or {} + due[npc] = true + end + end + if not due then return end + local cam = self.camera + local vw, vh = Game.renderer:worldViewSize() + local function onCamera(px, py) + return px + 16 > cam.x - 16 and px < cam.x + vw + 16 + and py + 16 > cam.y - 16 and py < cam.y + vh + 16 + end + for _, mv in ipairs(self.scriptMoves or {}) do due[mv.entity] = nil end + for entity in pairs(self.marchers or {}) do due[entity] = nil end + for _, npc in ipairs(self.npcs or {}) do + if due[npc] and onCamera(npc.px, npc.py) then due[npc] = nil end + end + for _, g in ipairs(self.ghosts or {}) do + if due[g.npc] and onCamera(g.npc.px + g.ox, g.npc.py + g.oy) then + due[g.npc] = nil + end + end + for npc in pairs(due) do + npc.pendingSpawnReset = nil + npc:resetToSpawn() + end +end + -- SGB overworld palette (engine/gfx/palettes.asm SetPal_Overworld): -- towns use their own palette, routes PAL_ROUTE, interiors the town or -- route they are in (wLastMap = our lastOutdoor), with tileset and @@ -1073,6 +1129,27 @@ function OverworldState:update(dt) if da.onDone then da.onDone() end end end + -- scripts/VermilionDock.asm:80 .shift_columns_up + if self.shipAnim and not self.shipAnim.gone then + local sa = self.shipAnim + sa.frames = sa.frames + 1 + if sa.frames >= SS_ANNE_PX_FRAMES then + sa.frames = 0 + sa.off = sa.off + 1 + if sa.off % SS_ANNE_SMOKE.every == 1 + and #sa.puffs < SS_ANNE_SMOKE.count then + sa.puffs[#sa.puffs + 1] = { x = sa.px - sa.off + SS_ANNE_SMOKE.dx, + y = sa.py + SS_ANNE_SMOKE.dy } + end + for _, p in ipairs(sa.puffs) do p.x = p.x + SS_ANNE_SMOKE.drift end + if sa.off >= SS_ANNE_SAIL_PX then + sa.gone, sa.puffs = true, {} + local done = sa.onDone + sa.onDone = nil + if done then done() end + end + end + end if self.cutAnim then local ca = self.cutAnim ca.frames = ca.frames - 1 @@ -1232,6 +1309,7 @@ function OverworldState:update(dt) -- escort then walks an extra tile before PlayerEntryMovementRLE, and -- the player lands on desk Oak. local scripted = self.runner:isRunning() or #self.scriptMoves > 0 + or (self.hopLand or 0) > 0 or self.engaging or self.emote or self.teleportOut or self.flyAnim or self.flyArrive if not scripted and not self.transitioning then @@ -1241,6 +1319,7 @@ function OverworldState:update(dt) -- direction handling (JoypadOverworld runs the map script first) -- -- the player can never start another step after being spotted. scripted = self.runner:isRunning() or #self.scriptMoves > 0 + or (self.hopLand or 0) > 0 or self.engaging or self.emote or self.teleportOut or self.flyAnim or self.flyArrive end @@ -1249,6 +1328,7 @@ function OverworldState:update(dt) if not scripted and not self.transitioning and Game.stack:top() == self then self:handleInput() end + if (self.hopLand or 0) > 0 then self.hopLand = self.hopLand - 1 end local stepped = self.player:update() -- the warp-arrival cell goes stale the instant the player's real cell @@ -1276,6 +1356,7 @@ function OverworldState:update(dt) self.camera:follow(self.player.px, self.player.py, Game.renderer:worldViewSize()) + self:applyPendingSpawnResets() -- pan_camera offset rides on top of the follow; the ramp resumes its -- runner when it lands @@ -1514,6 +1595,76 @@ function OverworldState:startDustAnim(cx, cy, onDone) self.dustAnim = { x = cx, y = cy, frames = 32, onDone = onDone } end +-- scripts/VermilionDock.asm:39 VermilionDockSSAnneLeavesScript: snapshot her +-- hull tiles, flood her box with water, and slide the snapshot west from there +function OverworldState:startSsAnneDeparture(onDone) + local map = self.map + local tx0, ty0 = SS_ANNE_BLOCK.x * 4, SS_ANNE_BLOCK.y * 4 + local tiles = {} + for row = 1, SS_ANNE_BLOCK.h * 4 do + local r = {} + for col = 1, SS_ANNE_BLOCK.w * 4 do + r[col] = map:tileAt(tx0 + col - 1, ty0 + row - 1) + end + tiles[row] = r + end + for bx = SS_ANNE_BLOCK.x, SS_ANNE_BLOCK.x + SS_ANNE_BLOCK.w - 1 do + for by = SS_ANNE_BLOCK.y, SS_ANNE_BLOCK.y + SS_ANNE_BLOCK.h - 1 do + map:setBlock(bx, by, SS_ANNE_WATER[by - SS_ANNE_BLOCK.y + 1] or 13) + end + end + map.renderer:rebuild() + self.shipAnim = { px = tx0 * 8, py = ty0 * 8, tiles = tiles, + off = 0, frames = 0, puffs = {}, onDone = onDone } +end + +-- scripts/VermilionDock.asm:164: the rSCX split keeps her top 16px (the +-- shoreline row and the gangway under the player) put while the rest sails +function OverworldState:drawShipAnim(camX, camY) + local sa = self.shipAnim + if not sa then return end + local renderer = self.map.renderer + local img, quads = renderer.image, renderer.quads + local ox, oy = -math.floor(camX), -math.floor(camY) + local keep = SS_ANNE_KEEP_PX / 8 + love.graphics.setColor(1, 1, 1, 1) + for row = 1, #sa.tiles do + if row <= keep or not sa.gone then + local slide = row > keep and sa.off or 0 + local wy = sa.py + (row - 1) * 8 + oy + for col = 1, #sa.tiles[row] do + local quad = quads[sa.tiles[row][col]] + if quad then + love.graphics.draw(img, quad, sa.px + (col - 1) * 8 - slide + ox, wy) + end + end + end + end + if #sa.puffs == 0 then return end + local fxDef = Game.data.field.overworldFx + local smoke = fxDef and fxDef.smoke + if not smoke then return end + if self.smokeImg == nil then + local ok, image = pcall(love.graphics.newImage, smoke.path) + self.smokeImg = ok and image or false + end + if not self.smokeImg then return end + local shader = PaletteFX.shader() + if shader then + PaletteFX.sendColors(shader, + PaletteFX.permute(PaletteFX.GRAYS, SS_ANNE_SMOKE_MAP)) + love.graphics.setShader(shader) + end + for _, p in ipairs(sa.puffs) do + for i = 0, 1 do + for j = 0, 1 do + love.graphics.draw(self.smokeImg, p.x + i * 8 + ox, p.y + j * 8 + oy) + end + end + end + if shader then love.graphics.setShader() end +end + -- Ledge hops (data/tilesets/ledge_tiles.asm): standing tile + ledge tile -- in front + matching input direction -> jump two cells. function OverworldState:checkLedgeHop(dir) @@ -1547,17 +1698,22 @@ function OverworldState:checkLedgeHop(dir) return false end require("src.core.Sound").play(Game.data, "Ledge") - local hop = (p.stepFramesCur or p.stepFrames or 16) * 2 + p.ledgeHop = true -- BIT_LEDGE_OR_FISHING, no bike speedup mid-hop + local hop = p:stepLength() * 2 p.hopFrames, p.hopTotal = hop, hop -- jump arc (cosmetic) - self:scriptMove(p, dir, 1, function() self:checkEdgeExit(dir) end) + self:scriptMove(p, dir, 1, function() + self:checkEdgeExit(dir) + self:finishLedgeHop() + end) return true end if not Collision.occupied(self.entities, lx, ly, p) and self.map:isWalkableCell(lx, ly) then require("src.core.Sound").play(Game.data, "Ledge") - local hop = (p.stepFramesCur or p.stepFrames or 16) * 2 + p.ledgeHop = true -- BIT_LEDGE_OR_FISHING, no bike speedup mid-hop + local hop = p:stepLength() * 2 p.hopFrames, p.hopTotal = hop, hop -- jump arc (cosmetic) - self:scriptMove(p, dir, 2) + self:scriptMove(p, dir, 2, function() self:finishLedgeHop() end) return true end end @@ -1565,6 +1721,13 @@ function OverworldState:checkLedgeHop(dir) return false end +-- _HandleMidJump .finishedJump lands with UpdateSprites + Delay3 before it +-- clears the joypad bytes -- engine/overworld/player_animations.asm:509 +function OverworldState:finishLedgeHop() + self.player.ledgeHop = nil + self.hopLand = 3 +end + -- walking off the map edge: connection crossing or edge warp (exit mats) function OverworldState:checkEdgeExit(dir) local p = self.player @@ -1670,9 +1833,7 @@ function OverworldState:crossConnection(dir, conn) -- fresh walk-cycle clock so the seam step always shows leg frames -- (mid-cycle stand phase would otherwise look like a slide) p.animClock = 0 - p.stepFramesCur = Game.save.onBike - and (FieldDefaults.world(Game.data, "bikeStepFrames") or 8) - or (FieldDefaults.world(Game.data, "stepFrames") or 16) + p.stepFramesCur = p:stepLength() require("src.core.FixedStep"):discardCatchup() return true end @@ -2427,16 +2588,12 @@ function OverworldState:trashCanSwitch(canIndex) local adj = tc.adjacent[puz.first] local masked = require("bit").band(love.math.random(0, 255), #adj) puz.second = masked == 0 and 0 or adj[masked] - -- VermilionGymTrashSuccessText1's text_asm tail plays SFX_SWITCH only - -- after the text has printed (text_far ...; text_asm; - -- WaitForSoundToFinish; PlaySound SFX_SWITCH; WaitForSoundToFinish), - -- and DisplayTextID's WaitForTextScrollButtonPress then holds the box - -- until the player dismisses it -- so the beep belongs on close, not - -- open. + -- engine/events/hidden_events/vermilion_gym_trash.asm:130 (text_asm tail: + -- SFX_SWITCH once the text has printed, before the button wait) (#1702) Game.stack:push(TextBox.new(Game, t._VermilionGymTrashSuccessText1 or Strings("Hey! There's a\nswitch under the\ntrash!\fThe 1st electric\nlock opened!"), - function() require("src.core.Sound").play(Game.data, "Switch") end)) + nil, TextBox.soundOpts(Game, "Switch"))) return end -- .trySecondLock @@ -2447,25 +2604,28 @@ function OverworldState:trashCanSwitch(canIndex) -- the clear floor block opens the doors (VermilionGymSetDoorTile) local door = FieldDefaults.fieldValue(Game.data, "hiddenExtras", "trashCans", "doorBlock") - self:replaceBlock(door.bx, door.by, door.block) - -- SuccessText3's text_asm tail plays SFX_GO_INSIDE after the text - -- prints, so the beep fires as the box closes, not as it opens. + -- engine/events/hidden_events/vermilion_gym_trash.asm:153 beeps as the + -- text finishes; scripts/VermilionGym.asm:30 beeps on the swap (#1702) Game.stack:push(TextBox.new(Game, t._VermilionGymTrashSuccessText3 or Strings("The 2nd electric\nlock opened!\fThe motorized door\nopened!"), - function() require("src.core.Sound").play(Game.data, "Go_Inside") end)) + function() + require("src.core.Sound").play(Game.data, "Go_Inside") + self:replaceBlock(door.bx, door.by, door.block) + end, + TextBox.soundOpts(Game, "Go_Inside"))) else -- wrong can: ResetEvent EVENT_1ST_LOCK_OPENED and immediately -- re-roll the first switch (Random & $e) save.flags.EVENT_1ST_LOCK_OPENED = nil puz.first = love.math.random(0, 7) * 2 puz.second = nil - -- VermilionGymTrashFailText's text_asm tail plays SFX_DENIED after the - -- text prints, so the beep fires as the box closes, not as it opens. + -- engine/events/hidden_events/vermilion_gym_trash.asm:162 (text_asm tail: + -- SFX_DENIED once the text has printed, before the button wait) (#1702) Game.stack:push(TextBox.new(Game, t._VermilionGymTrashFailText or Strings("Nope! There's\nonly trash here.\fHey! The electric\nlocks were reset!"), - function() require("src.core.Sound").play(Game.data, "Denied") end)) + nil, TextBox.soundOpts(Game, "Denied"))) end end @@ -2913,10 +3073,10 @@ function OverworldState:talkTo(npc) if entry then if entry.mart then npc:facePlayer(self.player) - Game.stack:push(TextBox.new(Game, romText(Game.data, "_PokemartGreetingText", "Hi there!\nMay I help you?"), function() - Screens.push(Game, "ShopMenu", entry.mart) - unfreeze() - end)) + -- the greeting stays in the box under the menu, so ShopMenu owns it + -- now -- home/text_script.asm:143 + Screens.push(Game, "ShopMenu", entry.mart) + unfreeze() return end if entry.nurse then @@ -3379,22 +3539,18 @@ function OverworldState:engageTrainer(npc, onDone, endBattleText, skipBattleText or (header and header.won and Game.data.text[header.won]) local BattleState = require("src.battle.BattleState") + local stingPlayed = false + -- home/trainers.asm:109 prints, :123 engages (BIT_SEEN_BY_TRAINER = + -- self.engaging), then home/text_script.asm:96 waits for A (#764, #1683) + local function playMeetSting() + if stingPlayed or self.engaging then return end + stingPlayed = true + local theme = meetTrainerTheme(d.trainerClass) + if theme then require("src.core.Music").play(Game.data, theme) end + end local function startBattle(options) self.cancelledTrainerSight = nil - -- TalkToTrainer (home/trainers.asm:88) prints the before-battle text - -- FIRST and only then runs `call EngageMapTrainer` / `jp - -- StartTrainerBattle`, so a trainer challenged on foot gets the sting - -- over the battle transition rather than under the dialogue. Its - -- `bit BIT_SEEN_BY_TRAINER, [hl] / ret nz` guard is self.engaging - -- here: TrainerEngage (engine/overworld/trainer_sight.asm:224) already - -- started the sting before the "!" bubble on the sight path, so it - -- must not restart. Script-driven challenges (gyms.lua leaders, - -- scripts/SilphCo11F.asm:269 Giovanni, scripts/FightingDojo.asm:122) - -- all `call EngageMapTrainer` too, and reach this same path (#764). - if not self.engaging then - local theme = meetTrainerTheme(d.trainerClass) - if theme then require("src.core.Music").play(Game.data, theme) end - end + playMeetSting() local battle = BattleState.newTrainer(Game, d.trainerClass, d.trainerParty, options) battle.checkpointOrigin = { @@ -3465,7 +3621,8 @@ function OverworldState:engageTrainer(npc, onDone, endBattleText, skipBattleText if skipBattleText then prepareBattle() else - Game.stack:push(TextBox.new(Game, battleText, prepareBattle)) + Game.stack:push(TextBox.new(Game, battleText, prepareBattle, + { auto = { wait = true, delay = 0, sound = playMeetSting } })) end end @@ -4409,18 +4566,11 @@ function OverworldState:afterBattle(result, battle) Logger.info("battle over: %s (lead %s %d/%d)", tostring(result), lead and lead.species or "-", lead and lead.hp or 0, lead and lead.stats.hp or 0) - local Evolution = require("src.pokemon.Evolution") - local function evolutions() - -- Only mons that gained a level this battle (EXP.ALL included). - -- Scanning the whole party re-offered B-cancelled evolutions forever (#213). - Evolution.checkParty(Game, nil, battle and battle.leveledUp) - end if result == "lose" then local oaksLabRival = battle and battle.oppClass == "OPP_RIVAL1" and self.map and self.map.id == "OAKS_LAB" if oaksLabRival then -- stay in the lab; OaksLabRivalEndBattleScript heals and continues - evolutions() return end -- blackout: revive the party at the last heal point; half the @@ -4433,7 +4583,7 @@ function OverworldState:afterBattle(result, battle) / (FieldDefaults.world(Game.data, "blackoutMoneyDivisor") or 2)) Runtime.emit("world.blacked_out", { save = Game.save, healTarget = self:healPoint() }) - self:warpToHealPoint(evolutions) + self:warpToHealPoint() else -- EndTrainerBattle sets BIT_CUR_MAP_LOADED_1 (home/trainers.asm), which -- re-runs the floor's door callback: beating the last Rocket Hideout guard @@ -4443,7 +4593,6 @@ function OverworldState:afterBattle(result, battle) if Game.save.safari and Game.save.safari.balls <= 0 then self:safariGameOver(Strings("PA: You're out of\nSAFARI BALLs!")) end - evolutions() end end @@ -4872,6 +5021,9 @@ function OverworldState:updateScriptMoves() e.facing = mv.dir local tx, ty = Collision.target(e.cellX, e.cellY, mv.dir) e.targetX, e.targetY = tx, ty + -- a simulated d-pad press runs at the CURRENT walk/bike speed, not + -- whatever the last real step left behind -- home/overworld.asm:276 + if e.stepLength then e.stepFramesCur = e:stepLength() end e.moving = true e.progress = 0 mv.remaining = mv.remaining - 1 @@ -5032,6 +5184,7 @@ function OverworldState:drawWorld() for _, nb in ipairs(self.neighbors) do nb.map.renderer:drawMapOnly(cam.x - nb.ox, bgY - nb.oy, vw, vh) end + self:drawShipAnim(cam.x, bgY) end -- per-billboard SGB palette source; only needed (and only paid for) when -- tilting. nil headless / on stale palettes -> billboards go uncolorized. @@ -5388,6 +5541,7 @@ function OverworldState:drawWorld() for _, nb in ipairs(self.neighbors) do nb.map.renderer:drawMapOnly(cam.x - nb.ox, bgY - nb.oy, vw, vh) end + self:drawShipAnim(cam.x, bgY) end end diff --git a/src/world/Player.lua b/src/world/Player.lua index 54727a7b..5fd33c47 100644 --- a/src/world/Player.lua +++ b/src/world/Player.lua @@ -110,6 +110,26 @@ function Player:turnWindow() return frames end +-- the bicycle doubles walking speed (8 frames per step); movement.speed +-- lets a mod multiply or replace that (running shoes, dash, etc.) +-- DoBikeSpeedup is skipped mid-hop -- home/overworld.asm:283 +function Player:stepLength() + local Game = require("src.core.Game") + local save = Game.save + local onBike = (save and save.onBike and not self.ledgeHop) or false + local frames = onBike and self.bikeStepFrames or self.stepFrames or STEP_FRAMES + if Runtime.wantsHook("movement.speed") then + frames = Runtime.call("movement.speed", function(f) return f end, frames, { + onBike = onBike, + surfing = self.surfing and true or false, + player = self, + input = Game.input, + save = save, + }) + end + return math.max(1, math.floor(tonumber(frames) or STEP_FRAMES)) +end + -- Attempt to start a step; returns "moved"|"turned"|"blocked"|nil. function Player:tryMove(dir, map, entities) if self.moving or self.inputLocked then return nil end @@ -145,22 +165,7 @@ function Player:tryMove(dir, map, entities) self.moving = true self.bumpFrames = nil -- a real step supersedes any in-place bonk self.progress = 0 - -- the bicycle doubles walking speed (8 frames per step); movement.speed - -- lets a mod multiply or replace that (running shoes, dash, etc.) - local Game = require("src.core.Game") - local save = Game.save - local frames = (save and save.onBike) and self.bikeStepFrames - or self.stepFrames or STEP_FRAMES - if Runtime.wantsHook("movement.speed") then - frames = Runtime.call("movement.speed", function(f) return f end, frames, { - onBike = save and save.onBike or false, - surfing = self.surfing and true or false, - player = self, - input = Game.input, - save = save, - }) - end - self.stepFramesCur = math.max(1, math.floor(tonumber(frames) or STEP_FRAMES)) + self.stepFramesCur = self:stepLength() return "moved" end diff --git a/src/world/gen2/FieldMoves.lua b/src/world/gen2/FieldMoves.lua index a9db2afc..97cb0cc9 100644 --- a/src/world/gen2/FieldMoves.lua +++ b/src/world/gen2/FieldMoves.lua @@ -36,6 +36,7 @@ -- `action` names the World method that carries it out; everything else in the -- table is that action's argument. +local GameVersion = require("src.core.GameVersion") local Permissions = require("src.world.gen2.Permissions") local Runtime = require("src.mods.Runtime") local Strings = require("src.core.Strings") @@ -101,8 +102,8 @@ FieldMoves.TEXT = { .. "\fA #MON may be\nable to pass it."), ASK_WHIRLPOOL = Strings.source("A whirlpool is in\nthe way." .. "\fWant to use\nWHIRLPOOL?"), - -- Not a cart line: the stand-in destination prompt World:askFlyPoint uses - -- until the POKeGEAR's MAP card grows _FlyMap's cursor mode. + -- Not a cart line: the prompt World:askFlyPoint falls back to when there is + -- no screen at all to push -- a headless probe, never a real run. ASK_FLY_TO = Strings.source("Fly to %s?"), } @@ -152,14 +153,45 @@ FieldMoves.KANTO_BADGES = { -- World:setEngineFlag / World:engineFlag route badge ids here so there is one -- store again, the same way ENGINE_BUG_CONTEST_TIMER is routed to -- save.bugContest rather than kept as a second copy. -FieldMoves.BADGE_FLAG = {} -for index, name in ipairs(FieldMoves.JOHTO_BADGES) do - FieldMoves.BADGE_FLAG[25 + index] = { store = "badges", name = name } -end -for index, name in ipairs(FieldMoves.KANTO_BADGES) do - FieldMoves.BADGE_FLAG[33 + index] = { store = "kantoBadges", name = name } +-- Crystal declares 162 engine flags to Gold's 93 and the badge block sits one +-- higher (constants/engine_flags.asm:39 vs pokegold's :38), so the ids come +-- from the cache's engineFlagOrder when it has one. +-- Crystal only; pokegold constants/engine_flags.asm:4-111 has no such row. +FieldMoves.FEMALE_FLAG_NAME = "ENGINE_PLAYER_IS_FEMALE" + +function FieldMoves.bindEngineFlags(order) + local byName = {} + if type(order) == "table" then + -- pairs, not ipairs: a const_skip leaves a hole and ipairs would stop + -- there, silently dropping every badge past it. + for index, name in pairs(order) do + if type(index) == "number" and type(name) == "string" then + byName[name] = index - 1 + end + end + end + local flags = {} + local function place(names, store, goldBase) + for index, name in ipairs(names) do + local id = byName["ENGINE_" .. name .. "BADGE"] or (goldBase + index) + flags[id] = { store = store, name = name } + end + end + place(FieldMoves.JOHTO_BADGES, "badges", 25) + place(FieldMoves.KANTO_BADGES, "kantoBadges", 33) + FieldMoves.BADGE_FLAG = flags + -- The flag IS wPlayerGender's bit 0, so World routes it to the gender byte + -- (data/events/engine_flags.asm:131, constants/engine_flags.asm:121). + FieldMoves.FEMALE_FLAG = byName[FieldMoves.FEMALE_FLAG_NAME] + -- Crystal's ENGINE_MOBILE_SYSTEM (constants/engine_flags.asm:25) has no Gold + -- row, so every id from BUG_CONTEST_TIMER up shifts one. + FieldMoves.BUG_CONTEST_FLAG = byName["ENGINE_BUG_CONTEST_TIMER"] or 16 + FieldMoves.BIKE_SHOP_CALL_FLAG = byName["ENGINE_BIKE_SHOP_CALL_ENABLED"] or 19 + return flags end +FieldMoves.bindEngineFlags(nil) + function FieldMoves.hasBadge(save, badge) if not badge then return true end local owned = save and save.player and save.player.badges @@ -357,6 +389,39 @@ FieldMoves.STATE_SPRITE = { surf_pika = "SPRITE_SURFING_PIKACHU", } +-- data/sprites/player_sprites.asm:8-13 KrisStateSprites, the other half of the +-- table GetPlayerSprite picks between (engine/overworld/overworld.asm:55-64). +FieldMoves.STATE_SPRITE_FEMALE = { + normal = "SPRITE_KRIS", + bike = "SPRITE_KRIS_BIKE", + surf = "SPRITE_SURF", + surf_pika = "SPRITE_SURFING_PIKACHU", +} + +-- wPlayerGender's PLAYERGENDER_FEMALE_F, as the save spells it +-- (constants/ram_constants.asm:176-177). +function FieldMoves.isFemale(gender) + return gender == "female" +end + +-- GetPlayerSprite's table pick and row walk +-- (engine/overworld/overworld.asm:57-64, :67-75). +function FieldMoves.stateSprite(state, gender) + local table_ = FieldMoves.isFemale(gender) + and FieldMoves.STATE_SPRITE_FEMALE or FieldMoves.STATE_SPRITE + return table_[state] or table_[FieldMoves.PLAYER_NORMAL] +end + +function FieldMoves.playerSprite(gender) + return FieldMoves.stateSprite(FieldMoves.PLAYER_NORMAL, gender) +end + +-- Whether the cache carries Kris at all; Gold and Silver have no +-- KrisStateSprites to extract (pokegold data/sprites/player_sprites.asm:1-6). +function FieldMoves.hasGenderChoice(sprites) + return (sprites and sprites[FieldMoves.STATE_SPRITE_FEMALE.normal]) ~= nil +end + function FieldMoves.isBiking(state) return state == FieldMoves.PLAYER_BIKE end @@ -536,10 +601,14 @@ end -- FlashFunction.CheckUseFlash: badge, then wTimeOfDayPalset == DARKNESS_PALSET -- -- so FLASH is refused in a lit cave and on a route alike, and the refusal -- is FieldMoveFailed's generic "Can't use that here." +-- +-- ../pokecrystal/engine/events/overworld.asm:284-287 puts SpecialAerodactylChamber +-- between the two, and its carry is a second way into `.useflash`. function FieldMoves.flashFromMenu(ctx) local refused = badgeGate(ctx, "FLASH") if refused then return refused end - if not ctx.dark then + local chamber = ctx.openAerodactylWall and ctx.openAerodactylWall() + if not ctx.dark and not chamber then return { ok = false, text = FieldMoves.TEXT.CANT_USE_HERE } end return { ok = true, action = "flash", text = FieldMoves.TEXT.BLINDING_FLASH } @@ -562,6 +631,11 @@ function FieldMoves.surfFromMenu(ctx) or FieldMoves.directionBlocked(ctx.playerColl, ctx.facing) then return { ok = false, text = FieldMoves.TEXT.CANT_SURF } end + -- Crystal's added `farcall CheckFacingObject`, which pokegold's :339 tags + -- BUG (../pokecrystal/engine/events/overworld.asm:364-365). + if ctx.facingObject and GameVersion.fixes().surfOntoNpc then + return { ok = false, text = FieldMoves.TEXT.CANT_SURF } + end return { ok = true, action = "surf", state = FieldMoves.surfType(ctx.mon), diff --git a/src/world/gen2/Npc.lua b/src/world/gen2/Npc.lua index ac21731e..3ad40933 100644 --- a/src/world/gen2/Npc.lua +++ b/src/world/gen2/Npc.lua @@ -31,6 +31,8 @@ local MOVE = { STANDING_LEFT = 8, STANDING_RIGHT = 9, SPINRANDOM_FAST = 10, + -- data/sprites/map_objects.asm:181-187 + POKEMON = 0x16, SPINCOUNTERCLOCKWISE = 0x1e, SPINCLOCKWISE = 0x1f, -- The three rows whose palette-flags byte is `STRENGTH_BOULDER | BIG_OBJECT` @@ -123,6 +125,11 @@ local SPIN_NEXT = { -- each quarter, no Random anywhere in the loop. local SPIN_TURN_FRAMES = 16 +-- SetFacingBounce steps OBJECT_STEP_FRAME once a frame and reads bit 3, so a +-- mon object holds each icon frame for eight -- map_object_action.asm:184-201 +local BOUNCE_PERIOD = 16 +local BOUNCE_HALF = 8 + local function rand(a, b) if love and love.math and love.math.random then return love.math.random(a, b) @@ -252,6 +259,8 @@ function NPC.new(mapId, objDef, spriteDef) bigObject = BIG_OBJECT[movement] == true, bigFacing = NPC.bigFacing(movement, spriteDef and spriteDef.id), fixedFacing = FIXED_FACING_MOVE[movement] or nil, + bouncing = movement == MOVE.POKEMON or nil, + bounceStep = 0, timer = rand(30, 120), sprite = SpriteRenderer.new(spriteDef, string.format("%s_obj_%d", mapId, objDef.index or 0)), -- The sheet is grayscale and carries no alpha; PAL_OW_* crossed with the @@ -501,6 +510,14 @@ function NPC:walkPhase() return (p >= frames / 4 and p < frames * 3 / 4) and 1 or 0 end +-- OBJECT_ACTION_BOUNCE's two columns, SetFacingBounce and +-- SetFacingFreezeBounce -- engine/overworld/map_object_action.asm:184-201 +function NPC:bounceFrame() + if not self.bouncing then return nil end + if self.frozen then return 0 end + return ((self.bounceStep or 0) >= BOUNCE_HALF) and 1 or 0 +end + -- Gen 1's seven-value entity pose (src/world/NPC.lua:124), on the class so a -- mod poses the object it is FOLLOWING, not only one it built itself. function NPC:pose() @@ -522,6 +539,9 @@ function NPC:update(map, entities) self.spawnLatched = true self.inGrass = NPC.grassAt(map, self.cellX, self.cellY) end + if self.bouncing and not self.frozen then + self.bounceStep = ((self.bounceStep or 0) + 1) % BOUNCE_PERIOD + end -- The teleport step type owns the object outright (it replaces -- STEP_TYPE_FROM_MOVEMENT until its last beat), so it runs above the frozen -- gate the way the walk interpolation does. @@ -763,7 +783,8 @@ function NPC:draw(ox, oy, scale) else self.sprite:draw( self.px, self.py + yOffset, 0, 0, - self.facing, self:walkPhase(), self.stepFlip) + self.facing, self:walkPhase(), self.stepFlip, + false, false, self:bounceFrame()) end G.pop() end diff --git a/src/world/gen2/Player.lua b/src/world/gen2/Player.lua index 4e4153fe..5a0734e4 100644 --- a/src/world/gen2/Player.lua +++ b/src/world/gen2/Player.lua @@ -23,6 +23,19 @@ local JUMP_Y = { -11, -10, -9, -8, -6, -4, 0, 0, } +-- FacingFish*'s loose rod OAM, offset from the sprite's top-left, and which +-- 8x8 of the sheet's rod row it draws (data/sprites/facings.asm:122-152). +local ROD_OAM = { + down = { dx = 0, dy = 16, tile = 0 }, + up = { dx = 0, dy = -8, tile = 0 }, + left = { dx = -8, dy = 5, tile = 1, flip = true }, + right = { dx = 16, dy = 5, tile = 1 }, +} + +-- The sheet row LoadFishingGFX lays over each standing frame's bottom tiles +-- (engine/events/fishing_gfx.asm:2-20). +local FISH_ROW = { down = 0, up = 1, left = 2, right = 2 } + function Player.new(cx, cy, facing, spriteDef) local self = setmetatable({ cellX = cx, cellY = cy, @@ -138,6 +151,17 @@ function Player:scriptStep(dir) return true end +-- CounterclockwiseSpinAction's .facings, seeded from the current direction by +-- Movement_step_dig -- map_object_action.asm:96-152, movement.asm:113-116 +local SPIN_FACINGS = { "down", "right", "up", "left" } +local SPIN_START = { down = 0, right = 1, up = 2, left = 3 } + +function Player:scriptSpin(frames) + if not frames or frames <= 0 then return end + self.spinFrames = frames + self.spinTimer = (SPIN_START[self.facing] or 0) * 4 +end + -- Gen 1's name for the cell being faced (src/world/Player.lua), so a mod that -- wraps World:interact asks one question of either generation. function Player:facingCell() @@ -158,6 +182,11 @@ function Player:update() if self.turnTimer > 0 then self.turnTimer = self.turnTimer - 1 end + if self.spinFrames then + self.spinTimer = (self.spinTimer or 0) + 1 + self.spinFrames = self.spinFrames - 1 + if self.spinFrames <= 0 then self.spinFrames = nil end + end if not self.moving then -- Re-arm turn-in-place once a poll finds no held direction (caller -- clears this while a dir is held; we only set it from idle). @@ -170,17 +199,27 @@ function Player:update() -- and the facing-delta math walked only half of it, leaving the sprite a -- cell behind where the grid said the player was. local frames = self.stepFrames or STEP_FRAMES - local adv = math.floor(self.progress * 16 / frames) local dx = (self.targetX or self.cellX) - self.cellX local dy = (self.targetY or self.cellY) - self.cellY - self.px = self.cellX * 16 + dx * adv - self.py = self.cellY * 16 + dy * adv + -- engine/overworld/map_objects.asm:331 -- AddStepVector moves the object + -- every frame, so the span is the whole move, not one cell scaled by dx. + local span = math.max(math.abs(dx), math.abs(dy), 1) + local adv = math.floor(self.progress * 16 * span / frames) + self.px = self.cellX * 16 + (dx / span) * adv + self.py = self.cellY * 16 + (dy / span) * adv if self.jumping then - -- engine/overworld/map_objects.asm:1815 - local idx = math.floor((self.progress - 1) / 2) + 1 + -- engine/overworld/map_objects.asm:1796 -- one table entry per cart frame, + -- tweened across our doubled step (#1713) + local t = (self.progress - 1) * (#JUMP_Y - 1) + / math.max(frames - 1, 1) + 1 + local idx = math.floor(t) if idx < 1 then idx = 1 end - if idx > #JUMP_Y then idx = #JUMP_Y end - self.spriteYOffset = JUMP_Y[idx] + if idx >= #JUMP_Y then + self.spriteYOffset = JUMP_Y[#JUMP_Y] + else + self.spriteYOffset = math.floor( + JUMP_Y[idx] + (JUMP_Y[idx + 1] - JUMP_Y[idx]) * (t - idx) + 0.5) + end end if self.progress >= frames then self.cellX, self.cellY = self.targetX, self.targetY @@ -195,12 +234,36 @@ function Player:update() return false end +-- FacingFishDown/Up/Left/Right: the standing frame's bottom tile row swapped +-- for the fishing sheet, plus the loose rod tile -- facings.asm:122-152 (#1708) +function Player:drawFishing(yOffset) + local sprite = self.sprite + local py = self.py + yOffset + local facing = self.facing + sprite:draw(self.px, py, 0, 0, facing, 0, false, true) + if not self.fishQuads then + self.fishQuads = { pose = {}, rod = {} } + for i = 0, 2 do + self.fishQuads.pose[i] = love.graphics.newQuad(0, i * 8, 16, 8, 16, 32) + end + for i = 0, 1 do + self.fishQuads.rod[i] = love.graphics.newQuad(i * 8, 24, 8, 8, 16, 32) + end + end + local sx, sy = sprite:getScreenOrigin(self.px, py, 0, 0) + sprite:drawTile(self.fishSheet, sx, + sy + math.max(0, sprite.frameHeight - 8), facing == "right", + self.fishQuads.pose[FISH_ROW[facing] or 0]) + local oam = ROD_OAM[facing] or ROD_OAM.down + sprite:drawTile(self.fishSheet, sx + oam.dx, sy + oam.dy, oam.flip, + self.fishQuads.rod[oam.tile]) +end + function Player:draw(ox, oy, scale) local G = love.graphics -- OBJECT_SPRITE_Y_OFFSET: added to the OBJ's y as it is written to OAM, so -- it moves the sprite without moving the player off the tile they are - -- standing on. StepFunction_GotBite's `xor 1` rod bob and the fly take-off - -- lift both ride this one byte. + -- standing on. StepFunction_GotBite's `xor 1` rod bob rides this one byte. local yOffset = self.spriteYOffset or 0 if self.jumping then -- engine/overworld/map_objects.asm:1995 @@ -217,9 +280,18 @@ function Player:draw(ox, oy, scale) G.scale(scale, scale) -- Chris is PAL_OW_RED; World:applyPalettes keeps the SpriteRenderer's -- OBJ palette current. - self.sprite:draw( - self.px, self.py + yOffset, 0, 0, - self.facing, self:walkPhase(), self.stepFlip) + if self.fishing and self.fishSheet then + self:drawFishing(yOffset) + else + local facing, phase = self.facing, self:walkPhase() + -- OBJECT_ACTION_SPIN (map_object_action.asm:96-152), for step_dig. + if self.spinFrames then + facing = SPIN_FACINGS[math.floor(self.spinTimer / 4) % 4 + 1] + phase = 0 + end + self.sprite:draw( + self.px, self.py + yOffset, 0, 0, facing, phase, self.stepFlip) + end G.pop() return end diff --git a/src/world/gen2/StepEvents.lua b/src/world/gen2/StepEvents.lua index 228b8213..7010ea29 100644 --- a/src/world/gen2/StepEvents.lua +++ b/src/world/gen2/StepEvents.lua @@ -23,7 +23,7 @@ -- -- Everything here is love-free and takes its state as arguments so the whole -- chain is testable without a world. -local Bike = require("src.world.gen2.Bike") +local FieldMoves = require("src.world.gen2.FieldMoves") local Breeding = require("src.core.gen2.Breeding") local Happiness = require("src.core.gen2.Happiness") local Phone = require("src.core.gen2.Phone") @@ -118,7 +118,7 @@ end local function bikeShopCallEnabled(save) local flags = save.engineFlags if type(flags) == "table" then - local set = flags[Bike.ENGINE_BIKE_SHOP_CALL_ENABLED] + local set = flags[FieldMoves.BIKE_SHOP_CALL_FLAG] if set ~= nil then return set == true end end return save.bikeShopCall == true @@ -137,7 +137,7 @@ function StepEvents.bikeStep(save, opts) Phone.queueSpecialCall(save, Phone.SPECIALCALL.SPECIALCALL_BIKESHOP) -- `res STATUSFLAGS2_BIKE_SHOP_CALL_F`: one call, ever. if type(save.engineFlags) == "table" then - save.engineFlags[Bike.ENGINE_BIKE_SHOP_CALL_ENABLED] = nil + save.engineFlags[FieldMoves.BIKE_SHOP_CALL_FLAG] = nil end save.bikeShopCall = false return true diff --git a/src/world/gen2/UnownWords.lua b/src/world/gen2/UnownWords.lua new file mode 100644 index 00000000..852679eb --- /dev/null +++ b/src/world/gen2/UnownWords.lua @@ -0,0 +1,225 @@ +-- ../pokecrystal/engine/events/unown_walls.asm:102 DisplayUnownWords, and the +-- Unown alphabet it writes out of ../pokecrystal/constants/charmap.asm:424; +-- :1 HoOhChamber, :13 OmanyteChamber, :54 SpecialAerodactylChamber and :81 +-- SpecialKabutoChamber, the four routines that open the chambers' walls. + +local Assets = require("src.render.Assets") +local Font = require("src.render.Font") +local GbcPalette = require("src.render.GbcPalette") +local Palettes = require("src.world.gen2.Palettes") +local Sound = require("src.core.Sound") + +local UnownWords = {} +UnownWords.__index = UnownWords +UnownWords.isOpaque = false + +-- engine/tilesets/map_palettes.asm:40, ../pokecrystal/constants/tileset_constants.asm:55 +UnownWords.BANK1 = 0x80 +UnownWords.BROWN = 6 + +-- ../pokecrystal/engine/events/unown_walls.asm:225 .YChar, :237 .ZChar, :249 .DashChar +local FIXED = { + [0x60] = { 0x5b, 0x5c, 0x4d, 0x5d }, + [0x62] = { 0x4e, 0x4f, 0x5e, 0x5f }, + [0x64] = { 0x02, 0x03, 0x03, 0x02 }, +} + +function UnownWords.square(char) + local fixed = FIXED[char] + if fixed then return fixed[1], fixed[2], fixed[3], fixed[4] end + local base = UnownWords.BANK1 + char + return base, base + 1, base + 0x10, base + 0x11 +end + +-- ../pokecrystal/engine/events/unown_walls.asm:122-127 +function UnownWords.origin(wall) + return (wall.x1 or 0) + 1, (wall.y1 or 0) + 2 +end + +-- ../pokecrystal/engine/events/unown_walls.asm:119 MenuBox, home/menu.asm:131 GetMenuBoxDims +function UnownWords.boxRect(wall) + local x1, y1 = wall.x1 or 0, wall.y1 or 0 + return x1, y1, (wall.x2 or x1) - x1 + 1, (wall.y2 or y1) - y1 + 1 +end + +-- ../pokecrystal/engine/events/unown_walls.asm:182 _DisplayUnownWords_CopyWord +function UnownWords.layout(wall) + local tx, ty = UnownWords.origin(wall) + local out = {} + for index, char in ipairs(wall.chars or {}) do + local tl, tr, bl, br = UnownWords.square(char) + out[index] = { + tx = tx + (index - 1) * 2, ty = ty, + tl = tl, tr = tr, bl = bl, br = br, + } + end + return out +end + +-- ../pokecrystal/constants/event_flags.asm:486-489, the four Crystal-only +-- EVENT_WALL_OPENED_IN_*_CHAMBER bits. +UnownWords.WALL_OPENED = { + HO_OH = 806, + KABUTO = 807, + OMANYTE = 808, + AERODACTYL = 809, +} + +-- ../pokecrystal/engine/events/unown_walls.asm:60, :87 -- the +-- GetMapAttributesPointer compare the two non-special routines are gated on. +UnownWords.CHAMBER_MAPS = { + HO_OH = "RUINS_OF_ALPH_HO_OH_CHAMBER", + KABUTO = "RUINS_OF_ALPH_KABUTO_CHAMBER", + OMANYTE = "RUINS_OF_ALPH_OMANYTE_CHAMBER", + AERODACTYL = "RUINS_OF_ALPH_AERODACTYL_CHAMBER", +} + +-- ../pokecrystal/engine/events/unown_walls.asm:4, :22 +UnownWords.HO_OH = "HO_OH" +UnownWords.WATER_STONE = "WATER_STONE" + +-- ../pokecrystal/engine/events/unown_walls.asm:16 EventFlagAction CHECK_FLAG +function UnownWords.wallOpened(events, chamber) + local flag = UnownWords.WALL_OPENED[chamber] + if not (events and flag) then return false end + return events:get(flag) and true or false +end + +-- ../pokecrystal/engine/events/unown_walls.asm:8 EventFlagAction SET_FLAG +function UnownWords.openWall(events, chamber) + local flag = UnownWords.WALL_OPENED[chamber] + if not (events and flag) then return false end + if events:get(flag) then return false end + events:set(flag, true) + return true +end + +-- ../pokecrystal/engine/events/unown_walls.asm:2-5: wPartySpecies[0], which +-- holds EGG rather than the hatchling's species while a slot is an egg. +function UnownWords.leadIsHoOh(party) + local lead = party and party[1] + if not lead or lead.isEgg then return false end + return lead.species == UnownWords.HO_OH +end + +-- ../pokecrystal/engine/events/unown_walls.asm:28-43: wPartyCount down to 1, +-- MON_ITEM on each, so the LAST slot holding one is the one it stops at. +function UnownWords.waterStoneSlot(party) + party = party or {} + for slot = #party, 1, -1 do + local mon = party[slot] + if mon and mon.item == UnownWords.WATER_STONE then return slot end + end + return nil +end + +-- ../pokecrystal/engine/events/unown_walls.asm:54, whose carry FlashFunction +-- jumps on at ../pokecrystal/engine/events/overworld.asm:285. +function UnownWords.aerodactylChamber(events, mapId) + if mapId ~= UnownWords.CHAMBER_MAPS.AERODACTYL then return false end + UnownWords.openWall(events, "AERODACTYL") + return true +end + +-- ../pokecrystal/engine/events/unown_walls.asm:81, off the escape rope arm of +-- EscapeRopeOrDig (../pokecrystal/engine/events/overworld.asm:809). +function UnownWords.kabutoChamber(events, mapId) + if mapId ~= UnownWords.CHAMBER_MAPS.KABUTO then return false end + UnownWords.openWall(events, "KABUTO") + return true +end + +-- ../pokecrystal/constants/script_constants.asm:319 UNOWNWORDS_* +function UnownWords.wallFor(data, scriptVar) + local walls = data and data.gen2EventTables and data.gen2EventTables.unownWalls + if not walls then return nil end + return walls[(scriptVar or 0) + 1] +end + +-- opts: wall (an events.unownWalls row), world, onClose() +function UnownWords.new(game, opts) + opts = opts or {} + local self = setmetatable({}, UnownWords) + self.game = game + self.wall = opts.wall + self.world = opts.world + self.onClose = opts.onClose + self.done = false + self.squares = self.wall and UnownWords.layout(self.wall) or {} + return self +end + +-- ../pokecrystal/engine/events/unown_walls.asm:155 _DisplayUnownWords_FillAttr +function UnownWords:tileset() + local world = self.world + local map = world and world.map + local def = map and map.def + local tileset = def and world.tilesets and world.tilesets[def.tileset] + if not tileset or not tileset.image then return nil end + if self.atlas == nil then + local ok, image = pcall(Assets.image, tileset.image) + self.atlas = ok and image or false + if self.atlas then self.atlas:setFilter("nearest", "nearest") end + local set = world.palettes + and Palettes.bgSet(world.palettes, def, world.daytime or "DAY") + self.colors = set and set[UnownWords.BROWN] or nil + end + if not self.atlas then return nil end + return tileset, self.atlas +end + +-- ../pokecrystal/engine/events/unown_walls.asm:147 PlayClickSFX, :148 CloseWindow +function UnownWords:finish() + if self.done then return end + self.done = true + local game = self.game + if game and game.data then Sound.play(game.data, "SFX_READ_TEXT_2") end + if game and game.stack then game.stack:pop() end + if self.onClose then self.onClose() end +end + +function UnownWords:update(_dt) + if self.done then return end + local input = self.game and self.game.input + if not input then return end + if input:wasPressed("a") or input:wasPressed("b") then self:finish() end +end + +function UnownWords:draw() + local wall = self.wall + if not wall then return end + local bx, by, bw, bh = UnownWords.boxRect(wall) + Font.drawBox(bx, by, bw, bh) + local tileset, atlas = self:tileset() + if not tileset then return end + local perRow = tileset.tilesPerRow or 16 + local aw, ah = atlas:getDimensions() + local quads = {} + local function quadFor(tile) + local q = quads[tile] + if not q then + q = love.graphics.newQuad((tile % perRow) * 8, + math.floor(tile / perRow) * 8, 8, 8, aw, ah) + quads[tile] = q + end + return q + end + local function body() + love.graphics.setColor(1, 1, 1, 1) + for _, square in ipairs(self.squares) do + local x, y = square.tx * 8, square.ty * 8 + love.graphics.draw(atlas, quadFor(square.tl), x, y) + love.graphics.draw(atlas, quadFor(square.tr), x + 8, y) + love.graphics.draw(atlas, quadFor(square.bl), x, y + 8) + love.graphics.draw(atlas, quadFor(square.br), x + 8, y + 8) + end + end + if self.colors and GbcPalette.available() then + GbcPalette.with(self.colors, body) + else + body() + end + love.graphics.setColor(0, 0, 0, 1) +end + +return UnownWords diff --git a/src/world/gen2/World.lua b/src/world/gen2/World.lua index ec83fa7b..c6c9a58f 100644 --- a/src/world/gen2/World.lua +++ b/src/world/gen2/World.lua @@ -41,6 +41,7 @@ local HiddenItems = require("src.world.gen2.HiddenItems") local Mail = require("src.core.gen2.Mail") local Map = require("src.world.gen2.Map") local Palettes = require("src.world.gen2.Palettes") +local UnownWords = require("src.world.gen2.UnownWords") local Mon = require("src.battle.gen2.Mon") local Movement = require("src.script.gen2.Movement") local Music = require("src.core.Music") @@ -55,6 +56,7 @@ local Roamers = require("src.core.gen2.Roamers") local Runtime = require("src.mods.Runtime") local Screens = require("src.ui.Screens") local Sound = require("src.core.Sound") +local SpriteRenderer = require("src.render.SpriteRenderer") local StepEvents = require("src.world.gen2.StepEvents") local Tilt = require("src.render.Tilt") local Strings = require("src.core.Strings") @@ -84,6 +86,7 @@ local SFX = { EXIT_BUILDING = 35, JUMP_OVER_LEDGE = 0x16, BUMP = 0x24, + FLY = 0x18, } local EMOTE_SHOCK = 0 @@ -116,6 +119,14 @@ local VAR = { XCOORD = 0x12, YCOORD = 0x13, SPECIALPHONECALL = 0x14, + -- ../pokecrystal/constants/script_constants.asm:69-74, the six rows Gold's + -- table stops short of; ../pokecrystal/engine/overworld/variables.asm:62-67. + BT_WIN_STREAK = 0x15, + KURT_APRICORNS = 0x16, + CALLERID = 0x17, + BLUECARDBALANCE = 0x18, + BUENASPASSWORD = 0x19, + KENJI_BREAK = 0x1a, } -- constants/ram_constants.asm:293 wPlayerState. PLAYER_SKATE (2) has no row: @@ -374,6 +385,13 @@ local function itemByIndex(items, index) return nil end +-- One WRAM byte, the width every VAR_* store is (`ld a, [de]` / `ld [de], a`). +local function byteOf(value) + local n = math.floor(tonumber(value) or 0) + if n < 0 then n = 0 end + return n % 256 +end + -- CountSetBits over a { key = true } flag table: VAR.DEXCAUGHT, VAR.DEXSEEN -- and VAR.BADGES are all "how many of these are set" reads off one. local function countFlags(flags) @@ -741,6 +759,15 @@ function World:load() self.tilesets = tilesets self.roofs = self:dataTable("gen2Roofs", "data/generated/roofs.lua") self.sprites = self:dataTable("gen2Sprites", "data/generated/sprites.lua") + -- A pre-#1748 cache stamps a SpriteMons row `frames = 1`, leaving + -- OBJECT_ACTION_BOUNCE one frame -- engine/overworld/map_object_action.asm:184 + for _, def in pairs(self.sprites or {}) do + if type(def) == "table" and (def.frames or 1) < 2 + and type(def.source) == "string" + and def.source:find("^ROM:SpriteMons") then + def.frames = 2 + end + end -- A cache from before the palette stage existed simply has no palettes.lua; -- everything below falls back to the grayscale path rather than failing. self.palettes = self:dataTable("gen2Palettes", "data/generated/palettes.lua") @@ -784,6 +811,14 @@ function World:load() local okImg, img = pcall(Assets.image, emotes.grassRustle) if okImg then self.grassRustleImage = img end end + -- LoadFishingGFX's two sheets + -- (../pokecrystal/engine/events/fishing_gfx.asm:7-12) + if emotes.fishing and pcall(Assets.image, emotes.fishing) then + self.fishingSheet = emotes.fishing + end + if emotes.fishingFemale and pcall(Assets.image, emotes.fishingFemale) then + self.fishingSheetFemale = emotes.fishingFemale + end end -- The heal machine's two OBJ tiles and their CGB palette, for the -- Pokecenter light show (World:startHealMachineAnim). A cache from before @@ -810,6 +845,7 @@ function World:load() self.text = self:dataTable("gen2Text", "data/generated/text.lua") or {} self.constants = self:dataTable("gen2Constants", "data/generated/constants.lua") or {} + FieldMoves.bindEngineFlags(self.constants.engineFlagOrder) -- The side tables a script command NAMES rather than carries: the phone -- book, the in-game trades, the elevator's floor labels and the decoration -- descriptions. A cache built before the extractor reached them has no @@ -1256,7 +1292,7 @@ function World:load() end, -- ---- encounters -------------------------------------------------------- - setSwarm = function(group, mapNum) self:setSwarm(group, mapNum) end, + setSwarm = function(group, mapNum, kind) self:setSwarm(group, mapNum, kind) end, rollWild = function() return self:rollWild() end, -- The WRAM bytes the ENGINE owns rather than the script: nil means "not -- mine", and the VM falls back to its own sparse store. @@ -1410,6 +1446,9 @@ function World:busy() -- and the waterfall climb are all applymovement / pause commands inside a -- queued script, so nothing else may run under them. or self.fieldMove ~= nil + -- FlyFromAnim and FlyToAnim are blocking `callasm`s inside .FlyScript + -- (engine/events/overworld.asm:599, :605). + or self.flyAnim ~= nil end -- CheckMenuOW (engine/overworld/events.asm:802) is the tail of OWPlayerInput, @@ -1417,12 +1456,15 @@ end -- away while wScriptRunning is non-zero (events.asm:238-243). Two more gates -- sit above it even with no script up: PlayerMovement answering -- PLAYERMOVEMENT_CONTINUE, i.e. the player is mid-step (events.asm:474-477), --- and CheckStandingOnIce carrying (events.asm:479-480). START and SELECT are --- read NOWHERE else in the overworld, so a press that arrives while any of --- those hold is not queued or deferred, it is never read at all. +-- and CheckStandingOnIce carrying (events.asm:479-480). The frame a step is +-- QUEUED answers PLAYERMOVEMENT_FINISH instead (player_movement.asm:455-461), +-- which is zero, so the poll still runs there -- and on the Cycling Road's +-- forced roll that landing frame is the only one there is (#1718). function World:acceptsMenuInput() if self.battleActive or self:busy() then return false end - if self.player and self.player.moving then return false end + if self.player and self.player.moving and not self.stepFinished then + return false + end -- The same latch pair World:step's slide uses: a latched direction on an ice -- tile is CheckStandingOnIce's carry. if self.turningDirection and Permissions.isIce(self:playerCollision()) then @@ -1573,9 +1615,78 @@ function World:readVar(varId) if varId == VAR.SPECIALPHONECALL then return self:specialCall() end + if varId >= VAR.BT_WIN_STREAK and varId <= VAR.KENJI_BREAK then + return self:crystalVar(varId) + end return 0 end +-- ../pokecrystal/engine/overworld/variables.asm:62-67, the six .VarActionTable +-- rows Crystal appends past VAR_SPECIALPHONECALL. +function World:crystalVar(varId) + -- ../pokecrystal/ram/wram.asm:3286 wCurCaller, which this port parks on the + -- VM (src/script/gen2/CallAsm.lua:190). + if varId == VAR.CALLERID then + return (self.vm and self.vm.curPhoneCaller) or 0 + end + local save = self.game and self.game.save + if not save then return 0 end + -- ../pokecrystal/ram/wram.asm:1703 wNrOfBeatenBattleTowerTrainers. + if varId == VAR.BT_WIN_STREAK then + return byteOf(Gen2Save.battleTowerState(save).streak) + end + -- ../pokecrystal/engine/events/kurt.asm:24,45 wKurtApricornQuantity. + if varId == VAR.KURT_APRICORNS then + return byteOf(save.kurtApricornQuantity) + end + local crystal = Gen2Save.crystalState(save) + if varId == VAR.BLUECARDBALANCE then + return byteOf(crystal.buenaPassword.balance) + end + if varId == VAR.BUENASPASSWORD then + return byteOf(crystal.buenaPassword.word) + end + -- ../pokecrystal/engine/overworld/time.asm:136 SampleKenjiBreakCountdown. + if varId == VAR.KENJI_BREAK then + return byteOf(crystal.kenjiBreak) + end + return 0 +end + +-- The three of them Script_writevar can reach: RETVAR_ADDR_DE rows write the +-- variable itself, RETVAR_STRBUF2 rows write the scratch buffer and are lost +-- (../pokecrystal/engine/overworld/variables.asm:21-25). +function World:setCrystalVar(varId, value) + value = byteOf(value) + if varId == VAR.CALLERID then + if self.vm then self.vm.curPhoneCaller = value end + return + end + local save = self.game and self.game.save + if not save then return end + local buena = Gen2Save.crystalState(save).buenaPassword + if varId == VAR.BLUECARDBALANCE then + buena.balance = value + elseif varId == VAR.BUENASPASSWORD then + buena.word = value + end +end + +-- ../pokecrystal/engine/events/kurt.asm:19-45 SelectApricornForKurt, whose +-- byte is what `verbosegiveitemvar , VAR_KURT_APRICORNS` hands over. +function World:setKurtApricornQuantity(count) + local save = self.game and self.game.save + if not save then return end + save.kurtApricornQuantity = byteOf(count) +end + +-- ../pokecrystal/engine/overworld/time.asm:136-142, the 3..6 day roll. +function World:setKenjiBreak(days) + local save = self.game and self.game.save + if not save then return end + Gen2Save.crystalState(save).kenjiBreak = byteOf(days) +end + -- Script_checkver: 0 for Gold, 1 for Silver (constants/misc_constants.asm -- GS_VERSION). function World:gsVersion() @@ -1609,7 +1720,7 @@ function World:engineFlag(flag) -- how the two would come apart -- the officer's `setflag` and the results -- script's `clearflag` are the only writers, and both go through the pair -- below. - if flag == BugContest.ENGINE_BUG_CONTEST_TIMER and save then + if flag == FieldMoves.BUG_CONTEST_FLAG and save then return BugContest.isActive(save) end -- Badges live in save.player.badges, not in the flag table: on the cart the @@ -1622,6 +1733,11 @@ function World:engineFlag(flag) local owned = player and player[badge.store] return type(owned) == "table" and owned[badge.name] == true end + -- Same one-store rule for ENGINE_PLAYER_IS_FEMALE, which IS wPlayerGender + -- (../pokecrystal/data/events/engine_flags.asm:131); Gold's FEMALE_FLAG is nil. + if flag == FieldMoves.FEMALE_FLAG then + return Gen2Save.isFemale(save) + end -- Same one-store rule for the day care. data/events/engine_flags.asm:18-20 -- maps the three ids onto DAYCAREMAN_HAS_EGG_F / DAYCAREMAN_HAS_MON_F / -- DAYCARELADY_HAS_MON_F, i.e. they ARE the bits DayCare_InitBreeding, @@ -1645,7 +1761,7 @@ end function World:setEngineFlag(flag, value) if flag == nil then return end local save = self.game and self.game.save - if flag == BugContest.ENGINE_BUG_CONTEST_TIMER and save then + if flag == FieldMoves.BUG_CONTEST_FLAG and save then -- Route35NationalParkGate_OkayToProceed sets the flag BEFORE `special -- GiveParkBalls`, so starting here and starting again there is the cart's -- own order and the second start is what puts the balls on the counter. @@ -1661,6 +1777,13 @@ function World:setEngineFlag(flag, value) save.player[badge.store][badge.name] = value and true or nil return end + -- InitGender is the only writer on the cart, so this exists only to keep a + -- stray setflag out of save.engineFlags (../pokecrystal/engine/menus/init_gender.asm:23-42). + if flag == FieldMoves.FEMALE_FLAG and save then + save.player = save.player or {} + save.player.gender = value and "female" or "male" + return + end -- The write half of the day-care aliases. DayCareManScript_Outside's -- `clearflag ENGINE.DAY_CARE_MAN_HAS_EGG` (maps/Route34.asm) is the ONLY cart -- script that writes any of the three, and it is idempotent because @@ -1697,6 +1820,9 @@ function World:writeVar(varId, value) local state = PLAYER_STATE_BY_ID[value or 0] if state then self:applyPlayerState(state) end end + if varId >= VAR.BT_WIN_STREAK and varId <= VAR.KENJI_BREAK then + self:setCrystalVar(varId, value) + end end function World:battleType() @@ -1854,6 +1980,16 @@ end -- (maps/Route36.asm:58, and again at :70 on the DidntCatchSudowoodo arm) hands -- the same slot to the Route 37 twins. So the pooled objects that read -- through the slot have to go with it. +-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1564-1575 writes +-- the sprite byte into wMapObjects, the LIVE copy, so the map def is untouched. +function World:setObjectSprite(objectId, spriteName) + local npc = self:objectEntity(objectId) + local spriteDef = spriteName and self.sprites and self.sprites[spriteName] + if not (npc and spriteDef) then return false end + if npc:setSpriteDef(spriteDef) then self:applySpritePalette(npc) end + return true +end + function World:setVariableSprite(slot, spriteIndex) if slot == nil then return end self.variableSprites[slot] = spriteIndex @@ -1931,7 +2067,7 @@ function World:breedmonSpriteDef(species) local def = { id = "SPRITE_DAY_CARE_MON", image = entry.image, - frames = 1, + frames = 2, walker = false, spriteType = "POKEMON_SPRITE", palette = "PAL_OW_RED", @@ -2200,6 +2336,9 @@ end -- rang the ordinary item jingle while the item argument was thrown away. An -- item the cache cannot name takes the `cp TM_HM / jr z` fall-through, SFX.ITEM. function World:specialSound(itemIndex) + -- The `waitsfx` above it (scripting.asm:445): SFX_READ_TEXT_2 ($08), which + -- the box rings on its own press, outranks SFX_GET_TM ($9b) here (#1483). + Sound.waitSfxDone() local id = itemIndex and self:itemIdByIndex(itemIndex) local items = self.game and self.game.data and self.game.data.items local def = id and items and items[id] @@ -2318,12 +2457,10 @@ end -- the map pair and DAILYFLAGS1_SWARM are set by the one command. A port that -- stored only the map would leave the Dunsparce call live forever, because -- CheckSwarmFlag answers off the flag and clears the pair itself. -function World:setSwarm(group, mapNum) +function World:setSwarm(group, mapNum, kind) local save = self.game and self.game.save if not save then return end - save.dailyFlags = save.dailyFlags or {} - save.dailyFlags.swarm = true - save.swarmMap = self:mapIdByGroupMap(group, mapNum) + Roamers.Swarm.set(save, self:mapIdByGroupMap(group, mapNum), kind) end -- Script_loadwildmon's other half: roll the CURRENT map's own table the way a @@ -3373,14 +3510,6 @@ function World:updateMapSetup() if ms.phase == "out" then ms.step = ms.step + 1 self.fade, self.fadeLevel = "white", ms.step / FADE_STEPS - -- FlyFromAnim carries the player up and off the map under the fade. The - -- bird's own frames are not in the cache, but the lift is: it is the same - -- OBJECT_SPRITE_Y_OFFSET sine the teleport step type walks - -- (src/script/gen2/Movement.lua), stepped over the fade's four levels. - if ms.lift and self.player then - self.player.spriteYOffset = Movement.teleportYOffset( - Movement.TELEPORT_RISE_HEIGHT + ms.step * FADE_STEPS) - end if ms.step >= FADE_STEPS then -- setMap clears self.fade (a map load repaints everything), so the sheet -- has to be re-armed at full strength on the far side for the fade in to @@ -3392,16 +3521,12 @@ function World:updateMapSetup() return end ms.step = ms.step - 1 - -- FlyToAnim, the same curve read backwards: the player comes down onto the - -- destination tile as the fade lets go of the screen. - if ms.lift and self.player then - self.player.spriteYOffset = Movement.teleportYOffset( - Movement.TELEPORT_RISE_HEIGHT + ms.step * FADE_STEPS) - end if ms.step <= 0 then self.fade, self.fadeLevel = nil, nil self.mapSetup = nil - if ms.lift and self.player then self.player.spriteYOffset = 0 end + -- `callasm FlyToAnim` is the command straight after `newloadmap + -- MAPSETUP_TELEPORT` (engine/events/overworld.asm:604-605). + if ms.flyIn then self:startFlyAnim("to", ms.flyIn) end return end self.fadeLevel = ms.step / FADE_STEPS @@ -3472,7 +3597,7 @@ function World:specialHooks() takeItem = function(index, qty) return self:takeItem(index, qty) end, engineFlag = function(flag) return self:engineFlag(flag) end, setEngineFlag = function(flag, v) self:setEngineFlag(flag, v) end, - setSwarm = function(group, mapNum) self:setSwarm(group, mapNum) end, + setSwarm = function(group, mapNum, kind) self:setSwarm(group, mapNum, kind) end, dayCare = function(side, onDone) self:dayCare(side, onDone) end, givePokeMail = function(mail) return self:givePokeMail(mail) end, checkPokeMail = function(mail, onDone) self:checkPokeMail(mail, onDone) end, @@ -3504,6 +3629,14 @@ function World:specialHooks() magnetTrain = function(toGoldenrod, onDone) self:magnetTrain(toGoldenrod, onDone) end, + -- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:220-223 + startTowerBattle = function(trainer, onDone) + return self:startBattle({ trainer = trainer, battleTower = true }, onDone) + end, + -- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1552-1575 + setObjectSprite = function(objectId, spriteName) + return self:setObjectSprite(objectId, spriteName) + end, pushScreen = function(id, opts) return self:pushScreen(id, opts) end, monName = function(index) local id, def = speciesByIndex( @@ -3537,9 +3670,36 @@ function World:specialHooks() self:openScriptMenu(header, "vertical", onChoose) end, rareWildMon = function() return self:rareWildMon() end, + -- ../pokecrystal/engine/menus/save.asm:181 AskOverwriteSaveFile and :266 + -- _SaveGameData, the two halves of Link_SaveGame (:63). + saveFileState = function() return self:saveFileState() end, + writeSave = function() return self:writeSave() end, + setKurtApricornQuantity = function(n) self:setKurtApricornQuantity(n) end, + setKenjiBreak = function(days) self:setKenjiBreak(days) end, } end +-- AskOverwriteSaveFile's two reads: wSaveFileExists, and +-- CompareLoadedAndSavedPlayerID (../pokecrystal/engine/menus/save.asm:224), +-- which is what picks AlreadyASaveFileText over AnotherSaveFileText. +function World:saveFileState() + local save = self.game and self.game.save + local version = save and save.version + if not Gen2Save.exists(version) then return false, false end + local stored = Gen2Save.load(version) + local mine = save and save.player and save.player.id + local theirs = stored and stored.player and stored.player.id + return true, (mine ~= nil and mine == theirs) +end + +-- _SaveGameData, through the writer the SAVE menu is handed +-- (src/core/Game2.lua:435) so the save.write veto holds here too. +function World:writeSave() + local game = self.game + if not (game and game.writeSave) then return false end + return game:writeSave() ~= false +end + -- RandomUnseenWildMon's lookup half. The routine picks one of the THREE -- RAREST grass slots on the map (`and %11 / jr z` rerolls 0, so it is slots 5, -- 6 or 7 of the seven) and drops it if that species is also one of the FOUR @@ -3721,6 +3881,15 @@ function World:updateMovement() while st.i <= #st.bytes do local b = st.bytes[st.i] st.i = st.i + 1 + -- Movement_step_dig spins for the frames in the byte that follows + -- -- engine/overworld/movement.asm:113-131 (#1716) + if b == Movement.STEP_DIG then + local duration = st.bytes[st.i] or 0 + st.i = st.i + 1 + if ent.scriptSpin then ent:scriptSpin(duration) end + st.sleep = duration + return + end -- engine/overworld/movement.asm:163 if b == 0x57 then local duration = st.bytes[st.i] or 0 @@ -3746,6 +3915,11 @@ function World:updateMovement() elseif act.kind == "step" then local fromX, fromY = ent.cellX, ent.cellY if ent:scriptStep(act.dir) then + -- TurningStep's OBJECT_ACTION_SPIN, for the length of the step + -- (engine/overworld/movement.asm:693-699). + if act.spin and ent.scriptSpin then + ent:scriptSpin(ent.stepFrames or 16) + end self:followStep(ent, fromX, fromY) end return @@ -4441,6 +4615,9 @@ function World:useEscapeRope(itemId) local items = self.game and self.game.data and self.game.data.items local def = items and items[itemId or "ESCAPE_ROPE"] self:takeItem(def and def.index, 1) + -- ../pokecrystal/engine/events/overworld.asm:809, between .escaperope and + -- QueueScript. + UnownWords.kabutoChamber(self.events, self.map and self.map.id) self.queuedFieldMove = { ok = true, action = "escaperope", destMap = destMapId, destWarp = destWarp, @@ -4650,15 +4827,35 @@ end -- src/world/gen2/Bike.lua owns every decision; this is the world state those -- decisions read and the presentation they end in. +-- ../pokecrystal/constants/engine_flags.asm:25 ENGINE_MOBILE_SYSTEM shifts every +-- later id up one: :36 ALWAYS_ON_BIKE against ../pokegold's :35. +function World:engineFlagId(name, goldId) + local order = self.constants and self.constants.engineFlagOrder + if type(order) ~= "table" then return goldId end + local ids = self.engineFlagIds + if not ids then + ids = {} + for index, entry in pairs(order) do + if type(index) == "number" and type(entry) == "string" then + ids[entry] = index - 1 + end + end + self.engineFlagIds = ids + end + return ids[name] or goldId +end + -- wBikeFlags' three bits are ENGINE_* ids like any other flag, so the map -- callbacks that set them (Route16AlwaysOnBikeCallback, -- Route17AlwaysOnBikeCallback) already land on save.engineFlags. function World:alwaysOnBike() - return self:engineFlag(Bike.ENGINE_ALWAYS_ON_BIKE) + return self:engineFlag(World.engineFlagId( + self, "ENGINE_ALWAYS_ON_BIKE", Bike.ENGINE_ALWAYS_ON_BIKE)) end function World:downhill() - return self:engineFlag(Bike.ENGINE_DOWNHILL) + return self:engineFlag(World.engineFlagId( + self, "ENGINE_DOWNHILL", Bike.ENGINE_DOWNHILL)) end -- GetPlayerTilePermission's operand: the collision under the player's feet. @@ -4816,6 +5013,11 @@ function World:beginFishing(outcome, wild) if self.player then self.player.fishing = true self.player.fishingState = self.fishing + -- LoadFishingGFX reads wPlayerGender + -- (../pokecrystal/engine/events/fishing_gfx.asm:8-12) + self.player.fishSheet = + (FieldMoves.isFemale(self:playerGender()) and self.fishingSheetFemale) + or self.fishingSheet end end @@ -5186,6 +5388,9 @@ function World:fieldContext(mon) facingX = fx, facingY = fy, facingColl = map:cellCollision(fx, fy), playerColl = map:cellCollision(p.cellX, p.cellY), + -- Crystal's SurfFunction.TrySurf is the only field move that asks + -- (../pokecrystal/engine/events/overworld.asm:364). + facingObject = self:facingObject(), upColl = map:cellCollision(p.cellX, p.cellY - 1), tileset = map.def and map.def.tileset, facingBlock = blockId, @@ -5195,10 +5400,18 @@ function World:fieldContext(mon) -- digFromMenu reads this rather than re-deriving the banked warp. canEscapeRope = self:escapeRopeTarget() ~= nil, playerState = self.playerState, + -- SurfFunction.TrySurf and TrySurfOW both refuse while wBikeFlags' + -- ALWAYS_ON_BIKE is set (engine/events/overworld.asm:343-345, :498-500). + alwaysOnBike = self:alwaysOnBike(), strengthActive = self.strengthActive, -- FlashFunction tests wTimeOfDayPalset, not the map header, so a -- PALETTE_DARK map that FLASH has already lit refuses a second FLASH. dark = Palettes.isDarkness(map.def, self:hour(), self.flashUsed), + -- ../pokecrystal/engine/events/overworld.asm:285, called by FLASH only and + -- only after the badge gate, because it SETS the wall-opened flag. + openAerodactylWall = function() + return UnownWords.aerodactylChamber(self.events, map.id) + end, } end @@ -5359,12 +5572,26 @@ function World:refreshMapImages() return true end +-- wPlayerGender, the byte GetPlayerSprite and AddMapObject both branch on +-- (engine/overworld/overworld.asm:61-64, engine/overworld/player_object.asm:32-39). +function World:playerGender() + local save = self.game and self.game.save + return save and save.player and save.player.gender or nil +end + +-- The Chris/Kris sheet the player wears with no state on it +-- (data/sprites/player_sprites.asm:2, :9). +function World:playerSpriteName() + return FieldMoves.playerSprite(self:playerGender()) or PLAYER_SPRITE +end + -- UpdatePlayerSprite (data/sprites/player_sprites.asm ChrisStateSprites): the -- player's sprite is a pure function of wPlayerState, which is what makes -- getting on and off a Lapras a one-byte change rather than an animation. function World:applyPlayerState(state) self.playerState = state or FieldMoves.PLAYER_NORMAL - local name = FieldMoves.STATE_SPRITE[self.playerState] or PLAYER_SPRITE + local name = FieldMoves.stateSprite(self.playerState, self:playerGender()) + or PLAYER_SPRITE local def = self.sprites and self.sprites[name] if def and self.player then self.player:setSprite(def) @@ -5386,15 +5613,34 @@ function World:runCut(result) end -- Script_UsedWhirlpool, which is Script_Cut with DisappearWhirlpool and --- PlayWhirlpoolSound (a bare SFX.SURF) in place of the snip. +-- PlayWhirlpoolSound in place of the snip. function World:runWhirlpool(result) self:setNickname(result.mon) self:showText(Strings(result.text), function() self:replaceBlock(result.blockIndex, result.replacement) - self:playSfx(SFX.SURF) + self:playWhirlpoolSound() end) end +-- PlayWhirlpoolSound is WaitSFX, SFX_SURF, WaitSFX, never a bare PlaySFX +-- -- engine/events/field_moves.asm:5-10 (#1717) +function World:playWhirlpoolSound() + self.fieldMove = { phase = "whirlpoolsfx", waiting = true, left = 180 } +end + +-- PlayerMovementPointers' .force_turn arm and the Script_ForcedMovement it +-- calls -- events.asm:786-793, forced_movement.asm:1-51 (#1716) +local FORCED_BACK = { up = "down", down = "up", left = "right", right = "left" } + +function World:runForcedMovement() + local p = self.player + if not p or p.moving or self.moveState then return false end + local back = FORCED_BACK[p.facing] + if not back then return false end + self:beginMovement(0, Movement.forcedMovementBytes(back)) + return true +end + -- Script_UseFlash: the text plays SFX.FLASH from inside itself -- (UseFlashTextScript's text_asm), and BlindingFlash then sets -- STATUSFLAGS_FLASH_F and reloads the palettes. Setting the flag is all there @@ -5547,7 +5793,7 @@ function World:runFieldMove(result) elseif action == "waterfall" then self:runWaterfall(result) elseif action == "fly" then - self:openFlyMap() + self:openFlyMap(result.mon) elseif action == "headbutt" then self:runHeadbutt(result.facingX, result.facingY, result.mon) elseif action == "sweetscent" then @@ -5731,25 +5977,138 @@ function World:flyPoints() self.game and self.game.save, self.landmarks, self:region()) end --- .FlyScript: WarpToSpawnPoint, then `newloadmap MAPSETUP.TELEPORT` brings the --- map up with the player back in PLAYER_NORMAL. MapSetupScript_Teleport opens --- on FadeOutToWhite and falls through into _Warp, so flying is bracketed by the --- same pair of fades a door is, which is where the two fly animations ride: --- `lift` below hands them to World:updateMapSetup. -function World:flyTo(spawnId) +-- FlyFromAnim / FlyToAnim (engine/events/field_moves.asm:300, :334) and the two +-- curves they run (engine/sprite_anims/functions.asm:1350, :1418). +local FLY = { + FROM_FRAMES = 128, TO_FRAMES = 64, HOVER = 0x40, + AMP_MAX = 0x40, TO_AMP = 11 * 8, RISE = 84, +} + +-- FlyFunction_InitGFX's GetSpeciesIcon (engine/events/field_moves.asm:390): +-- the icon of the mon in wCurPartyMon, on PAL_OW_RED like every other OW OBJ. +function World:flyIconFor(mon) + if type(mon) ~= "table" then return nil end + local data = self.game and self.game.data + local icons = data and data.gen2Icons + local iconId = mon.isEgg and "ICON_EGG" + or (icons and icons.species and mon.species + and icons.species[mon.species]) + local entry = iconId and icons and icons.icons and icons.icons[iconId] + if not (entry and entry.image) then return nil end + local def = { + id = "SPRITE_FLY_MON", image = entry.image, frames = 2, walker = false, + spriteType = "POKEMON_SPRITE", palette = "PAL_OW_RED", paletteId = 0, + species = mon.species, icon = iconId, + } + local ok, icon = pcall(SpriteRenderer.new, def, "gen2fly") + if not (ok and icon) then return nil end + local daytime = self.daytime or Palettes.daytimeFor( + self.map and self.map.def, self:hour(), self.flashUsed) + local colors = Palettes.spritePalette(self.palettes, daytime, def) + if colors then + icon:setObjPalette(colors, ("gen2:%s:0"):format(tostring(daytime))) + end + return icon +end + +-- False when there is no icon sheet (or no love at all): the caller then flies +-- the way it always did rather than parking the world on an animation. +function World:startFlyAnim(phase, mon, onDone) + local icon = self:flyIconFor(mon) + local p = self.player + if not (icon and p) then return false end + local landing = phase == "to" + self.flyAnim = { + phase = phase, icon = icon, onDone = onDone, t = 0, + px = p.px, py = p.py, xoff = 0, wave = 0, + left = landing and FLY.TO_FRAMES or FLY.FROM_FRAMES, + hover = landing and 0 or FLY.HOVER, + amp = landing and FLY.TO_AMP or 0, + y = landing and -FLY.RISE or 0, + } + return true +end + +-- FlyFunction_FrameTimer (engine/events/field_moves.asm:409) over the two +-- AnimSeq_Fly* curves; the wobble is Sprites_Cosine's d * cos(n * pi / 32). +function World:stepFlyAnim() + local fa = self.flyAnim + if not fa then return end + local left = fa.left + if left <= 0 then + local done = fa.onDone + self.flyAnim = nil + if done then done() end + return + end + fa.left = left - 1 + if left >= 0x40 and left % 8 == 0 then + self:playSfxNamed("Sfx_Fly", SFX.FLY) + end + fa.t = fa.t + 1 + local amp = fa.amp + if fa.phase == "to" then + if fa.y >= 0 then return end + fa.y = fa.y + 2 + if amp > 0 then fa.amp = amp - 2 end + else + if fa.hover > 0 then + fa.hover = fa.hover - 1 + return + end + if fa.y <= -FLY.RISE then return end + fa.y = fa.y - 2 + if amp < FLY.AMP_MAX then fa.amp = amp + 8 end + end + fa.xoff = math.floor(amp * math.cos((fa.wave % 64) * math.pi / 32)) + fa.wave = fa.wave + 1 +end + +-- .Frameset_RedWalk is two 8-frame icon beats, the fourth mirrored +-- -- data/sprite_anims/framesets.asm:81-86 +function World:drawFlyAnim(s, billboard) + local fa = self.flyAnim + if not (fa and fa.icon) then return end + local G = love.graphics + local cam = self.camera + local px = fa.px + fa.xoff + local py = fa.py + fa.y + local ox = math.floor((0 - cam.x) * s) + local oy = math.floor((0 - cam.y) * s) + local beat = math.floor(fa.t / 8) % 4 + local function body() + G.setColor(1, 1, 1, 1) + G.push() + G.translate(ox, oy) + G.scale(s, s) + fa.icon:draw(px, py, 0, 0, "down", 0, false, false, beat == 3, beat % 2) + G.pop() + end + if billboard then + billboard(ox + (px + 8) * s, oy + (py + 16) * s, body) + else + body() + end +end + +-- .FlyScript: FlyFromAnim, WarpToSpawnPoint, `newloadmap MAPSETUP_TELEPORT`, +-- then FlyToAnim -- engine/events/overworld.asm:595-609 +function World:flyTo(spawnId, mon) local spawn = self.landmarks and self.landmarks.spawns and self.landmarks.spawns[spawnId] if not (spawn and spawn.map and self.maps and self.maps[spawn.map]) then return false end - self:applyPlayerState(FieldMoves.PLAYER_NORMAL) - local ok = self:runMapSetup(MAPSETUP.TELEPORT, function() - return self:setMap(spawn.map, spawn.x, spawn.y, "down") - end) - -- FlyFromAnim / FlyToAnim ride the setup script's own two fades: the take-off - -- lift under the fade out, the landing under the fade in. - if self.mapSetup then self.mapSetup.lift = true end - return ok + local function warp() + self:applyPlayerState(FieldMoves.PLAYER_NORMAL) + local ok = self:runMapSetup(MAPSETUP.TELEPORT, function() + return self:setMap(spawn.map, spawn.x, spawn.y, "down") + end) + if self.mapSetup then self.mapSetup.flyIn = mon end + return ok + end + if self:startFlyAnim("from", mon, warp) then return true end + return warp() end -- _FlyMap: the town map with the cursor locked to visited flypoints, A takes @@ -5760,7 +6119,7 @@ end -- "Where?" plate over it instead of the card strip. A run with no love at all -- (a headless probe) has no screen to push, so the destinations are offered -- one at a time through the same yesorno box every other field move uses. -function World:openFlyMap() +function World:openFlyMap(mon) local points = self:flyPoints() if #points == 0 then return false end -- Loaded on demand and through pcall: a headless run has no love, and this @@ -5771,28 +6130,31 @@ function World:openFlyMap() save = self.game.save, currentLandmark = self:currentLandmarkId(), fly = points, + -- TownMapMon draws wCurPartyMon's icon as the cursor + -- (../pokecrystal/engine/pokegear/pokegear.asm:2708-2721). + flyMon = mon, onFly = function(spawnId) self.game.stack:pop() - self:flyTo(spawnId) + self:flyTo(spawnId, mon) end, onClose = function() self.game.stack:pop() end, }) return true end - self:askFlyPoint(points, 1) + self:askFlyPoint(points, 1, mon) return true end -function World:askFlyPoint(points, index) +function World:askFlyPoint(points, index, mon) local row = points[index] if not row then return end local name = (row.name or row.landmark):gsub("\n", " ") self:showText(Strings(FieldMoves.TEXT.ASK_FLY_TO, name), function() self:askYesNo(function(yes) if yes then - self:flyTo(row.spawn) + self:flyTo(row.spawn, mon) else - self:askFlyPoint(points, index + 1) + self:askFlyPoint(points, index + 1, mon) end end) end) @@ -5820,6 +6182,18 @@ function World:updateFieldMove() st.timer = st.timer - 1 return end + if st.phase == "whirlpoolsfx" then + st.left = (st.left or 0) - 1 + if st.waiting then + if Sound.sfxBusy() and st.left > 0 then return end + st.waiting = nil + self:playSfxNamed("Sfx_Surf", SFX.SURF) + return + end + if Sound.sfxBusy() and st.left > 0 then return end + self.fieldMove = nil + return + end if st.phase == "strength" then self.fieldMove = nil if st.text then self:showText(Strings(st.text)) end @@ -5915,6 +6289,12 @@ function World:startBattle(opts, onDone) -- wBattleType, when the script armed one: the FORCESHINY / TRAP -- no-escape rules live in Battle:tryRun and the force-switch handler. battleType = opts.battleType, + -- wInBattleTowerBattle (../pokecrystal/engine/events/battle_tower/ + -- battle_tower.asm:220-223), which turns DoBadgeTypeBoosts off. + battleTower = opts.battleTower, + -- wTimeOfDay, for BattleCommand_TimeBasedHealContinue + -- (engine/battle/effect_commands.asm:6401-6404). + timeOfDay = self:timeOfDayId(), }) self:playBattleMusic(opts) local function pushBattle() @@ -7428,26 +7808,43 @@ function World:interact() return self:interactBody() end +-- CheckFacingObject (engine/overworld/npc_movement.asm:229-248): "Double the +-- distance for counter tiles." A Pokecenter nurse and a Mart clerk stand +-- BEHIND a COLL_COUNTER tile, so the cell the player faces is the counter +-- itself and the object is one further on. Without this the press finds an +-- empty wall and nothing happens -- which is to say no nurse and no clerk in +-- the game could be talked to at all. +-- +-- Only the OBJECT lookup is doubled, exactly as the cart does it: bg events +-- and the tile-collision events still read the tile actually faced. +function World:facingObjectCell() + local p = self.player + if not p then return nil end + local d = Map.DELTA[p.facing] or Map.DELTA.down + local fx, fy = p.cellX + d[1], p.cellY + d[2] + if self.map and Permissions.isCounter(self.map:cellCollision(fx, fy)) then + return p.cellX + d[1] * 2, p.cellY + d[2] * 2 + end + return fx, fy +end + +-- The carry CheckFacingObject answers with: IsNPCAtCoord, and then only when +-- that object's OBJECT_WALKING reads STANDING (npc_movement.asm:250-266). +function World:facingObject() + local ox, oy = self:facingObjectCell() + if not ox then return nil end + local npc = self:npcAt(ox, oy) + if npc and npc.moving then return nil end + return npc +end + function World:interactBody() if self:busy() or not self.player or not self.vm then return false end local p = self.player if p.moving then return false end local d = Map.DELTA[p.facing] local fx, fy = p.cellX + d[1], p.cellY + d[2] - -- CheckFacingObject (engine/overworld/npc_movement.asm:229): "Double the - -- distance for counter tiles." A Pokecenter nurse and a Mart clerk stand - -- BEHIND a COLL_COUNTER tile, so the cell the player faces is the counter - -- itself and the object is one further on. Without this the press finds an - -- empty wall and nothing happens -- which is to say no nurse and no clerk in - -- the game could be talked to at all. - -- - -- Only the OBJECT lookup is doubled, exactly as the cart does it: bg events - -- and the tile-collision events below still read the tile actually faced. - local ox, oy = fx, fy - if self.map and Permissions.isCounter(self.map:cellCollision(fx, fy)) then - ox, oy = p.cellX + d[1] * 2, p.cellY + d[2] * 2 - end - local npc = self:npcAt(ox, oy) + local npc = self:npcAt(self:facingObjectCell()) -- TryObjectEvent writes hLastTalked for EVERY A-press dispatch; scripts -- then use LAST_TALKED (`disappear`, `applymovementlasttalked`) without any -- setlasttalked of their own. The port only wrote it from the explicit @@ -8481,8 +8878,10 @@ function World:setMap(mapId, cx, cy, facing, opts) -- MAPCALLBACK_NEWMAP runs, because the Cycling Road's callback is what sets -- them straight back again: leaving them set is how one visit to Route 17 -- would keep the player glued to the bike for the rest of the game. - self:setEngineFlag(Bike.ENGINE_ALWAYS_ON_BIKE, false) - self:setEngineFlag(Bike.ENGINE_DOWNHILL, false) + self:setEngineFlag(World.engineFlagId( + self, "ENGINE_ALWAYS_ON_BIKE", Bike.ENGINE_ALWAYS_ON_BIKE), false) + self:setEngineFlag(World.engineFlagId( + self, "ENGINE_DOWNHILL", Bike.ENGINE_DOWNHILL), false) -- "Respawn in Pokemon Centers" (home/map.asm, LoadMapAttributes' .SetSpawn): -- walking from an OUTDOOR map into an INDOOR one whose tileset is -- TILESET_POKECENTER rewrites wLastSpawnMapGroup / wLastSpawnMapNumber, and @@ -8541,13 +8940,13 @@ function World:setMap(mapId, cx, cy, facing, opts) -- GetWarpDestCoords / EnterMapConnection / EnterMapSpawnPoint write wXCoord -- and wYCoord BEFORE HandleNewMap (data/maps/setup_scripts.asm:79-106). local face = facing or (self.player and self.player.facing) or "down" - local chris = self.sprites and self.sprites[PLAYER_SPRITE] + local playerDef = self.sprites and self.sprites[self:playerSpriteName()] if self.player then self.player.cellX, self.player.cellY = cx, cy self.player.px, self.player.py = cx * 16, cy * 16 self.player.facing = face - if chris and not self.player.sprite then - self.player:setSprite(chris) + if playerDef and not self.player.sprite then + self.player:setSprite(playerDef) end if not opts.seamless then self.player.moving = false @@ -8555,7 +8954,7 @@ function World:setMap(mapId, cx, cy, facing, opts) self.player.targetX, self.player.targetY = nil, nil end else - self.player = Player.new(cx, cy, face, chris) + self.player = Player.new(cx, cy, face, playerDef) end -- LoadMapObjects rebuilds OBJECT_FLAGS2 from scratch, so IN_GRASS is decided -- by the cell the player arrives on (engine/overworld/map_objects.asm:247). @@ -9583,6 +9982,7 @@ end function World:stepBody() if not self.map or not self.player then return end + self.stepFinished = false self:pollTimeOfDay() -- ShakeScreen and the `musicfadeout` tail both run UNDER a script (the VM is -- parked on the earthquake's own waitFrames while the screen is still @@ -9663,6 +10063,7 @@ function World:stepBody() -- it ticks here above the input gate the same way the emote does; its -- last flash's onDone is what resumes the nurse. if self.healAnim then self:stepHealAnim() end + if self.flyAnim then self:stepFlyAnim() end -- HandleCmdQueue sits in the overworld loop, once a frame, above the input -- gate: it is what drops a boulder that is already sitting on a hole, and it @@ -9683,8 +10084,9 @@ function World:stepBody() -- Freeze player input while a script / textbox / cutscene move is up. if self:busy() then - -- Keep scripted entities animating mid-step. - if self.player and self.player.moving then + -- Keep scripted entities animating mid-step. A step_dig spin has the + -- player standing still, so it has to tick here too. + if self.player and (self.player.moving or self.player.spinFrames) then self:playerStepGrass() if self.player:update() then self.player.inGrass = @@ -9698,6 +10100,7 @@ function World:stepBody() local p = self.player self:playerStepGrass() local landed = p:update() + self.stepFinished = landed -- CopyCoordsTileToLastCoordsTile -> SetTallGrassFlags, which is what a step -- ENDS on (engine/overworld/map_objects.asm:196-208, :247). if landed then p.inGrass = self:grassAt(p.cellX, p.cellY) end @@ -9759,6 +10162,11 @@ function World:stepBody() local dir = self.heldDir if not p.moving then local coll = self:playerCollision() + -- .CheckTile tests CheckWhirlpoolTile above the nybble ladder + -- -- engine/overworld/player_movement.asm:117-123 (#1716) + if Permissions.isWhirlpool(coll) and self:runForcedMovement() then + return + end local current = Permissions.currentDirection(coll) or Permissions.doorForcedDirection(coll) if current then @@ -9872,18 +10280,21 @@ function World:drawPeople(s, billboard) local G = love.graphics local p = self.player local cam = self.camera - local drawList = { - { kind = "player", py = p.py, ox = 0, oy = 0 }, - } - for _, npc in ipairs(self.npcs) do - drawList[#drawList + 1] = { - kind = "npc", npc = npc, ox = 0, oy = 0, py = npc.py, - } - end - for _, g in ipairs(self.ghosts) do - drawList[#drawList + 1] = { - kind = "npc", npc = g.npc, ox = g.ox, oy = g.oy, py = g.oy + g.npc.py, - } + -- .FlyScript hides the map's objects from HideSprites until its + -- LoadWalkingSpritesGFX tail -- engine/events/overworld.asm:597, :608 + local drawList = {} + if not self.flyAnim then + drawList[1] = { kind = "player", py = p.py, ox = 0, oy = 0 } + for _, npc in ipairs(self.npcs) do + drawList[#drawList + 1] = { + kind = "npc", npc = npc, ox = 0, oy = 0, py = npc.py, + } + end + for _, g in ipairs(self.ghosts) do + drawList[#drawList + 1] = { + kind = "npc", npc = g.npc, ox = g.ox, oy = g.oy, py = g.oy + g.npc.py, + } + end end table.sort(drawList, function(a, b) return a.py < b.py end) @@ -9918,6 +10329,7 @@ function World:drawPeople(s, billboard) self:drawEmote(s, billboard) self:drawHealAnim(s, billboard) + self:drawFlyAnim(s, billboard) end -- Split out of drawPeople so World:drawPipeline composites the one copy the @@ -9985,11 +10397,12 @@ function World:drawPipeline(id, w, h, s) -- the art: nil, like Gen 1 returns in its true-colour modes. paletteFor = function() return nil end, spriteColors = function() return nil end, - -- Gold's only standing effects; it has no dust/cutTree/bird/rod overlay, - -- and Gen 1's `at` skips a nil body, so those keys are simply absent. + -- Gold's only standing effects; it has no dust/cutTree/rod overlay, and + -- Gen 1's `at` skips a nil body, so those keys are simply absent. fx = { emote = function() self:drawEmote(1, nil) end, heal = function() self:drawHealAnim(1, nil) end, + bird = function() self:drawFlyAnim(1, nil) end, }, } -- `project(wx, wy)` -> canvas pixels, nil behind the camera. s = 1 lays the @@ -10007,6 +10420,7 @@ function World:drawPipeline(id, w, h, s) end self:drawEmote(1, at) self:drawHealAnim(1, at) + self:drawFlyAnim(1, at) end local override = Pipelines.drawWorld(id, ctx) -- world post-processes fold in here, so they never touch the text box on top diff --git a/tests/crystal_import_test.lua b/tests/crystal_import_test.lua new file mode 100644 index 00000000..915d732b --- /dev/null +++ b/tests/crystal_import_test.lua @@ -0,0 +1,165 @@ +-- Crystal manifest shape and the specials surface the extractor pins to it. +-- ROM-free: reads tools/rom_manifest_crystal.json out of the source tree. +-- luajit tests/crystal_import_test.lua +-- Also dofile'd by tests/run_tests.lua. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("crystal import") +local check, eq = S.check, S.eq + +love = require("tests.love_stub") + +local Json = require("src.link.Json") +local GameVersion = require("src.core.GameVersion") + +local function manifest(path) + local file = assert(io.open(path, "r")) + local data = assert(Json.decode(file:read("*a"))) + file:close() + return data +end + +local function size(tbl) + local n = 0 + for _ in pairs(tbl or {}) do n = n + 1 end + return n +end + +local crystal = manifest("tools/rom_manifest_crystal.json") +local gold = manifest("tools/rom_manifest_gold.json") + +-- ------- 1. the manifest the launcher row points at + +eq(GameVersion.VERSIONS.crystal.manifest, "tools/rom_manifest_crystal.json", + "the crystal row names this manifest") +eq(crystal.romSha1, GameVersion.VERSIONS.crystal.sha1, + "and the manifest carries the same sha1") +eq(crystal.generation, 2, "generation 2") +eq(crystal.format, gold.format, "same manifest format as Gold") + +-- ------- 2. content counts + +eq(size(crystal.maps), 388, "388 maps") +eq(size(crystal.tilesets), 36, "36 tilesets") +eq(size(crystal.pokemonAssets), 251, "251 pokemon asset rows") +eq(size(crystal.constants), 50, "50 constants keys") +eq(type(crystal.constants.engineFlagOrder), "table", + "the 50th is engineFlagOrder, the key Gold has no counterpart for") +check(size(crystal.symbols) > 2000, + ("symbols table is populated (%d)"):format(size(crystal.symbols))) +check(size(crystal.charmap) > 200, + ("charmap is populated (%d)"):format(size(crystal.charmap))) +check(#(crystal.fontCharmap or {}) > 200, + ("fontCharmap is populated (%d)"):format(#(crystal.fontCharmap or {}))) + +-- pokecrystal/constants/map_constants.asm:504, pokegold:484 +eq(size(gold.maps), 368, "Gold names 368") +local added, removed = 0, 0 +for name in pairs(crystal.maps) do + if not gold.maps[name] then added = added + 1 end +end +for name in pairs(gold.maps) do + if not crystal.maps[name] then removed = removed + 1 end +end +eq(added, 21, "Crystal adds 21 maps") +eq(removed, 1, "and drops one") +-- pokegold/constants/map_constants.asm:152 +eq(crystal.maps.ECRUTEAK_TIN_TOWER_BACK_ENTRANCE, nil, + "the one Gold map Crystal drops is ECRUTEAK_TIN_TOWER_BACK_ENTRANCE") +check(crystal.maps.BATTLE_TOWER_1F ~= nil, "and BATTLE_TOWER_1F is new") + +-- ------- 3. every map row is addressable + +local badMap +for name, row in pairs(crystal.maps) do + if type(row) ~= "table" then badMap = name; break end +end +eq(badMap, nil, "every map row is a table") + +-- ------- 4. the text labels resolve, as they must for the Dialogue stage + +local labels = (crystal.text or {}).labels or {} +check(#labels > 800, ("crystal names %d text labels"):format(#labels)) +local unresolved = {} +for _, label in ipairs(labels) do + if not crystal.symbols[label] then unresolved[#unresolved + 1] = label end +end +eq(#unresolved, 0, + ("every crystal text label resolves to a symbol (%s)") + :format(table.concat(unresolved, ", "):sub(1, 60))) + +-- ------- 5. specials: constants.specialOrder against the handler table + +local Specials = require("src.script.gen2.Specials") + +local order = crystal.constants.specialOrder +check(type(order) == "table", "constants.specialOrder is a list") +eq(#order, 169, "Crystal's SpecialsPointers has 169 rows") + +local missing = {} +for index, name in ipairs(order) do + if not Specials.ALL[name] then + missing[#missing + 1] = ("%d (%s)"):format(index - 1, name) + end +end +eq(#missing, 0, + ("every crystal special has a handler (%s)") + :format(table.concat(missing, ", "):sub(1, 80))) + +local goldOrder = gold.constants.specialOrder +local goldMissing = {} +for index, name in ipairs(goldOrder or {}) do + if not Specials.ALL[name] then + goldMissing[#goldMissing + 1] = ("%d (%s)"):format(index - 1, name) + end +end +eq(#goldMissing, 0, + ("every gold special has a handler too (%s)") + :format(table.concat(goldMissing, ", "):sub(1, 80))) + +local sameOrder = #order == #(goldOrder or {}) +if sameOrder then + for index, name in ipairs(order) do + if goldOrder[index] ~= name then sameOrder = false; break end + end +end +check(not sameOrder, "the Crystal and Gold special orders are not the same") + +-- ------- 6. handler bookkeeping + +local overlap = {} +for name in pairs(Specials.STUBS) do + if Specials.HANDLERS[name] then overlap[#overlap + 1] = name end +end +eq(#overlap, 0, + ("HANDLERS and STUBS are disjoint (%s)"):format(table.concat(overlap, ", "))) + +local unexplained = {} +for name in pairs(Specials.STUBS) do + local reason = (Specials.STUB_REASONS or {})[name] + if type(reason) ~= "string" or reason == "" then + unexplained[#unexplained + 1] = name + end +end +eq(#unexplained, 0, + ("every stub records a reason (%s)") + :format(table.concat(unexplained, ", "):sub(1, 80))) + +-- ------- 7. the Crystal-only assets the completeness gate pins + +local importerSource = assert(io.open("src/import/RomImporter.lua", "r")) +local importerText = importerSource:read("*a") +importerSource:close() +for _, path in ipairs({ + "assets/generated/title/crystal_logo.png", + "assets/generated/title/crystal_wordmark.png", + "assets/generated/title/crystal_suicune.png", + "assets/generated/splash/ditto.png", + "assets/generated/intro/chris.png", + "assets/generated/intro/kris.png", +}) do + check(importerText:find(path, 1, true) ~= nil, + "RomImporter still requires " .. path) +end + +S.finish() diff --git a/tests/crystal_world_test.lua b/tests/crystal_world_test.lua new file mode 100644 index 00000000..d86d2226 --- /dev/null +++ b/tests/crystal_world_test.lua @@ -0,0 +1,158 @@ +-- Crystal world data: the roamer roster and swarm pairs that differ from +-- Gold, then the facts only a real Crystal cache can answer. +-- Self-contained: `luajit tests/crystal_world_test.lua`; also dofile'd by +-- tests/run_tests.lua. The cache half SKIPs when no crystal cache is present. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("crystal world") +local check, eq = S.check, S.eq + +local Permissions = require("src.world.gen2.Permissions") +local Roamers = require("src.core.gen2.Roamers") +local Swarm = Roamers.Swarm + +-- ---------------------------------------------------------------- ROM-free + +check(Permissions.isWalkable(0x00), "COLL_FLOOR walkable") +check(not Permissions.isWalkable(0x07), "COLL_WALL blocked") +-- pokecrystal/constants/collision_constants.asm:19 +check(Permissions.isGrass(0x18), "COLL_TALL_GRASS is grass") +check(Permissions.isWater(0x29), "COLL_WATER is water") +check(Permissions.isWarpCollision(0x71), "COLL_DOOR is a warp") +check(Permissions.isLedge(0xa0), "COLL_HOP_DOWN is a ledge") +eq(Permissions.of(0xff), Permissions.WALL, "a missing coll reads as wall") + +local bare = {} +Roamers.init(bare) +eq(#bare.roamers, 3, "no cache -> the three-beast Gold roster") +check(Roamers.SPECIES ~= nil and #Roamers.SPECIES == 3, + "Roamers.SPECIES survives as the ROM-free fallback") + +-- pokecrystal/constants/script_constants.asm:256-257 +local pair = {} +Swarm.set(pair, "DARK_CAVE_VIOLET_ENTRANCE", 0) +Swarm.set(pair, "ROUTE_35", 1) +eq(pair.swarmMaps.DUNSPARCE, "DARK_CAVE_VIOLET_ENTRANCE", "SWARM_DUNSPARCE key") +eq(pair.swarmMaps.YANMA, "ROUTE_35", "SWARM_YANMA key, independently") +eq(Swarm.onMap(pair, "ROUTE_35"), "YANMA", "the Yanma map answers YANMA") +eq(Swarm.mapId(pair, "YANMA"), "ROUTE_35", "mapId takes a kind") +eq(Swarm.mapId(pair), "DARK_CAVE_VIOLET_ENTRANCE", "and defaults to Gold's key") +Swarm.timeEvents(pair, 100) +check(Swarm.timeEvents(pair, 101), "the next day ends the swarm") +eq(Swarm.onMap(pair, "ROUTE_35"), nil, "and both pairs are stranded") + +local legacy = { swarmMap = "ROUTE_35", dailyFlags = { swarm = true } } +eq(Swarm.mapId(legacy), "ROUTE_35", "an old scalar save answers mapId") +eq(Swarm.onMap(legacy, "ROUTE_35"), "DUNSPARCE", "normalized onto the Gold key") +eq(Swarm.onMap(legacy, "ROUTE_36"), nil, "another map is not the swarm map") +check(Swarm.entry(legacy, { swarmGrass = { ROUTE_35 = { "row" } }, grass = {} }, + "ROUTE_35", "grass") ~= nil, "and its swarm table still resolves") + +-- ------------------------------------------------------------ cache-gated + +local cache = os.getenv("CRYSTAL_CACHE") +if not cache then + local home = os.getenv("HOME") or "" + cache = home .. "/Library/Application Support/LOVE/crystal-dev/crystal" +end + +local mapsPath = cache .. "/data/generated/maps.lua" +local mapsFile = io.open(mapsPath, "r") +if not mapsFile then + check(true, "crystal cache absent : SKIP") + S.finish() + return +end +mapsFile:close() + +local function loadLua(rel) + local chunk = loadfile(cache .. "/" .. rel) + if not chunk then return nil end + local ok, value = pcall(chunk) + return ok and value or nil +end + +local maps = loadLua("data/generated/maps.lua") +local encounters = loadLua("data/generated/encounters.lua") +local tilesets = loadLua("data/generated/tilesets.lua") +local constants = loadLua("data/generated/constants.lua") + +check(maps ~= nil, "maps.lua loads") +check(encounters ~= nil, "encounters.lua loads") +check(tilesets ~= nil, "tilesets.lua loads") +check(constants ~= nil, "constants.lua loads") + +-- ---- 1. the maps Phase 1 has to reach + +local count = 0 +for _ in pairs(maps or {}) do count = count + 1 end +eq(count, 388, "the cache carries 388 maps") +for _, id in ipairs({ + "NEW_BARK_TOWN", "PLAYERS_HOUSE_1F", "PLAYERS_HOUSE_2F", "ELMS_LAB", + "ROUTE_29", "CHERRYGROVE_CITY", "ROUTE_30", "ROUTE_31", "VIOLET_CITY", + "SPROUT_TOWER_1F", "VIOLET_GYM", +}) do + check(maps[id] ~= nil, "the New Bark to Violet route has " .. id) +end +check(maps.BATTLE_TOWER_1F ~= nil, "and the Crystal-only Battle Tower") +-- pokegold/constants/map_constants.asm:152 +eq(maps.ECRUTEAK_TIN_TOWER_BACK_ENTRANCE, nil, + "the one map Crystal drops is absent") + +-- ---- 2. roamers (CT-4) + +check(type(encounters.roamMons) == "table", + "the cache emits encounters.roamMons") +eq(#encounters.roamMons, 2, "Crystal seeds two beasts, not three") +eq(encounters.roamMons[1].species, "RAIKOU", "slot 1 is Raikou") +eq(encounters.roamMons[1].map, "ROUTE_42", "on Route 42") +eq(encounters.roamMons[2].species, "ENTEI", "slot 2 is Entei") +eq(encounters.roamMons[2].map, "ROUTE_37", "on Route 37") +eq(encounters.roamMons[1].level, 40, "both start at level 40") +eq(encounters.roamMons[2].level, 40, "both start at level 40") + +local save = {} +Roamers.init(save, { encounters = encounters }) +eq(#save.roamers, 2, "Roamers.init over the cache builds two slots") +eq(save.roamers[1].species, "RAIKOU", "slot 1 Raikou") +eq(save.roamers[2].species, "ENTEI", "slot 2 Entei") +eq(save.roamers[3], nil, "and no Suicune slot") +eq(save.roamers[1].hp, 0, "hp is zeroed so stats regenerate on contact") + +local data = {} +Roamers.init(data, { data = { gen2Encounters = encounters } }) +eq(#data.roamers, 2, "the opts.data path resolves the same roster") + +eq(Roamers.checkEncounter(save, "ROUTE_38", false, function() return 3 end), nil, + "the Suicune slot roll finds nothing on a Crystal save") +local hit = Roamers.checkEncounter(save, "ROUTE_42", false, function() return 1 end) +check(hit and hit.species == "RAIKOU", "the Raikou roll still hits") + +-- ---- 3. the swarm tables the kind byte indexes + +check(encounters.swarmGrass ~= nil, "the cache carries swarmGrass") +check(encounters.swarmGrass.DARK_CAVE_VIOLET_ENTRANCE ~= nil, + "with the Dunsparce table") +check(encounters.swarmGrass.ROUTE_35 ~= nil, "and the Yanma table") + +-- ---- 4. the tileset palette-map bank +-- pokecrystal.sym 13:40e5 TilesetJohtoPalMap (pokegold.sym 02:40c7) +local johto = (tilesets or {}).TILESET_JOHTO +check(johto ~= nil, "TILESET_JOHTO is in the cache") +eq(johto and johto.palMap and johto.palMap.bank, 0x13, + "TilesetJohtoPalMap is read out of bank $13") +eq(johto and johto.palMap and johto.palMap.address, 0x40e5, + "at $40e5") +local kanto = (tilesets or {}).TILESET_KANTO +eq(kanto and kanto.palMap and kanto.palMap.bank, 0x13, + "TilesetKantoPalMap is bank $13 too") +eq(kanto and kanto.palMap and kanto.palMap.address, 0x4075, "at $4075") +check(johto and johto.tilePalettes and #johto.tilePalettes > 0, + "and the map decoded to a per-tile palette list") + +-- ---- 5. the special table the cache pins + +eq(#(constants.specialOrder or {}), 169, + "the cache's specialOrder has Crystal's 169 rows") + +S.finish() diff --git a/tests/drivers/bag_cancel_row_bug1685_test.lua b/tests/drivers/bag_cancel_row_bug1685_test.lua new file mode 100644 index 00000000..cc5c7e05 --- /dev/null +++ b/tests/drivers/bag_cancel_row_bug1685_test.lua @@ -0,0 +1,174 @@ +-- Eye check on the CANCEL row at the foot of the item list (#1685). +-- PrintListMenuEntries prints ListMenuCancelText on the $ff terminator and +-- returns there (pokered home/list_menu.asm:371-372, 523-528), so the '▼' +-- at :518-522 never shares a page with CANCEL; DisplayListMenuIDLoop treats +-- that row as ExitListMenu (:105-110). No POKEPORT_SPEED: the arrow and +-- the cursor step are being judged frame by frame. +-- POKEPORT_DRIVER=tests/drivers/bag_cancel_row_bug1685_test.lua POKEPORT_IDENTITY=bug1685 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pokemon = require("src.pokemon.Pokemon") + local ListMenu = require("src.ui.ListMenu") + local Menu = require("src.ui.Menu") + local Screens = require("src.ui.Screens") + local Strings = require("src.core.Strings") + + local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local ok = true + local function check(label, pass) + U.log(pass and "PASS" or "FAIL", label) + if not pass then ok = false end + return pass + end + + -- pokered data/maps/objects/PalletTown.asm: the house doors sit at (5,5) + -- and (13,5) and the lab's at (12,11), so (10,8) is open ground. + local TOWN, TOWN_X, TOWN_Y = "PALLET_TOWN", 10, 8 + -- six items: page one fills all four printed rows (so the '▼' is there to + -- lose), and CANCEL lands on the page after it + local STOCK = { "POTION", "ANTIDOTE", "BURN_HEAL", "ICE_HEAL", + "AWAKENING", "PARLYZ_HEAL" } + + for _, id in ipairs(STOCK) do + check(id .. " exists as an item", game.data.items[id] ~= nil) + end + check("CANCEL has a string", Strings("CANCEL") ~= nil and Strings("CANCEL") ~= "") + check("renderer is up", game.renderer ~= nil) + + game.save.player.name = "SEBAS" + game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) } + game.save.inventory = {} + game.save.bagOrder = nil + local Bag = require("src.inventory.Bag") + for i, id in ipairs(STOCK) do Bag.add(game.save, id, i) end + + local function stationary() + for _ = 1, 60 do + if not (game.overworld and game.overworld.player.moving) then break end + coroutine.yield() + end + end + + local function openBag(where) + stationary() + U.tap(game, "start") + U.wait(20) + local menu = game.stack:top() + if getmetatable(menu) == Menu then + local ITEM = Strings("ITEM") + for _ = 1, 20 do + if menu.items[menu.index].label == ITEM then break end + U.tap(game, "down") + U.wait(6) + end + U.tap(game, "a") + U.wait(20) + end + local bag = game.stack:top() + if getmetatable(bag) ~= ListMenu then + U.log("START -> ITEM did not reach the bag " .. where + .. ", pushing BagMenu directly") + bag = Screens.push(game, "BagMenu") + U.wait(20) + end + return getmetatable(bag) == ListMenu and bag or nil + end + + local function toTop(bag) + for _ = 1, #bag.items + 2 do + if bag.index == 1 then break end + U.tap(game, "up") + U.wait(6) + end + end + + U.teleport(game, TOWN, TOWN_X, TOWN_Y, "down") + U.wait(20) + + local bag = openBag("in Pallet Town") + if check("the bag opened", bag ~= nil) then + check("six items are in it", #bag.items == #STOCK + 1) + local last = bag.items[#bag.items] + check("the row after the last item is the terminator's CANCEL", + last ~= nil and last.cancel == true) + check("and it carries no item id", last ~= nil and last.value == nil) + check("labelled CANCEL", last ~= nil and last.label == Strings("CANCEL")) + + toTop(bag) + check("page one shot reached disk", + U.shot(game, SHOT_DIR .. "/bug1685_page_one.png")) + + -- walk down until the cursor stops moving: it must stop ON the CANCEL + -- row, not one row short of it + local before + for _ = 1, #bag.items + 4 do + before = bag.index + U.tap(game, "down") + U.wait(6) + if bag.index == before then break end + end + check("the cursor walked all the way onto the CANCEL row", + bag.items[bag.index] ~= nil and bag.items[bag.index].cancel == true) + check("and stops there", bag.index == #bag.items) + check("with the list scrolled to its last page", + bag.scroll == #bag.items - (bag.cursorRows or bag.rows)) + check("cancel row shot reached disk", + U.shot(game, SHOT_DIR .. "/bug1685_cancel_row.png")) + + -- A on CANCEL is ExitListMenu: the same exit B takes + U.tap(game, "a") + U.wait(25) + local stillUp = false + for _, s in ipairs(game.stack.states) do + if s == bag then stillUp = true end + end + check("A on CANCEL closed the item list", not stillUp) + check("and left no box or submenu over it", + getmetatable(game.stack:top()) ~= ListMenu) + check("after-cancel shot reached disk", + U.shot(game, SHOT_DIR .. "/bug1685_after_cancel.png")) + end + + for _ = 1, 10 do + if game.stack:top() == game.overworld then break end + U.tap(game, "b") + U.wait(12) + end + + -- an empty bag is a box with CANCEL alone, which is what the cart shows; + -- the port used to print an invented "Nothing here." instead + game.save.inventory = {} + game.save.bagOrder = nil + local empty = openBag("with an empty bag") + if check("the empty bag still opens a box", empty ~= nil) then + check("holding exactly one row", #empty.items == 1) + check("and that row is CANCEL", + empty.items[1] ~= nil and empty.items[1].cancel == true) + check("empty bag shot reached disk", + U.shot(game, SHOT_DIR .. "/bug1685_empty_bag.png")) + end + + U.log("") + if ok then + U.log("the bag has been opened, walked to the bottom and cancelled for you.") + U.log("in bug1685_page_one.png the four item names fill the box and the") + U.log("down arrow sits in the lower right corner. in bug1685_cancel_row.png") + U.log("CANCEL is one row below the last item, at the same left edge as the") + U.log("names, with the cursor on it -- and that corner arrow must be GONE.") + U.log("an arrow still painted next to CANCEL is the near miss here: it is") + U.log("easy to miss in a still and it is the exact thing the terminator's") + U.log("tail jump settles.") + U.log("bug1685_empty_bag.png is an empty bag: a box with CANCEL and nothing") + U.log("else. that replaces the port's old \"Nothing here.\" line, which the") + U.log("cart never printed, so it is a deliberate change, not a regression.") + U.log("the empty bag is on screen now; A or B should both close it.") + U.log("yellow shares this list code: rerun with POKEPORT_VERSION=yellow and") + U.log("a yellow identity and the three shots should look the same.") + else + U.log("a check above failed, so nothing on screen is worth reading yet.") + end + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/bike_dismount_bug513_test.lua b/tests/drivers/bike_dismount_bug513_test.lua index e9994a11..259d986f 100644 --- a/tests/drivers/bike_dismount_bug513_test.lua +++ b/tests/drivers/bike_dismount_bug513_test.lua @@ -160,9 +160,8 @@ return function(game) if check("the bag opened", isBag) then if stepTo(bag, function(b) return b.items[b.index].value == "BICYCLE" end, "BICYCLE") then - U.tap(game, "a") -- USE / TOSS submenu - U.wait(20) - U.tap(game, "a") -- USE is the first row + -- the BICYCLE skips USE/TOSS (start_sub_menus.asm:340-342) + U.tap(game, "a") U.wait(30) end end diff --git a/tests/drivers/bike_option_box_bug1705_test.lua b/tests/drivers/bike_option_box_bug1705_test.lua new file mode 100644 index 00000000..194db22d --- /dev/null +++ b/tests/drivers/bike_option_box_bug1705_test.lua @@ -0,0 +1,342 @@ +-- Eye check that the BICYCLE never shows the USE/TOSS box (#1705). +-- StartMenu_Item .choseItem does `cp BICYCLE / jp z, .useOrTossItem` before +-- it loads USE_TOSS_MENU_TEMPLATE (engine/menus/start_sub_menus.asm:340-342), +-- so mount, dismount and the Cycling Road refusal all land on one A press. +-- No POKEPORT_SPEED: it scales only the logic clock, and the frame the box +-- would flash on is the whole point of this run. +-- POKEPORT_DRIVER=tests/drivers/bike_option_box_bug1705_test.lua POKEPORT_IDENTITY=bug1705 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pokemon = require("src.pokemon.Pokemon") + local TextBox = require("src.render.TextBox") + local ListMenu = require("src.ui.ListMenu") + local Menu = require("src.ui.Menu") + local Screens = require("src.ui.Screens") + local Strings = require("src.core.Strings") + + local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local ok = true + local function check(label, pass) + U.log(pass and "PASS" or "FAIL", label) + if not pass then ok = false end + return pass + end + + -- pokered data/maps/objects/PalletTown.asm: the house doors sit at (5,5) + -- and (13,5) and the lab's at (12,11), so (10,8) is open ground. OVERWORLD + -- tileset, so IsBikeRidingAllowed says yes here (home/overworld.asm). + local TOWN, TOWN_X, TOWN_Y = "PALLET_TOWN", 10, 8 + -- pokered data/maps/force_bike_surf.asm: ROUTE_16 (17,11) is a landing cell + -- out of the Route 16 gate's south door and arms BIT_ALWAYS_ON_BIKE. + local FORCE_MAP, FORCE_X, FORCE_Y = "ROUTE_16", 17, 11 + -- pokered data/maps/objects/Route16Gate1F.asm: warps on x 0 and 7, the + -- guard on (4,5), so (3,6) is plain floor -- and the gate script is what + -- clears the flag again (scripts/Route16Gate1F.asm `res BIT_ALWAYS_ON_BIKE`) + local GATE_MAP, GATE_X, GATE_Y = "ROUTE_16_GATE_1F", 3, 6 + + -- a missing item def, an unextracted refusal line and a bag that never + -- opens all look like the bug from the couch + check("BICYCLE exists as an item", game.data.items.BICYCLE ~= nil) + local cannot = game.data.text._CannotGetOffHereText + check("_CannotGetOffHereText was extracted", + type(cannot) == "string" and cannot:find("get off", 1, true) ~= nil) + check("POTION exists as the control item", game.data.items.POTION ~= nil) + check("renderer is up", game.renderer ~= nil) + + game.save.player.name = "SEBAS" + game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) } + game.save.inventory = game.save.inventory or {} + game.save.inventory.BICYCLE = 1 + game.save.inventory.POTION = 3 + game.save.onBike = false + game.save.forcedBike = nil + + local function stationary() + for _ = 1, 90 do + game.input.state.b = true + if not (game.overworld and game.overworld.player.moving) then break end + coroutine.yield() + end + game.input.state.b = false + end + + -- START -> ITEM -> the bag, by real presses. Route 16/17 roll the player + -- south whenever nothing is held, so brake into a standstill first or + -- handleInput eats the START. + local function openBag(where) + stationary() + U.tap(game, "start") + U.wait(20) + local menu = game.stack:top() + if getmetatable(menu) == Menu then + local ITEM = Strings("ITEM") + for _ = 1, 20 do + if menu.items[menu.index].label == ITEM then break end + U.tap(game, "down") + U.wait(6) + end + U.tap(game, "a") + U.wait(20) + end + local bag = game.stack:top() + if getmetatable(bag) ~= ListMenu then + -- a start menu that reordered itself would otherwise strand the run; + -- say so, then get to the moment anyway + U.log("START -> ITEM did not reach the bag on " .. where + .. ", pushing BagMenu directly") + bag = Screens.push(game, "BagMenu") + U.wait(20) + end + return getmetatable(bag) == ListMenu and bag or nil + end + + -- the cursor comes back on the last-used row (#1732), so walk to the top + -- before walking down or a row above the saved one is unreachable + local function cursorTo(bag, id) + for _ = 1, #bag.items do + if bag.index == 1 then break end + U.tap(game, "up") + U.wait(5) + end + for _ = 1, #bag.items do + local row = bag.items[bag.index] + if row and row.value == id then return true end + U.tap(game, "down") + U.wait(5) + end + return bag.items[bag.index] and bag.items[bag.index].value == id + end + + -- StartMenu_Item keeps the START menu up behind the bag, and that is a + -- Menu too, so only a Menu that was NOT already there counts as the + -- option box + local function menuSnapshot() + local seen = {} + for _, s in ipairs(game.stack.states) do seen[s] = true end + return seen + end + + local function newMenu(before) + for _, s in ipairs(game.stack.states) do + if getmetatable(s) == Menu and not before[s] then return true end + end + return false + end + + -- press A and watch every frame after it: a USE/TOSS box that appears and + -- is replaced two frames later is invisible in a still, and is the near + -- miss this whole run exists to catch + local function chooseAndWatch(frames) + local before = menuSnapshot() + U.tap(game, "a") + local flashed = newMenu(before) + for _ = 1, frames or 30 do + if newMenu(before) then flashed = true end + coroutine.yield() + end + return flashed, before + end + + local function boxSays() + local top = game.stack:top() + if getmetatable(top) ~= TextBox then return nil end + local lines = {} + for _, page in ipairs(top.pages or {}) do + for _, line in ipairs(page) do lines[#lines + 1] = line end + end + return table.concat(lines, " / ") + end + + local function dismissBox() + for _ = 1, 8 do + if getmetatable(game.stack:top()) ~= TextBox then break end + U.tap(game, "b") + U.wait(12) + end + end + + local function backToOverworld() + for _ = 1, 10 do + if game.stack:top() == game.overworld then break end + U.tap(game, "b") + U.wait(12) + end + end + + -- the control: an ordinary item DOES open the box, so a run where nothing + -- ever appears cannot be mistaken for the fix working + U.teleport(game, TOWN, TOWN_X, TOWN_Y, "down") + U.wait(20) + local bag = openBag("Pallet Town") + if check("the bag opened in " .. TOWN, bag ~= nil) then + if check("found the POTION row", cursorTo(bag, "POTION")) then + U.tap(game, "a") + U.wait(20) + check("a POTION still opens USE/TOSS", getmetatable(game.stack:top()) == Menu) + check("control shot reached disk", + U.shot(game, SHOT_DIR .. "/bug1705_potion_usetoss.png")) + U.tap(game, "b") + U.wait(15) + end + end + + -- mount: one press, straight to the text + if bag and check("found the BICYCLE row", cursorTo(bag, "BICYCLE")) then + local flashed, before = chooseAndWatch(4) + check("no option box in the first frames after A (mount)", not flashed) + check("early shot reached disk", + U.shot(game, SHOT_DIR .. "/bug1705_mount_first_frames.png")) + U.wait(60) + check("still no option box while the text types", not newMenu(before)) + local said = boxSays() + U.log("the box reads:", tostring(said)) + check("the single press mounted the bike", game.save.onBike == true) + check("and the line is the mount text", + said ~= nil and said:find("got on", 1, true) ~= nil) + check("mount shot reached disk", U.shot(game, SHOT_DIR .. "/bug1705_mount.png")) + end + dismissBox() + backToOverworld() + + -- dismount: the same one press + bag = openBag("Pallet Town (riding)") + if bag and check("found the BICYCLE row again", cursorTo(bag, "BICYCLE")) then + local flashed = chooseAndWatch(4) + check("no option box in the first frames after A (dismount)", not flashed) + U.wait(60) + local said = boxSays() + U.log("the box reads:", tostring(said)) + check("the single press dismounted", game.save.onBike == false) + check("and the line is the dismount text", + said ~= nil and said:find("got off", 1, true) ~= nil) + check("dismount shot reached disk", + U.shot(game, SHOT_DIR .. "/bug1705_dismount.png")) + end + dismissBox() + backToOverworld() + + -- the Cycling Road refusal, armed the way the game arms it: by arriving on + -- the forced-bike cell (setMap runs checkForcedMovement on entry) + game.save.onBike = false + U.teleport(game, FORCE_MAP, FORCE_X, FORCE_Y, "down") + U.wait(25) + check("stepping onto the Route 16 cell mounts the bike", game.save.onBike == true) + check("and arms the forced-bike flag", game.save.forcedBike == true) + bag = openBag("Route 16") + if bag and check("found the BICYCLE row on the road", cursorTo(bag, "BICYCLE")) then + local flashed = chooseAndWatch(4) + check("no option box in the first frames after A (refusal)", not flashed) + U.wait(60) + local said = boxSays() + U.log("the box reads:", tostring(said)) + check("the refusal printed, not the dismount", + said ~= nil and said:find("get off", 1, true) ~= nil) + check("the player is still riding", game.save.onBike == true) + local stillUp = false + for _, s in ipairs(game.stack.states) do + if s == bag then stillUp = true end + end + check("and the bag list is still on the stack under the box (#513)", stillUp) + check("refusal shot reached disk", + U.shot(game, SHOT_DIR .. "/bug1705_forced_refusal.png")) + end + dismissBox() + backToOverworld() + + -- walking into the gate releases the flag; the gate's tileset is not one of + -- IsBikeRidingAllowed's (home/overworld.asm), so the map change dismounts on + -- the way in and the release has to be shown back out on the road + U.teleport(game, GATE_MAP, GATE_X, GATE_Y, "up") + U.wait(25) + check("the gate clears the forced-bike flag", + game.save.forcedBike == nil or game.save.forcedBike == false) + + -- a plain Route 16 cell: walkable, nobody on it, and NOT one of the two + -- forced-bike tiles, so nothing re-arms the flag when we land + local function plainRoadCell() + local ow = game.overworld + local fm = game.data.field.forcedMovement + local forced = {} + for _, t in ipairs((fm and fm.tiles and fm.tiles[FORCE_MAP]) or {}) do + forced[t.x .. "," .. t.y] = true + end + if not ow then return nil end + for y = 0, 35 do + for x = 0, 19 do + if not forced[x .. "," .. y] and ow.map:inBounds(x, y) + and ow.map:isWalkableCell(x, y) and not ow:npcAtCell(x, y) then + return x, y + end + end + end + return nil + end + + U.teleport(game, FORCE_MAP, FORCE_X, FORCE_Y - 3, "up") + U.wait(20) + local px, py = plainRoadCell() + if px then + U.teleport(game, FORCE_MAP, px, py, "up") + U.wait(20) + end + check("back on Route 16, off the forced tiles, flag still clear", + game.save.forcedBike == nil or game.save.forcedBike == false) + + -- with the flag released the bike answers again: mount, then get off, one + -- press each, and the get-off is the very thing the road refused + if not game.save.onBike then + bag = openBag("Route 16 (released)") + if bag and check("found the BICYCLE row to remount", cursorTo(bag, "BICYCLE")) then + local flashed = chooseAndWatch(4) + check("no option box on the remount", not flashed) + U.wait(60) + U.log("the box reads:", tostring(boxSays())) + check("the remount worked", game.save.onBike == true) + end + dismissBox() + backToOverworld() + end + + bag = openBag("Route 16 (getting off)") + if bag and check("found the BICYCLE row again on the road", + cursorTo(bag, "BICYCLE")) then + local flashed = chooseAndWatch(4) + check("no option box on the released dismount", not flashed) + U.wait(60) + local said = boxSays() + U.log("the box reads:", tostring(said)) + check("the flag really is released: this one gets off", + game.save.onBike == false) + check("and the line is the dismount text, not the refusal", + said ~= nil and said:find("got off", 1, true) ~= nil) + check("released dismount shot reached disk", + U.shot(game, SHOT_DIR .. "/bug1705_released_dismount.png")) + end + + check("the BICYCLE was never consumed", (game.save.inventory.BICYCLE or 0) == 1) + + U.log("") + if ok then + U.log("the bike has been used five times for you: mounted and dismounted in") + U.log("PALLET TOWN, refused on the Route 16 forced stretch, then mounted and") + U.log("taken off again after the gate released the flag. every one of those") + U.log("was a single A press on the BICYCLE row, with no USE/TOSS box.") + U.log("shots are in " .. SHOT_DIR .. ": bug1705_potion_usetoss.png is the") + U.log("control, the box a POTION still gets. bug1705_mount_first_frames.png") + U.log("is two frames after the A press on the bike -- that one must show") + U.log("the mount text or a bare map, never a small box in the lower right.") + U.log("the near miss is a box that appears and is replaced before the text") + U.log("types, which reads as a pass in every later shot.") + U.log("you are on Route 16 with the bag closed; open it and try the bike as") + U.log("often as you like. it also has no TOSS any more, since the box that") + U.log("carried it is the one that no longer opens.") + U.log("yellow shares this code path: rerun with POKEPORT_VERSION=yellow") + U.log("and a yellow identity, and every beat should read the same.") + else + U.log("a check above failed, so nothing on screen is worth reading yet.") + end + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/crystal_battle_tower_shots.lua b/tests/drivers/crystal_battle_tower_shots.lua new file mode 100644 index 00000000..1db2af83 --- /dev/null +++ b/tests/drivers/crystal_battle_tower_shots.lua @@ -0,0 +1,173 @@ +-- The Battle Tower lobby, driven in a real game. +-- +-- POKEPORT_IDENTITY=lead-card POKEPORT_GAME=crystal POKEPORT_VERSION=crystal \ +-- POKEPORT_SHOT_DIR=/tmp/tower \ +-- POKEPORT_DRIVER=tests/drivers/crystal_battle_tower_shots.lua love . +-- +-- Three sections, all on maps/BattleTower1F.asm's own extracted script: +-- A an illegal party, so _CheckForBattleTowerRules prints its refusals +-- B a legal party through Menu_ChallengeExplanationCancel and the room menu +-- C the walk to the elevator the chosen room starts +-- +-- Section B overrides the TryQuickSave stub for the length of the run: +-- src/script/gen2/Specials.lua:2516 answers 0, and BattleTower1F.asm:84-85 +-- backs out of the whole challenge on that, so the desk cannot be walked +-- past without it. +local U = require("tests.drivers.util") + +local BattleTower = require("src.core.gen2.BattleTower") +local BattleTowerMenu = require("src.ui.gen2.BattleTowerMenu") +local GameVersion = require("src.core.GameVersion") +local Mon = require("src.battle.gen2.Mon") +local ScriptMenu = require("src.ui.gen2.ScriptMenu") +local Vm = require("src.script.gen2.Vm") + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/crystal-tower" + local fails, shots = 0, 0 + + local function say(line) print("[driver] " .. line) end + local function ok(cond, line) + if not cond then fails = fails + 1 end + say((cond and "OK " or "FAIL ") .. line) + end + local function shot(name) + shots = shots + 1 + U.shot(game, ("%s/%02d-%s.png"):format(out, shots, name)) + end + local function tap(button, frames) + game.input.pressQueue[#game.input.pressQueue + 1] = button + game.input.state[button] = true + U.wait(2) + game.input.state[button] = false + U.wait(frames or 4) + end + local function top() return game.stack:top() end + local function isMenu() return getmetatable(top()) == ScriptMenu end + local function isRoomMenu() return getmetatable(top()) == BattleTowerMenu end + + U.wait(60) + local world = game.world + assert(world and world.map, "crystal world did not boot") + say("version=" .. GameVersion.get() .. " engine=" .. GameVersion.engine()) + + local function party(spec) + local list = {} + for _, row in ipairs(spec) do + local m = Mon.new(game.data, row[1], row[2]) + m.item = row[3] + list[#list + 1] = m + end + game.save.party = list + end + + local function enterLobby() + while top() do tap("b", 2) end + assert(world:setMap("BATTLE_TOWER_1F", 7, 7, "up"), "no BATTLE_TOWER_1F") + U.wait(40) + end + + -- maps/BattleTower1F.asm:55-73: A through the welcome, NO to the + -- explanation offer, and the menu is the next command. + local function talkToMenu(limit) + for _ = 1, limit or 200 do + if isMenu() then return true end + if world.choicebox then tap("b", 4) else tap("a", 4) end + end + return false + end + + say("--- A: an illegal party at the desk") + party({ { "TYPHLOSION", 20 } }) + enterLobby() + shot("lobby") + ok(talkToMenu(), "the receptionist reaches Menu_ChallengeExplanationCancel") + shot("challenge-menu") + ok(isMenu() and #top().items == 3, "three rows: Challenge/Explanation/Cancel") + tap("a", 6) + + local pages = {} + for _ = 1, 40 do + local body = world.lastText + if type(body) == "string" and body ~= "" and pages[#pages] ~= body then + pages[#pages + 1] = body + shot("rules-" .. #pages) + end + if not world:busy() and top() == nil then break end + tap("a", 4) + end + local joined = table.concat(pages, " | ") + say("pages: " .. joined) + ok(joined:find("You're not ready", 1, true) ~= nil, + "_ExcuseMeYoureNotReadyText printed") + ok(joined:find("Only three", 1, true) ~= nil, + "_OnlyThreeMonMayBeEnteredText printed") + ok(joined:find("Please return when", 1, true) ~= nil, + "_BattleTowerReturnWhenReadyText printed") + + say("--- B: a legal party, the room menu, the walk out") + local savedQuickSave = Vm.SPECIALS.TryQuickSave + Vm.SPECIALS.TryQuickSave = function(vm) vm.scriptVar = 1 end + + party({ { "TYPHLOSION", 20 }, { "FERALIGATR", 20, "BERRY" }, + { "MEGANIUM", 20, "GOLD_BERRY" } }) + enterLobby() + ok(talkToMenu(), "the desk reaches the menu again") + tap("a", 6) + + local sawSavePrompt = false + for _ = 1, 120 do + if isRoomMenu() then break end + if world.choicebox then + sawSavePrompt = true + shot("save-prompt") + tap("a", 6) + else + tap("a", 4) + end + end + ok(sawSavePrompt, "Text_SaveBeforeEnteringBattleRoom asked first") + ok(isRoomMenu(), "special BattleTowerRoomMenu pushed Gen2BattleTowerMenu") + shot("room-menu") + + if isRoomMenu() then + local screen = top() + ok(#screen.rows == BattleTower.PRE_HOF_LEVEL_GROUPS, + "no Hall of Fame, so four rooms are offered") + -- L:10 with an L20 party is the refusal at mobile_46.asm:3915-3917. + tap("a", 6) + ok(screen.phase == "message", "picking L:10 prints the level refusal") + shot("tops-this-level") + for _ = 1, 150 do + if screen.phase == "pick" then break end + U.wait(1) + end + ok(screen.phase == "pick", "and the menu comes back after the hold") + tap("up", 6) + shot("room-menu-l20") + tap("a", 6) + end + + for _ = 1, 240 do + if not world:busy() and top() == nil then break end + tap("a", 4) + end + local tower = BattleTower.state(game.save) + ok(tower.levelGroup ~= nil, "the level group survived the menu") + say("levelGroup=" .. tostring(game.world.vm and game.world.vm.btLevelGroup) + .. " reward=" .. tostring(tower.reward) + .. " challenge=" .. tostring(tower.challenge) + .. " map=" .. tostring(world.map and world.map.id)) + ok(tower.reward ~= nil, + "BATTLETOWERACTION_CHOOSEREWARD banked a prize on the way out") + shot("after-desk") + + U.wait(180) + shot("elevator-or-hallway") + say("map after the walk: " .. tostring(world.map and world.map.id)) + + Vm.SPECIALS.TryQuickSave = savedQuickSave + + say(fails == 0 and "PASS" or (fails .. " FAILURES")) + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/drivers/crystal_boot_smoke.lua b/tests/drivers/crystal_boot_smoke.lua new file mode 100644 index 00000000..2d8d8fe3 --- /dev/null +++ b/tests/drivers/crystal_boot_smoke.lua @@ -0,0 +1,249 @@ +-- Smoke: the whole Crystal boot chain, with no driver shortcut. +-- +-- POKEPORT_GAME=crystal POKEPORT_BOOT_CINEMA=1 \ +-- POKEPORT_SHOT_DIR= \ +-- POKEPORT_DRIVER=tests/drivers/crystal_boot_smoke.lua love . +-- +-- copyright -> GameFreak -> Crystal intro -> title -> intro menu -> NEW GAME +-- -> clock -> Oak speech -> name pick -> naming screen -> the bedroom -> +-- start menu -> outdoors -> a wild battle. Every step is asserted by the +-- class of the state on the stack, so a broken hand-off names the screen it +-- stalled on instead of just hanging. +local U = require("tests.drivers.util") + +local CopyrightSplash = require("src.ui.gen2.CopyrightSplash") +local CrystalIntro = require("src.ui.gen2.CrystalIntro") +local GameFreakPresents = require("src.ui.gen2.GameFreakPresents") +local GenderSelect = require("src.ui.gen2.GenderSelect") +local InitClock = require("src.ui.gen2.InitClock") +local MainMenu = require("src.ui.gen2.MainMenu") +local NamePick = require("src.ui.gen2.NamePick") +local NamingScreen = require("src.ui.gen2.NamingScreen") +local OakSpeech = require("src.ui.gen2.OakSpeech") +local OptionsMenu = require("src.ui.gen2.OptionsMenu") +local StartMenu = require("src.ui.gen2.StartMenu") +local TitleState = require("src.ui.gen2.TitleState") + +local GameVersion = require("src.core.GameVersion") +local Mon = require("src.battle.gen2.Mon") + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/crystal-boot" + local log = io.open(os.getenv("POKEPORT_BOOT_LOG") + or (out .. "/boot.log"), "w") + + local function say(line) + print("[driver] " .. line) + if log then log:write(line .. "\n"); log:flush() end + end + + local function bail(reason) + say("FAIL " .. reason) + if log then log:close() end + love.event.quit(1) + error(reason, 0) + end + + local function top() + return game.stack:top() + end + + local function isA(class) + local state = top() + return state ~= nil and getmetatable(state) == class + end + + local function tap(button, frames) + game.input.pressQueue[#game.input.pressQueue + 1] = button + game.input.state[button] = true + U.wait(2) + game.input.state[button] = false + U.wait(frames or 4) + end + + local function waitFor(label, predicate, frames) + for _ = 1, frames or 900 do + if predicate() then return end + U.wait(1) + end + bail(("stalled waiting for %s (top is %s)"):format(label, tostring(top()))) + end + + local function pick(menu, value) + for i, item in ipairs(menu.list.items) do + if item.value == value then menu.list.index = i end + end + end + + if GameVersion.get() ~= "crystal" then + bail("booted " .. tostring(GameVersion.get()) .. ", not crystal") + end + if GameVersion.engine() ~= "crystal" then + bail("engine lineage is " .. tostring(GameVersion.engine())) + end + say("version=" .. GameVersion.get() .. " engine=" .. GameVersion.engine()) + + U.wait(10) + if not isA(CopyrightSplash) then + bail("boot did not start at the copyright splash (top " + .. tostring(top()) .. ")") + end + say("OK copyright") + U.shot(game, out .. "/01-copyright.png") + + waitFor("GameFreak presents", function() return isA(GameFreakPresents) end) + U.wait(90) + say("OK gamefreak") + U.shot(game, out .. "/02-gamefreak.png") + + -- ../pokecrystal/engine/movie/intro.asm:1 + waitFor("the Crystal intro", function() return isA(CrystalIntro) end) + U.wait(240) + say("OK crystal intro") + U.shot(game, out .. "/03-intro.png") + U.wait(240) + U.shot(game, out .. "/03b-intro.png") + tap("start") + + waitFor("the title screen", function() return isA(TitleState) end) + U.wait(90) + say("OK title") + U.shot(game, out .. "/04-title.png") + for _ = 1, 3 do tap("start", 3) end + + waitFor("the intro menu", function() return isA(MainMenu) end) + say("OK main menu") + U.shot(game, out .. "/05-mainmenu.png") + + local menu = top() + pick(menu, "option") + tap("a") + waitFor("the options screen", function() return isA(OptionsMenu) end) + U.shot(game, out .. "/06-options.png") + tap("b") + waitFor("the intro menu again", function() return isA(MainMenu) end) + + menu = top() + pick(menu, "new") + U.shot(game, out .. "/07-newgame.png") + tap("a") + + -- ../pokecrystal/engine/menus/init_gender.asm:23 InitGender, which + -- PlayerProfileSetup runs first (engine/menus/intro_menu.asm:80-84). + waitFor("the gender screen", function() return isA(GenderSelect) end, 300) + say("OK gender select") + U.shot(game, out .. "/07b-gender.png") + tap("a") + + -- ../pokecrystal/engine/menus/intro_menu.asm:628 + waitFor("the clock screen", function() return isA(InitClock) end, 300) + say("OK init clock") + U.shot(game, out .. "/08-initclock.png") + for _ = 1, 60 do + if isA(OakSpeech) then break end + tap("a", 2) + end + waitFor("the Oak speech", function() return isA(OakSpeech) end, 300) + local oak = top() + say("OK oak speech, demo mon = " .. tostring(oak.demoSpecies)) + if oak.demoSpecies ~= "WOOPER" then + bail("Oak's demo mon is " .. tostring(oak.demoSpecies) + .. ", Crystal's is WOOPER") + end + U.wait(60) + U.shot(game, out .. "/09-oak.png") + + local shotDemo = false + for _ = 1, 400 do + if isA(NamePick) then break end + if not shotDemo and isA(OakSpeech) and top().pic == top().marillPic then + U.wait(45) + U.shot(game, out .. "/10-wooper.png") + shotDemo = true + end + tap("a", 2) + end + if not isA(NamePick) then + bail("Oak speech never reached the name picker (top " + .. tostring(top()) .. ")") + end + say("OK name pick" .. (shotDemo and " (demo pic captured)" or "")) + -- ../pokecrystal/engine/menus/intro_menu.asm:738 NamePlayer + waitFor("the name menu to slide in", + function() return top().slide == nil end, 240) + U.shot(game, out .. "/11-namepick.png") + + local picker = top() + picker.cursor = 1 + tap("a") + waitFor("the naming screen", function() return isA(NamingScreen) end) + local naming = top() + tap("a") + tap("right") + tap("a") + if naming.text ~= "AB" then + bail("typed name is " .. tostring(naming.text) .. ", expected AB") + end + say("OK naming screen") + U.shot(game, out .. "/12-naming.png") + naming.row = naming:bottomRow() + naming.col = 6 + tap("a") + + for _ = 1, 600 do + if game.phase == "play" and game.world and game.world.map then break end + tap("a", 2) + end + if not (game.phase == "play" and game.world and game.world.map) then + bail("never reached the overworld (top is " .. tostring(top()) .. ")") + end + if game.save.player.name ~= "AB" then + bail("player name is " .. tostring(game.save.player.name)) + end + if game.world.map.id ~= "PLAYERS_HOUSE_2F" then + bail("new game started on " .. tostring(game.world.map.id) + .. ", expected the bedroom") + end + say("OK overworld, map = " .. game.world.map.id + .. ", name = " .. tostring(game.save.player.name)) + U.wait(20) + U.shot(game, out .. "/13-bedroom.png") + + tap("start") + waitFor("the start menu", function() return isA(StartMenu) end) + say("OK start menu") + U.shot(game, out .. "/14-startmenu.png") + tap("b") + waitFor("the overworld again", function() return top() == nil end) + + if not game.world:setMap("NEW_BARK_TOWN", 13, 6, "down") then + bail("could not load NEW_BARK_TOWN") + end + U.wait(30) + if game.world.map.id ~= "NEW_BARK_TOWN" then + bail("setMap landed on " .. tostring(game.world.map.id)) + end + local tileset = game.world.map.tileset or {} + say("OK outdoors, tileset = " .. tostring(tileset.id) + .. ", tilePalettes = " .. tostring(#(tileset.tilePalettes or {}))) + U.shot(game, out .. "/15-newbark.png") + + local player = Mon.new(game.data, "CYNDAQUIL", 12) + if not (player and #player.moves > 0) then + bail("could not build a CYNDAQUIL from the Crystal pokemon.lua") + end + local wild = Mon.new(game.data, "WOOPER", 5) + if not wild then bail("could not build a wild WOOPER") end + game.save.party = { player } + if not game.world:startBattle({ wild = wild }) then + bail("startBattle failed") + end + U.wait(240) + say("OK battle, " .. player.species .. " L" .. player.level + .. " vs " .. wild.species .. " L" .. wild.level) + U.shot(game, out .. "/16-battle.png") + + say("PASS crystal boot chain in " .. out) + if log then log:close() end + love.event.quit(0) +end diff --git a/tests/drivers/crystal_f2_save_and_vars.lua b/tests/drivers/crystal_f2_save_and_vars.lua new file mode 100644 index 00000000..bb3e0113 --- /dev/null +++ b/tests/drivers/crystal_f2_save_and_vars.lua @@ -0,0 +1,227 @@ +-- TryQuickSave and the Crystal script VARs, in a real Crystal game. +-- +-- POKEPORT_IDENTITY=f2-crystal POKEPORT_GAME=crystal POKEPORT_VERSION=crystal \ +-- POKEPORT_SHOT_DIR=/tmp/f2 \ +-- POKEPORT_DRIVER=tests/drivers/crystal_f2_save_and_vars.lua love . +-- +-- A ../pokecrystal/maps/BattleTower1F.asm:80-86 with NO monkeypatch: the +-- desk's own `writetext / yesorno`, then `special TryQuickSave` -- the +-- overwrite prompt, the SAVING pages and a real file on disk -- and the +-- room menu it can only reach on a TRUE. +-- B ../pokecrystal/maps/RadioTower2F.asm:135-146: the correct password's +-- `readvar / addval 1 / writevar VAR_BLUECARDBALANCE`, through the real +-- World, over a save and a reload, and then BuenaPrize spending it. +local U = require("tests.drivers.util") + +local BattleTowerMenu = require("src.ui.gen2.BattleTowerMenu") +local BuenaPassword = require("src.ui.gen2.BuenaPassword") +local Mon = require("src.battle.gen2.Mon") +local Save = require("src.core.gen2.Save") +local ScriptMenu = require("src.ui.gen2.ScriptMenu") +local Specials = require("src.script.gen2.Specials") + +local VAR_BLUECARDBALANCE = 0x18 +local VAR_BUENASPASSWORD = 0x19 + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/crystal-f2" + local fails, shots = 0, 0 + + local function say(line) + print("[driver] " .. line) + io.stdout:flush() + end + local function ok(cond, line) + if not cond then fails = fails + 1 end + say((cond and "OK " or "FAIL ") .. line) + end + local function shot(name) + shots = shots + 1 + U.shot(game, ("%s/%02d-%s.png"):format(out, shots, name)) + end + local function tap(button, frames) + game.input.pressQueue[#game.input.pressQueue + 1] = button + game.input.state[button] = true + U.wait(2) + game.input.state[button] = false + U.wait(frames or 4) + end + local function top() return game.stack:top() end + + U.wait(60) + local world = game.world + assert(world and world.map, "crystal world did not boot") + local save = game.save + + ok(Specials.STUB_REASONS.TryQuickSave == nil, + "TryQuickSave is a handler, not a stub") + ok(Specials.STUB_REASONS.SampleKenjiBreakCountdown == nil, + "SampleKenjiBreakCountdown is a handler too") + + --------------------------------------------------------------- section B + say("--- B: Buena's Blue Card, over a save and a reload") + + ok(world:setMap("RADIO_TOWER_2F", 5, 5, "down") == true, "on RADIO_TOWER_2F") + U.wait(30) + + local crystal = Save.crystalState(save) + crystal.buenaPassword.balance = 0 + crystal.buenaPassword.word = 0x00 + crystal.buenaPassword.day = nil + + -- The three rows the correct-answer arm runs, straight off the extracted + -- opcode table, so the round trip is World:readVar -> Vm -> World:writeVar. + local function awardPoint() + local started = world.vm:start({ + { op = "readvar", var = VAR_BLUECARDBALANCE }, + { op = "addval", args = { 1 } }, + { op = "writevar", var = VAR_BLUECARDBALANCE }, + }) + ok(started == true, "the award rows started (vm idle)") + for _ = 1, 120 do + if not world.vm:running() then break end + U.wait(1) + end + end + for _ = 1, 3 do awardPoint() end + say("blue card balance: " .. tostring(world:readVar(VAR_BLUECARDBALANCE))) + ok(world:readVar(VAR_BLUECARDBALANCE) == 3, + "three correct passwords are three points") + ok(crystal.buenaPassword.balance == 3, "and the points are in save.crystal") + + -- The password itself is a RETVAR_ADDR_DE row too; BuenasPassword parks the + -- day's roll there through the same writevar path. + world:writeVar(VAR_BUENASPASSWORD, 0x24) + ok(world:readVar(VAR_BUENASPASSWORD) == 0x24, "wBuenasPassword reads back") + + -- The map load that used to eat it: scriptVars is rebuilt empty at + -- World.lua:579. + ok(world:setMap("RADIO_TOWER_1F", 5, 5, "down") == true, "walked downstairs") + U.wait(20) + ok(world:readVar(VAR_BLUECARDBALANCE) == 3, "a map load does not eat it") + + ok(game:writeSave() ~= false, "saved with the balance on it") + local reloaded = Save.load(save.version) + local reloadedBalance = reloaded and reloaded.crystal + and reloaded.crystal.buenaPassword + and reloaded.crystal.buenaPassword.balance + say("reloaded blue card balance: " .. tostring(reloadedBalance)) + ok(reloadedBalance == 3, "and it survives a save and a reload") + ok(reloaded and reloaded.crystal.buenaPassword.word == 0x24, + "so does the password") + + -- BuenaPrize, which could never dispense anything on a balance of 0. + -- data/items/buena_prizes.asm's first row is ULTRA_BALL at 2 points. + save.inventory = save.inventory or {} + save.inventory.ULTRA_BALL = nil + local prizeIndex + for index, name in ipairs(world.vm.specialOrder or {}) do + if name == "BuenaPrize" then prizeIndex = index - 1 break end + end + ok(prizeIndex ~= nil, "BuenaPrize is in constants.specialOrder") + world.vm:start({ { op = "special", id = prizeIndex } }) + local sawPrizeMenu = false + for _ = 1, 300 do + if not world.vm:running() and top() == nil then break end + if getmetatable(top()) == BuenaPassword then + if not sawPrizeMenu then + sawPrizeMenu = true + say("prize menu balance shown: " .. tostring(top().balance)) + shot("prize-menu") + tap("a", 6) -- ULTRA BALL + else + tap("b", 6) -- second pass: leave the shop + end + else + tap("a", 4) + end + end + ok(sawPrizeMenu, "BuenaPrize opened its list") + say("ULTRA_BALL x" .. tostring(save.inventory.ULTRA_BALL) + .. " balance=" .. tostring(world:readVar(VAR_BLUECARDBALANCE))) + ok((save.inventory.ULTRA_BALL or 0) >= 1, "the prize was handed over") + ok(world:readVar(VAR_BLUECARDBALANCE) == 1, "and two points were spent") + shot("after-prize") + + --------------------------------------------------------------- section A + say("--- A: the Battle Tower desk, with the real TryQuickSave") + + -- A file has to already exist for AskOverwriteSaveFile to have anything to + -- ask about (`ld a, [wSaveFileExists] / and a / jr z, .erase`). + ok(game:writeSave() ~= false, "a first save is on disk") + ok(Save.exists(save.version), "Save.exists agrees") + + save.party = {} + for _, row in ipairs({ { "TYPHLOSION", 20 }, { "FERALIGATR", 20 }, + { "MEGANIUM", 20 } }) do + local mon = Mon.new(game.data, row[1], row[2]) + save.party[#save.party + 1] = mon + end + + for _ = 1, 40 do + if top() == nil then break end + tap("b", 2) + end + ok(world:setMap("BATTLE_TOWER_1F", 7, 7, "up") == true, "on BATTLE_TOWER_1F") + U.wait(40) + shot("tower-lobby") + + local function isMenu() return getmetatable(top()) == ScriptMenu end + local function isRoomMenu() return getmetatable(top()) == BattleTowerMenu end + + local reached = false + for _ = 1, 200 do + if isMenu() then reached = true break end + if world.choicebox then tap("b", 4) else tap("a", 4) end + end + ok(reached, "the receptionist reaches Menu_ChallengeExplanationCancel") + tap("a", 6) -- CHALLENGE + + -- Two yes/no prompts now, not one: Text_SaveBeforeEnteringBattleRoom and + -- then AskOverwriteSaveFile's own, which the stub never asked. + local prompts, pages = 0, {} + for _ = 1, 240 do + if isRoomMenu() then break end + if world.choicebox then + prompts = prompts + 1 + shot("prompt-" .. prompts) + say("prompt " .. prompts .. ": " .. tostring(world.lastText)) + tap("a", 6) + else + local body = world.lastText + if type(body) == "string" and body ~= "" and pages[#pages] ~= body then + pages[#pages + 1] = body + end + tap("a", 4) + end + end + say("pages: " .. table.concat(pages, " | ")) + ok(prompts >= 2, + "AskOverwriteSaveFile asked its own question (" .. prompts .. " prompts)") + local joined = table.concat(pages, " | ") + ok(joined:find("SAVING", 1, true) ~= nil, "the SAVING page was shown") + ok(joined:find("saved", 1, true) ~= nil, "and SavedTheGameText") + ok(isRoomMenu(), "TryQuickSave answered TRUE and the room menu opened") + shot("room-menu") + + local onDisk = Save.load(save.version) + ok(onDisk ~= nil, "the challenge's own save reached disk") + ok(onDisk and onDisk.position and onDisk.position.map == "BATTLE_TOWER_1F", + "and it was written from the lobby: " + .. tostring(onDisk and onDisk.position and onDisk.position.map)) + + -- The room menu's own header is STATICMENU_DISABLE_B, so the way out of the + -- desk is forward: pick a room and let the walk to the elevator finish. + for _ = 1, 400 do + if not world:busy() and top() == nil then break end + tap("a", 4) + end + say("map after the desk: " .. tostring(world.map and world.map.id)) + for _ = 1, 40 do + if top() == nil then break end + game.stack:pop() + end + + say(fails == 0 and "PASS" or (fails .. " FAILURES")) + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/drivers/crystal_gender_flag_shots.lua b/tests/drivers/crystal_gender_flag_shots.lua new file mode 100644 index 00000000..a548283c --- /dev/null +++ b/tests/drivers/crystal_gender_flag_shots.lua @@ -0,0 +1,260 @@ +-- U2 verification: ENGINE_PLAYER_IS_FEMALE end to end, plus the four gendered +-- pictures and the Crystal surf refusal. Not a suite; a shot harness. +-- +-- POKEPORT_GAME=crystal POKEPORT_BOOT_CINEMA=1 POKEPORT_GENDER=girl \ +-- POKEPORT_SHOT_DIR= \ +-- POKEPORT_DRIVER=tests/drivers/crystal_gender_flag_shots.lua love . +local U = require("tests.drivers.util") + +local BattleState = require("src.ui.gen2.BattleState") +local FieldMoves = require("src.world.gen2.FieldMoves") +local GameVersion = require("src.core.GameVersion") +local GenderSelect = require("src.ui.gen2.GenderSelect") +local HallOfFame = require("src.ui.gen2.HallOfFame") +local MainMenu = require("src.ui.gen2.MainMenu") +local Mon = require("src.battle.gen2.Mon") +local NamePick = require("src.ui.gen2.NamePick") +local NamingScreen = require("src.ui.gen2.NamingScreen") +local OakSpeech = require("src.ui.gen2.OakSpeech") +local Screens = require("src.ui.Screens") + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/u2-gender" + local want = (os.getenv("POKEPORT_GENDER") or "girl"):lower() + local log = io.open(out .. "/driver.log", "w") + + local function say(line) + print("[u2] " .. line) + if log then log:write(line .. "\n"); log:flush() end + end + + local function done(code) + if log then log:close() end + love.event.quit(code) + if code ~= 0 then error("driver failed", 0) end + coroutine.yield() + end + + local function bail(reason) + say("FAIL " .. reason) + done(1) + end + + local function top() return game.stack:top() end + local function isA(class) + local s = top() + return s ~= nil and getmetatable(s) == class + end + + local function tap(button, frames) + game.input.pressQueue[#game.input.pressQueue + 1] = button + game.input.state[button] = true + U.wait(2) + game.input.state[button] = false + U.wait(frames or 4) + end + + local function waitFor(label, predicate, frames) + for _ = 1, frames or 900 do + if predicate() then return true end + U.wait(1) + end + bail(("stalled waiting for %s (top is %s)"):format(label, tostring(top()))) + end + + say("version=" .. GameVersion.get() .. " engine=" .. GameVersion.engine()) + say("fixes.surfOntoNpc=" .. tostring(GameVersion.fixes().surfOntoNpc)) + + ------------------------------------------------------------------ new game + U.wait(10) + for _ = 1, 1200 do + if isA(MainMenu) then break end + tap("start", 2) + end + waitFor("the main menu", function() return isA(MainMenu) end, 600) + local menu = top() + for i, item in ipairs(menu.list.items) do + if item.value == "new" then menu.list.index = i end + end + tap("a") + + local gendered = false + for _ = 1, 400 do + if isA(GenderSelect) then gendered = true break end + if isA(OakSpeech) or isA(NamePick) then break end + U.wait(1) + end + if GameVersion.engine() == "crystal" then + if not gendered then bail("Crystal never offered InitGender") end + local pick = top() + pick.cursor = (want == "girl") and 2 or 1 + U.shot(game, out .. "/00-gender.png") + say("OK gender screen, choosing " .. want) + tap("a") + else + if gendered then bail("Gold offered a gender prompt; it has none") end + say("OK no gender prompt on " .. GameVersion.get()) + end + + for _ = 1, 900 do + if game.phase == "play" and game.world and game.world.map then break end + if isA(NamingScreen) then + local naming = top() + naming.row = naming:bottomRow() + naming.col = 6 + end + tap("a", 2) + end + if not (game.phase == "play" and game.world and game.world.map) then + bail("never reached the overworld (top is " .. tostring(top()) .. ")") + end + local world = game.world + local save = game.save + say("OK overworld, name=" .. tostring(save.player.name) + .. " gender=" .. tostring(save.player.gender) + .. " sprite=" .. tostring(world:playerSpriteName())) + + ------------------------------------------------------ the engine flag itself + local id = FieldMoves.FEMALE_FLAG + say("FieldMoves.FEMALE_FLAG=" .. tostring(id)) + if GameVersion.engine() == "crystal" then + if id ~= 99 then bail("female flag id is " .. tostring(id) .. ", want 99") end + local reads = world:engineFlag(id) + say("world:engineFlag(" .. id .. ")=" .. tostring(reads)) + if reads ~= (want == "girl") then + bail("checkflag ENGINE_PLAYER_IS_FEMALE read " .. tostring(reads)) + end + elseif id ~= nil then + bail("Gold bound a female flag id: " .. tostring(id)) + end + + ------------------------------------------------------------- the three sites + local sites = { + { "COPYCATS_HOUSE_2F", 3, 4, "up", "01-copycat" }, + { "ROUTE_37", 5, 9, "up", "02-route37" }, + { "POKECENTER_2F", 5, 5, "up", "03-pokecenter2f" }, + } + for _, site in ipairs(sites) do + local mapId, x, y, facing, name = site[1], site[2], site[3], site[4], site[5] + if world.maps and world.maps[mapId] then + world:setMap(mapId, x, y, facing) + U.wait(40) + U.shot(game, out .. "/" .. name .. ".png") + local names = {} + for _, npc in ipairs(world.npcs or {}) do + names[#names + 1] = tostring(npc.def and npc.def.sprite) + end + say(name .. " map=" .. tostring(world.map.id) + .. " objects=" .. table.concat(names, ",")) + else + say("SKIP " .. mapId .. ": not in this cache") + end + end + + ------------------------------------------------------------------- the pack + world:setMap("NEW_BARK_TOWN", 13, 6, "down") + U.wait(20) + save.inventory = save.inventory or {} + save.inventory.POTION = 3 + Screens.push(game, "Gen2PackMenu", { save = save, world = {}, + onClose = function() game.stack:pop() end }) + U.wait(20) + U.shot(game, out .. "/04-pack.png") + local packGfx = top() and top().gfx + local pals = packGfx and packGfx.gfx and packGfx.gfx.palettes + local menuGfx = game.data.gen2MenuGfx or {} + local femalePals = menuGfx.pack and menuGfx.pack.palettesFemale + say("pack palettes are the female set: " + .. tostring(pals ~= nil and pals == femalePals)) + tap("b") + U.wait(10) + while top() do game.stack:pop() end + + ------------------------------------------------------------ battle back pic + local hud = menuGfx.battleHud or {} + say("battleHud.playerBack=" .. tostring(hud.playerBack) + .. " playerBackFemale=" .. tostring(hud.playerBackFemale)) + save.party = { Mon.new(game.data, "CYNDAQUIL", 12) } + world:startBattle({ wild = Mon.new(game.data, "WOOPER", 5) }) + local battle + for _ = 1, 600 do + if getmetatable(top()) == BattleState then battle = top() break end + U.wait(1) + end + if not battle then bail("startBattle never reached the battle screen") end + -- showPlayerTrainer is only true until SendOutPlayerMon; hold it so the + -- back pic is what the shot catches. + for _ = 1, 90 do + battle.showPlayerTrainer = true + U.wait(1) + end + battle.showPlayerTrainer = true + U.shot(game, out .. "/05-battle-backpic.png") + say("battle backpic path=" .. tostring(battle.playerBackPath)) + while top() do game.stack:pop() end + U.wait(10) + + ------------------------------------------------------------- trainer card + Screens.push(game, "Gen2TrainerCard", { onClose = function() + game.stack:pop() end }) + U.wait(30) + U.shot(game, out .. "/06-trainercard.png") + say("trainer card female arm=" .. tostring(top() and top().female)) + tap("b") + U.wait(10) + while top() do game.stack:pop() end + + ------------------------------------------------------------- hall of fame + save.hallOfFame = nil + Screens.push(game, "Gen2HallOfFame", { + save = save, mode = "induct", text = world.text, + entry = { winCount = 1, mons = { { species = "CYNDAQUIL", level = 12, + nickname = "CYNDAQUIL", otId = 1234, gender = "male" } } }, + onDone = function() game.stack:pop() end }) + U.wait(20) + local hof = top() + if getmetatable(hof) ~= HallOfFame then + bail("Gen2HallOfFame did not push (top is " .. tostring(hof) .. ")") + end + -- The ceremony walks its own phases; freeze it so the two player cards can + -- be posed and shot. + hof.update = function() end + hof.scx, hof.scy = 0, 0 + hof.phase = "player" + U.shot(game, out .. "/07-hof-trainerpic.png") + hof.phase = "playerBack" + U.shot(game, out .. "/08-hof-backpic.png") + say("hof backpic=" .. tostring(hof.playerBackPath) + .. " trainerPic=" .. tostring(hof.trainerPicPath)) + while top() do game.stack:pop() end + U.wait(10) + + ------------------------------------------------------------- surf onto NPC + -- + -- SurfFunction.TrySurf with a facing object: Crystal refuses, Gold does not + -- (../pokecrystal/engine/events/overworld.asm:364). + local ctx = { + save = { player = { badges = { FOG = true } } }, + mon = { species = "LAPRAS" }, + facing = "down", + facingColl = 0x29, + playerColl = 0x00, + playerState = FieldMoves.PLAYER_NORMAL, + facingObject = { id = "an NPC standing on the water" }, + } + local refused = FieldMoves.surfFromMenu(ctx) + say("surf onto an NPC: ok=" .. tostring(refused.ok) + .. " text=" .. tostring(refused.text)) + local shouldRefuse = GameVersion.fixes().surfOntoNpc == true + if (refused.ok ~= true) ~= shouldRefuse then + bail("surf-onto-NPC answered ok=" .. tostring(refused.ok) + .. " but fixes().surfOntoNpc=" .. tostring(shouldRefuse)) + end + ctx.facingObject = nil + if not FieldMoves.surfFromMenu(ctx).ok then + bail("open water refused SURF") + end + + say("PASS " .. GameVersion.get() .. " / " .. want .. " in " .. out) + done(0) +end diff --git a/tests/drivers/crystal_gender_shots.lua b/tests/drivers/crystal_gender_shots.lua new file mode 100644 index 00000000..41db7f19 --- /dev/null +++ b/tests/drivers/crystal_gender_shots.lua @@ -0,0 +1,234 @@ +-- Crystal's InitGender screen, end to end, and the six things that follow from +-- the answer. POKEPORT_GENDER picks the arm: "girl", "boy" or "none" (Gold, +-- which must never see the screen at all). +-- +-- POKEPORT_IDENTITY= POKEPORT_GAME=crystal POKEPORT_BOOT_CINEMA=1 \ +-- POKEPORT_GENDER=girl POKEPORT_SHOT_DIR= \ +-- POKEPORT_DRIVER=tests/drivers/crystal_gender_shots.lua love . +local U = require("tests.drivers.util") + +local GenderSelect = require("src.ui.gen2.GenderSelect") +local InitClock = require("src.ui.gen2.InitClock") +local MainMenu = require("src.ui.gen2.MainMenu") +local NamePick = require("src.ui.gen2.NamePick") +local NamingScreen = require("src.ui.gen2.NamingScreen") +local OakSpeech = require("src.ui.gen2.OakSpeech") +local TrainerCard = require("src.ui.gen2.TrainerCard") + +local FieldMoves = require("src.world.gen2.FieldMoves") +local GameVersion = require("src.core.GameVersion") +local Screens = require("src.ui.Screens") + +return function(game) + local want = os.getenv("POKEPORT_GENDER") or "girl" + local out = os.getenv("POKEPORT_SHOT_DIR") or ("/tmp/crystal-gender-" .. want) + local log = io.open(out .. "/driver.log", "w") + + local function say(line) + print("[driver] " .. line) + if log then log:write(line .. "\n"); log:flush() end + end + + local function bail(reason) + say("FAIL " .. reason) + if log then log:close() end + love.event.quit(1) + error(reason, 0) + end + + local function top() return game.stack:top() end + local function isA(class) + local state = top() + return state ~= nil and getmetatable(state) == class + end + + local function tap(button, frames) + game.input.pressQueue[#game.input.pressQueue + 1] = button + game.input.state[button] = true + U.wait(2) + game.input.state[button] = false + U.wait(frames or 4) + end + + local function waitFor(label, predicate, frames) + for _ = 1, frames or 900 do + if predicate() then return end + U.wait(1) + end + bail(("stalled waiting for %s (top is %s)"):format(label, tostring(top()))) + end + + local function eq(got, wanted, label) + if got ~= wanted then + bail(("%s: got %s, want %s"):format(label, tostring(got), + tostring(wanted))) + end + say("OK " .. label .. " = " .. tostring(got)) + end + + say("version=" .. tostring(GameVersion.get()) + .. " engine=" .. tostring(GameVersion.engine()) + .. " gender=" .. want) + + -- Straight to the intro menu; the boot chain itself is crystal_boot_smoke's. + for _ = 1, 900 do + if isA(MainMenu) then break end + tap("start", 2) + end + if not isA(MainMenu) then + for _ = 1, 400 do + if isA(MainMenu) then break end + tap("a", 2) + end + end + waitFor("the intro menu", function() return isA(MainMenu) end) + local menu = top() + for i, item in ipairs(menu.list.items) do + if item.value == "new" then menu.list.index = i end + end + tap("a") + + if want == "none" then + -- Gold and Silver: PlayerProfileSetup has no InitGender to farcall. + waitFor("the clock screen", function() return isA(InitClock) end, 400) + if isA(GenderSelect) then bail("Gold put up the gender screen") end + say("OK no gender screen; NEW GAME went straight to the clock") + for _ = 1, 200 do + if isA(OakSpeech) then break end + tap("a", 2) + end + waitFor("the Oak speech", function() return isA(OakSpeech) end, 400) + local speech = top() + for _, step in ipairs(speech.steps or {}) do + if step.kind == "gender" then bail("Gold's speech grew a gender beat") end + end + eq(speech.steps and speech.steps[1] and speech.steps[1].id, "init_clock", + "the first beat") + U.wait(40) + U.shot(game, out .. "/01-oak.png") + eq(game.save.player.gender, "male", "save.player.gender") + say("PASS gold has no gender prompt") + if log then log:close() end + love.event.quit(0) + return + end + + waitFor("the gender screen", function() return isA(GenderSelect) end, 600) + local gender = top() + U.wait(20) + say("OK gender screen, cursor = " .. tostring(gender.cursor)) + U.shot(game, out .. "/01-genderselect.png") + if want == "girl" then + tap("down") + U.wait(10) + U.shot(game, out .. "/02-genderselect-girl.png") + else + U.shot(game, out .. "/02-genderselect-boy.png") + end + tap("a") + + waitFor("the clock screen", function() return isA(InitClock) end, 400) + eq(game.save.player.gender, want == "girl" and "female" or "male", + "wPlayerGender after the answer") + eq(game.save.player.name, want == "girl" and "KRIS" or "CHRIS", + "NamePlayer's InitName default") + + for _ = 1, 200 do + if isA(OakSpeech) then break end + tap("a", 2) + end + waitFor("the Oak speech", function() return isA(OakSpeech) end, 400) + local speech = top() + eq(speech.steps[1].id, "gender_select", "the beat the speech opened on") + + local shotPic = false + for _ = 1, 500 do + if isA(NamePick) then break end + if not shotPic and isA(OakSpeech) then + local at = top().steps and top().steps[top().step] + if at and at.id == "ask_player_name" then + U.wait(45) + local wanted = (want == "girl") and top().playerPicFemale + or top().playerPic + if top().pic ~= wanted then + bail("DrawIntroPlayerPic put up the wrong pic") + end + U.shot(game, out .. "/03-intropic.png") + shotPic = true + end + end + tap("a", 2) + end + waitFor("the name picker", function() return isA(NamePick) end, 200) + waitFor("the name menu to slide in", + function() return top().slide == nil end, 240) + local picker = top() + eq(picker.items[2], want == "girl" and "KRIS" or "CHRIS", "preset row 1") + eq(picker.items[3], want == "girl" and "AMANDA" or "MAT", "preset row 2") + U.shot(game, out .. "/04-namepick.png") + + picker.cursor = 1 + tap("a") + waitFor("the naming screen", function() return isA(NamingScreen) end) + local naming = top() + eq(naming.gender, want == "girl" and "female" or "male", + "the keyboard's header icon gender") + U.wait(20) + U.shot(game, out .. "/05-naming.png") + naming.row = naming:bottomRow() + naming.col = 6 + tap("a") + + for _ = 1, 700 do + if game.phase == "play" and game.world and game.world.map then break end + tap("a", 2) + end + if not (game.phase == "play" and game.world and game.world.map) then + bail("never reached the overworld (top is " .. tostring(top()) .. ")") + end + eq(game.world.map.id, "PLAYERS_HOUSE_2F", "the bedroom") + eq(game.save.player.name, want == "girl" and "KRIS" or "CHRIS", + "the name that reached the save") + local sprite = game.world.player and game.world.player.spriteDef + eq(sprite and sprite.id, FieldMoves.playerSprite(game.save.player.gender), + "the overworld sheet") + U.wait(20) + U.shot(game, out .. "/06-bedroom.png") + + -- Walk a step so the sheet is seen mid-stride rather than only standing. + U.hold(game, "down", 20) + U.wait(20) + U.shot(game, out .. "/07-bedroom-walk.png") + + -- The card, opened directly: the START-menu route is Gen2StartMenu's and is + -- already covered by crystal_boot_smoke. + game.save.player.badges = { ZEPHYR = true, HIVE = true } + local card = Screens.push(game, "Gen2TrainerCard", + { save = game.save, onClose = function() end }) + if getmetatable(card) ~= TrainerCard then bail("the card did not open") end + eq(card.female, want == "girl", "GetCardPic picked KrisCardPic") + U.wait(30) + U.shot(game, out .. "/08-trainercard.png") + tap("right") + U.wait(20) + U.shot(game, out .. "/09-trainercard-badges.png") + game.stack:pop() + + local gear = Screens.push(game, "Gen2Pokegear", { save = game.save }) + U.wait(30) + local pals = gear.pals and gear:pals() + local femalePals = gear.gfx and gear.gfx.palettesFemale + if want == "girl" and femalePals and pals ~= femalePals then + bail("the gear kept MalePokegearPals for Kris") + end + if want == "boy" and femalePals and pals == femalePals then + bail("the gear took FemalePokegearPals for Chris") + end + say("OK pokegear palettes = " .. (pals == femalePals and "female" or "male")) + U.shot(game, out .. "/10-pokegear.png") + game.stack:pop() + + say("PASS crystal gender run (" .. want .. ") in " .. out) + if log then log:close() end + love.event.quit(0) +end diff --git a/tests/drivers/crystal_intro_shots.lua b/tests/drivers/crystal_intro_shots.lua new file mode 100644 index 00000000..fcc5d096 --- /dev/null +++ b/tests/drivers/crystal_intro_shots.lua @@ -0,0 +1,117 @@ +local U = require("tests.drivers.util") +local CrystalIntro = require("src.ui.gen2.CrystalIntro") +local TitleState = require("src.ui.gen2.TitleState") + +local INTERVAL = 20 +local LIMIT = 4000 + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/crystal-intro" + local interval = tonumber(os.getenv("POKEPORT_SHOT_INTERVAL") or "") + or INTERVAL + + U.wait(30) + local assets = game.data and game.data.gen2Intro + if not (assets and assets.acts) then + U.log("SKIP no crystal intro acts in this cache -- re-import crystal") + return + end + + local finished = false + local intro = CrystalIntro.new(game, { + onDone = function() finished = true end, + }) + game.stack:clear() + game.stack:push(intro) + + local shots, scene = 0, 0 + while not finished and intro.frames < LIMIT do + U.wait(interval) + if intro.scene ~= scene then + scene = intro.scene + U.log(("scene %d at frame %d (scx=%02x scy=%02x)") + :format(scene, intro.frames, intro.scx % 256, intro.scy % 256)) + end + if not finished then + U.shot(game, ("%s/intro-%04d-scene%02d.png") + :format(out, intro.frames, intro.scene)) + shots = shots + 1 + end + end + assert(finished, + "the movie never reached IntroScene28 (frame " .. intro.frames .. ")") + U.log(("movie done: %d shots over %d frames"):format(shots, intro.frames)) + + local skipped = false + local intro2 = CrystalIntro.new(game, { + onDone = function() skipped = true end, + }) + game.stack:clear() + game.stack:push(intro2) + U.wait(45) + U.tap(game, "b") + U.wait(3) + assert(skipped and intro2.skipped, "a button did not skip the intro") + U.log("skip via B verified at frame " .. intro2.frames) + + game:showTitle() + U.wait(1) + local title = game.stack:top() + assert(getmetatable(title) == TitleState, + "showTitle left " .. tostring(title) .. " on the stack") + assert(#title.suicuneColor == 4, + "title.lua has no 4-frame Suicune set -- stale cache?") + assert(title.entrance, "title.lua has no entrance block -- stale cache?") + assert(title.suicuneX == 48 and title.suicuneY == 96, + ("Suicune is at (%s, %s), expected (48, 96)") + :format(tostring(title.suicuneX), tostring(title.suicuneY))) + + for i = 1, 4 do + U.shot(game, ("%s/title-entrance-%d.png"):format(out, i)) + U.wait(4) + end + for _ = 1, 90 do + if title.entranceScx == 0 then break end + U.wait(1) + end + assert(title.entranceScx == 0, "the entrance never landed") + assert(title.gemY == title.gemRestY, + ("gem y %s did not land at %s with the entrance") + :format(tostring(title.gemY), tostring(title.gemRestY))) + U.wait(2) + for i = 1, 5 do + U.shot(game, ("%s/title-suicune-%d.png"):format(out, i)) + U.wait(6) + end + + if love.window and love.window.setMode then + for _, shape in ipairs({ { 1280, 720 }, { 1920, 500 }, { 480, 800 } }) do + love.window.setMode(shape[1], shape[2], { resizable = true }) + U.wait(3) + U.shot(game, ("%s/title-wide-%dx%d.png"):format(out, shape[1], shape[2])) + end + love.window.setMode(1280, 720, { resizable = true }) + end + + local intro3 = CrystalIntro.new(game, {}) + game.stack:clear() + game.stack:push(intro3) + local function runTo(target, extra) + for _ = 1, LIMIT do + if intro3.scene >= target then break end + U.wait(5) + end + assert(intro3.scene >= target, + "widescreen pass never reached scene " .. target) + U.wait(extra) + end + runTo(4, 40) + U.shot(game, out .. "/intro-wide-scene04.png") + runTo(10, 60) + U.shot(game, out .. "/intro-wide-scene10.png") + runTo(18, 20) + U.shot(game, out .. "/intro-wide-scene18.png") + intro3:skip() + + U.log(("PASS crystal intro + title shots in %s"):format(out)) +end diff --git a/tests/drivers/crystal_shaderfx_shots.lua b/tests/drivers/crystal_shaderfx_shots.lua new file mode 100644 index 00000000..bec16cc1 --- /dev/null +++ b/tests/drivers/crystal_shaderfx_shots.lua @@ -0,0 +1,94 @@ +-- SHADER FX preset examples on Crystal, including a stacked pair. +-- +-- POKEPORT_IDENTITY=crystal-dev POKEPORT_GAME=crystal POKEPORT_TOUCH=0 \ +-- POKEPORT_SHOT_DIR=/tmp/shaderfx \ +-- POKEPORT_DRIVER=tests/drivers/crystal_shaderfx_shots.lua love . +-- +-- Never run this under POKEPORT_SPEED: the shots are of a live frame. +local U = require("tests.drivers.util") +local ShaderFX = require("src.render.ShaderFX") + +local WANT = { + { file = "gameboy-color-dot-matrix.slangp", tag = "gbc-dot-matrix" }, + { file = "sameboy-lcd.slangp", tag = "sameboy-lcd" }, + { file = "crt-caligari.slangp", tag = "crt-caligari" }, +} + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/shaderfx" + + U.wait(30) + U.log(("bridge canConvert=%s"):format(tostring(ShaderFX.canConvert()))) + if not ShaderFX.canConvert() then + U.log("FAIL no librashader bridge, nothing can be converted") + return + end + + local list = ShaderFX.list() + U.log(("found %d presets"):format(#list)) + if #list == 0 then U.log("FAIL no presets on disk") return end + + local byName = {} + for _, e in ipairs(list) do byName[e.name] = e end + + local ready = {} + for _, want in ipairs(WANT) do + local entry = byName[want.file] + if not entry then + U.log(("SKIP %s not in the preset set"):format(want.file)) + else + local ok, err = true, nil + if not entry.converted then ok, err = ShaderFX.convert(entry) end + if ok then + ready[#ready + 1] = { entry = entry, tag = want.tag } + U.log(("PASS converted %s"):format(want.file)) + else + U.log(("FAIL convert %s: %s"):format(want.file, tostring(err))) + end + end + end + if #ready == 0 then U.log("FAIL nothing converted") return end + + U.wait(60) + local world = game.world + if world and world.setMap then + world:setMap("CHERRYGROVE_CITY", 21, 6, "down") + U.wait(20) + else + U.log("note: no gen2 world yet, shooting whatever is on screen") + end + + ShaderFX.deactivate("main") + ShaderFX.deactivate("secondary") + U.wait(20) + U.shot(game, out .. "/00-none.png") + U.log("shot: no shader") + + for i, r in ipairs(ready) do + ShaderFX.deactivate("secondary") + local ok, err = ShaderFX.activate("main", r.entry) + U.wait(30) + if ok then + U.shot(game, ("%s/%02d-%s.png"):format(out, i, r.tag)) + U.log(("shot: %s"):format(r.tag)) + else + U.log(("FAIL activate %s: %s"):format(r.tag, tostring(err))) + end + end + + if #ready >= 2 then + local a, b = ready[1], ready[2] + local okA = ShaderFX.activate("main", a.entry) + local okB = ShaderFX.activate("secondary", b.entry) + U.wait(30) + if okA and okB then + U.shot(game, ("%s/%02d-stacked-%s-over-%s.png"):format(out, #ready + 1, a.tag, b.tag)) + U.log(("shot: stacked %s + %s"):format(a.tag, b.tag)) + else + U.log("FAIL could not stack two slots") + end + end + + U.log("done. the pad is yours; SHADER FX rows are in OPTIONS.") + while true do coroutine.yield() end +end diff --git a/tests/drivers/crystal_splash_shots.lua b/tests/drivers/crystal_splash_shots.lua new file mode 100644 index 00000000..fa46ff92 --- /dev/null +++ b/tests/drivers/crystal_splash_shots.lua @@ -0,0 +1,86 @@ +-- The Crystal GAME FREAK splash (pokecrystal engine/movie/splash.asm), shot +-- finely enough to catch both Ditto bounces, plus both IntroSequence exits +-- (pokecrystal engine/menus/intro_menu.asm:964-967): watched -> CrystalIntro, +-- skipped -> the title. +local U = require("tests.drivers.util") +local CrystalIntro = require("src.ui.gen2.CrystalIntro") +local CrystalSplash = require("src.ui.gen2.CrystalSplash") +local TitleState = require("src.ui.gen2.TitleState") + +local LIMIT = 900 + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/crystal-splash" + local interval = tonumber(os.getenv("POKEPORT_SHOT_INTERVAL") or "") or 2 + + U.wait(10) + local finished, skipped + local splash = CrystalSplash.new(game, { + oakSpeech = game.oakSpeechData or {}, + onDone = function(s) + finished, skipped = true, s + end, + }) + game.stack:clear() + game.stack:push(splash) + + local st = splash.anims.structs[1] + assert(st and st.index ~= 0, "no Ditto sprite anim struct was spawned") + local minY, sawMorph, scene = 256, false, -1 + while not finished and splash.frames < LIMIT do + U.wait(interval) + local offset = st.yOffset + if offset >= 128 then offset = offset - 256 end + if st.jt == 1 and offset < minY then minY = offset end + if st.jt >= 3 then sawMorph = true end + if splash.scene ~= scene then + scene = splash.scene + U.log(("scene %d at frame %d (jt=%d var1=%d)") + :format(scene, splash.frames, st.jt, st.var1)) + end + if not finished then + U.shot(game, ("%s/splash-%04d-scene%d.png") + :format(out, splash.frames, splash.scene)) + end + end + assert(finished, "the splash never finished (frame " + .. splash.frames .. ", scene " .. splash.scene .. ")") + assert(not skipped, "an unskipped splash reported skipped") + assert(minY <= -90, "Ditto never reached the top of its 96px bounce (min " + .. minY .. ")") + assert(sawMorph, "the transform scene never ran") + U.log(("splash done at frame %d, min bounce offset %d") + :format(splash.frames, minY)) + + -- Watched through: Game2 routes to the intro movie. + game:showGameFreak() + U.wait(5) + assert(getmetatable(game.stack:top()) == CrystalSplash, + "showGameFreak did not open the Crystal splash") + for _ = 1, LIMIT do + if getmetatable(game.stack:top()) == CrystalIntro then break end + U.wait(5) + end + assert(getmetatable(game.stack:top()) == CrystalIntro, + "a watched splash did not hand off to CrystalIntro (top " + .. tostring(game.stack:top()) .. ")") + U.log("watched splash handed off to the intro movie") + + -- Skipped: straight to the title, no intro movie. + game:showGameFreak() + U.wait(40) + U.shot(game, out .. "/skip-before.png") + U.tap(game, "b") + for _ = 1, 60 do + if getmetatable(game.stack:top()) == TitleState then break end + assert(getmetatable(game.stack:top()) ~= CrystalIntro, + "a skipped splash still played the intro movie") + U.wait(1) + end + assert(getmetatable(game.stack:top()) == TitleState, + "a skipped splash did not land on the title (top " + .. tostring(game.stack:top()) .. ")") + U.wait(30) + U.shot(game, out .. "/skip-title.png") + U.log("PASS crystal splash shots in " .. out) +end diff --git a/tests/drivers/crystal_story_shots.lua b/tests/drivers/crystal_story_shots.lua new file mode 100644 index 00000000..f7515610 --- /dev/null +++ b/tests/drivers/crystal_story_shots.lua @@ -0,0 +1,278 @@ +-- The Crystal story specials, driven in a real game. +-- +-- POKEPORT_IDENTITY=unitb-crystal POKEPORT_GAME=crystal \ +-- POKEPORT_VERSION=crystal POKEPORT_SHOT_DIR=/tmp/unitb \ +-- POKEPORT_DRIVER=tests/drivers/crystal_story_shots.lua love . +-- +-- Four sections, each entered by putting the player on the real map and +-- letting the extracted script run: +-- A TIN_TOWER_1F the Suicune confrontation, and whether it flees +-- B ILEX_FOREST the GS Ball shrine, Celebi, and CheckCaughtCelebi +-- C DRAGON_SHRINE GiveDratini's Extremespeed moveset +-- D DAY_CARE GiveOddEgg +local U = require("tests.drivers.util") + +local BattleState = require("src.ui.gen2.BattleState") +local GameVersion = require("src.core.GameVersion") +local Mon = require("src.battle.gen2.Mon") +local NamingScreen = require("src.ui.gen2.NamingScreen") +local Roamers = require("src.core.gen2.Roamers") +local Save = require("src.core.gen2.Save") + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/crystal-story" + local fails = 0 + + local function say(line) print("[driver] " .. line) end + local function ok(cond, line) + if not cond then fails = fails + 1 end + say((cond and "OK " or "FAIL ") .. line) + end + + local function tap(button, frames) + game.input.pressQueue[#game.input.pressQueue + 1] = button + game.input.state[button] = true + U.wait(2) + game.input.state[button] = false + U.wait(frames or 4) + end + + local function top() return game.stack:top() end + local function battleState() + local st = top() + if st ~= nil and getmetatable(st) == BattleState then return st end + return nil + end + local function inBattle() + local st = battleState() + return st and st.battle or nil + end + + local function waitFor(pred, frames) + for _ = 1, frames or 1200 do + if pred() then return true end + U.wait(1) + end + return false + end + + local world + + -- Mash A until the script is finished and the stack is bare again. The + -- naming screen gets B: `givepoke` ends in Specials.askNickname + -- (src/script/gen2/Specials.lua:382) and mashing A there types letters + -- forever instead of leaving. + -- The naming screen has no B exit: crystal_boot_smoke.lua leaves it by + -- putting the cursor on the bottom row's confirm cell. Confirming a blank + -- name is the cart's own "no nickname" (home/string.asm:6 _InitString). + local function mash() + local st = top() + if getmetatable(st) == NamingScreen then + st.row, st.col = st:bottomRow(), 6 + end + tap("a", 2) + end + + local function settle(frames) + for _ = 1, frames or 3000 do + if not world:busy() and top() == nil then return true end + mash() + end + local meta, name = getmetatable(top()), "?" + for id, mod in pairs(package.loaded) do + if mod == meta then name = id end + end + say((" stuck on %s (phase=%s) busy=%s"):format(name, + tostring(top() and top().phase), tostring(world:busy()))) + return false + end + + -- Stand one cell below the object carrying `scriptKey`, facing it. + local function standBelow(mapId, scriptKey) + world:setMap(mapId, 1, 1, "down") + U.wait(10) + local tx, ty + for _, npc in ipairs(world.npcs or {}) do + if npc.def and npc.def.scriptKey == scriptKey then + tx, ty = npc.cellX, npc.cellY + end + end + if not tx then return false end + world:setMap(mapId, tx, ty + 1, "up") + U.wait(30) + return world.player.cellX == tx and world.player.cellY == ty + 1 + end + + local function moveIds(mon) + local list = {} + for _, move in ipairs((mon and mon.moves) or {}) do + list[#list + 1] = move.id + end + return table.concat(list, ",") + end + + U.wait(60) + world = game.world + assert(world and world.map, "crystal world did not boot") + say("version=" .. GameVersion.get() .. " engine=" .. GameVersion.engine()) + + local save = game.save + save.player = save.player or {} + save.player.name = save.player.name or "CHRIS" + save.player.id = save.player.id or 30000 + save.inventory = save.inventory or {} + save.party = { Mon.new(game.data, "TYPHLOSION", 50) } + + -- ---- A TIN_TOWER_1F ---------------------------------------------------- + -- ../pokecrystal/maps/TinTower1F.asm:84 TinTower1FSuicuneBattleScript, run + -- from the SCENE_TINTOWER1F_SUICUNE_BATTLE scene script at :22. + say("A Tin Tower") + say(" crystal AlwaysFleeMons SUICUNE = " + .. tostring(Roamers.alwaysFleeMons("crystal").SUICUNE) + .. ", gold = " .. tostring(Roamers.alwaysFleeMons("gold").SUICUNE)) + world.mapScenes.TIN_TOWER_1F = 0 + world:setMap("TIN_TOWER_1F", 9, 14, "up") + U.wait(20) + U.shot(game, out .. "/a1-tintower-enter.png") + + ok(waitFor(function() return world:busy() end, 600), + "the scene script started on map entry") + U.wait(120) + U.shot(game, out .. "/a2-beasts.png") + ok(waitFor(function() return inBattle() ~= nil end, 3000), + "the Tin Tower scene reached a battle") + local battle = inBattle() + if battle then + U.wait(90) + U.shot(game, out .. "/a3-suicune-battle.png") + ok(battle.enemy and battle.enemy.species == "SUICUNE", + "the wild mon is SUICUNE (got " .. + tostring(battle.enemy and battle.enemy.species) .. ")") + ok((battle.enemy and battle.enemy.level) == 40, "at level 40") + -- ../pokecrystal/engine/battle/core.asm:759 TryEnemyFlee: with Suicune off + -- AlwaysFleeMons it has to survive the two random gates as well. + local fled = 0 + for _ = 1, 500 do + if battle:tryEnemyFlee() then fled = fled + 1 end + end + ok(fled == 0, "Suicune never flees in 500 rolls (fled " .. fled .. ")") + ok(battle.outcome == nil, "and the battle is still live") + battle.outcome = "run" + local st = battleState() + if st then st:finishBattle() end + end + ok(settle(2000), "the Tin Tower script ran to its end") + + -- ---- B ILEX_FOREST ----------------------------------------------------- + -- ../pokecrystal/maps/IlexForest.asm:429 IlexForestShrineScript, a BGEVENT_UP + -- at (8,22). EVENT_FOREST_IS_RESTLESS and the GS Ball are the two gates; on + -- a retail cart neither can be reached, so both are set here the way the + -- Virtual Console wrapper sets sGSBallFlag + -- (../pokecrystal/engine/menus/save.asm:168). + say("B Ilex Forest shrine") + save.party = { Mon.new(game.data, "TYPHLOSION", 50) } + Save.crystalState(save).gsBall = "have" + save.inventory.GS_BALL = 1 + save.inventory.MASTER_BALL = 5 + world:setMap("ILEX_FOREST", 8, 23, "up") + U.wait(40) + world.events:set(192, true) + U.shot(game, out .. "/b1-shrine.png") + + tap("a", 30) + U.shot(game, out .. "/b2-shrine-prompt.png") + -- The prompt is the script's own yesorno; A takes YES. + ok(waitFor(function() + if inBattle() then return true end + tap("a", 3) + return false + end, 400), "the shrine script reached a battle") + local celebi = inBattle() + if celebi then + U.wait(90) + U.shot(game, out .. "/b3-celebi.png") + ok(celebi.enemy and celebi.enemy.species == "CELEBI", + "the wild mon is CELEBI (got " .. + tostring(celebi.enemy and celebi.enemy.species) .. ")") + ok(celebi.battleType == 11, + "the battle carries BATTLETYPE_CELEBI (got " + .. tostring(celebi.battleType) .. ")") + -- Catch it with a MASTER BALL so CheckCaughtCelebi has something to read. + local thrown = false + for _ = 1, 900 do + local st = battleState() + if not st then break end + if not thrown and st.phase == "menu" then + st:useItem("MASTER_BALL") + thrown = true + U.wait(60) + U.shot(game, out .. "/b4-masterball.png") + end + mash() + end + end + ok(settle(2000), "the shrine script ran to its end") + U.shot(game, out .. "/b5-after-celebi.png") + local caught = false + for _, mon in ipairs(save.party) do + if mon.species == "CELEBI" then caught = true end + end + ok(caught, "Celebi is in the party") + ok(Save.crystalState(save).celebiCaught == true, + "CheckCaughtCelebi recorded the catch") + ok((save.inventory.GS_BALL or 0) == 0, "the GS Ball was taken") + + -- ---- C DRAGON_SHRINE --------------------------------------------------- + -- ../pokecrystal/maps/DragonShrine.asm:192 DragonShrineElder1Script, whose + -- .GiveDratini arm (:208) is `givepoke DRATINI, 15 / checkevent + -- EVENT_ANSWERED_DRAGON_MASTER_QUIZ_WRONG / special GiveDratini`. + say("C Dragon Shrine") + save.party = { Mon.new(game.data, "TYPHLOSION", 50) } + -- ../pokecrystal/maps/DragonShrine.asm:10 SCENE_DRAGONSHRINE_NOOP; scene 0 is + -- the Dragon Master quiz cutscene, which is not what this section measures. + world.mapScenes.DRAGON_SHRINE = 1 + ok(standBelow("DRAGON_SHRINE", "63:51a5"), "found the shrine elder") + U.shot(game, out .. "/c1-shrine.png") + tap("a", 30) + U.shot(game, out .. "/c2-elder.png") + ok(settle(2000), "the elder's script ran to its end") + U.shot(game, out .. "/c3-dratini.png") + local dratini + for _, mon in ipairs(save.party) do + if mon.species == "DRATINI" then dratini = mon end + end + ok(dratini ~= nil, "the elder handed over a DRATINI") + if dratini then + say(" moves = " .. moveIds(dratini)) + ok(moveIds(dratini) == "WRAP,THUNDER_WAVE,TWISTER,EXTREMESPEED", + "with .Moveset0, Extremespeed and all") + ok(dratini.moves[4].pp == 5, "Extremespeed arrives with 5 PP") + end + + -- ---- D DAY_CARE -------------------------------------------------------- + -- ../pokecrystal/maps/DayCare.asm:23 DayCareManScript_Inside, whose + -- `special GiveOddEgg` is at :33. + say("D Day Care") + save.party = { Mon.new(game.data, "TYPHLOSION", 50) } + save.inventory.EGG_TICKET = 1 + ok(standBelow("DAY_CARE", "18:6f8f"), "found the Day-Care Man") + U.shot(game, out .. "/d1-daycare.png") + tap("a", 30) + U.shot(game, out .. "/d2-gramps.png") + ok(settle(2000), "the Day-Care Man's script ran to its end") + U.shot(game, out .. "/d3-oddegg.png") + local egg = save.party[2] + ok(egg ~= nil and egg.isEgg == true, "an EGG joined the party") + if egg then + say((" %s ot=%s otId=%s eggSteps=%s moves=%s"):format( + tostring(egg.species), tostring(egg.ot), tostring(egg.otId), + tostring(egg.eggSteps), moveIds(egg))) + ok(egg.ot == "ODD", "with OT ODD") + ok(egg.eggSteps == 20, "and 20 hatch cycles") + end + ok((save.inventory.EGG_TICKET or 0) == 0, "the EGG TICKET was tossed") + + say(fails == 0 and ("PASS crystal story in " .. out) + or ("FAIL " .. fails .. " checks, shots in " .. out)) + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/drivers/crystal_tower_battle_f1.lua b/tests/drivers/crystal_tower_battle_f1.lua new file mode 100644 index 00000000..c35cf272 --- /dev/null +++ b/tests/drivers/crystal_tower_battle_f1.lua @@ -0,0 +1,277 @@ +-- maps/BattleTowerBattleRoom.asm's own loop, driven in a real game: the +-- opponent draw, the walk-in, the battle, and the counter the room script +-- reads back. +-- +-- POKEPORT_IDENTITY=f1-tower POKEPORT_GAME=crystal POKEPORT_VERSION=crystal \ +-- POKEPORT_SHOT_DIR=/tmp/tower-battle \ +-- POKEPORT_DRIVER=tests/drivers/crystal_tower_battle_f1.lua love . +-- +-- src/world/gen2/World.lua has no startTowerBattle / setObjectSprite hook and +-- World:startBattle does not forward `battleTower` into Battle.new, so the +-- three seams are shimmed here for the length of the run. The bodies are +-- exactly what belongs in World:specialHooks and World:startBattle. +local U = require("tests.drivers.util") + +local Battle = require("src.battle.gen2.Battle") +local BattleTower = require("src.core.gen2.BattleTower") +local Mon = require("src.battle.gen2.Mon") +local World = require("src.world.gen2.World") + +-- The world exists before a POKEPORT_DRIVER chunk loads (main.lua:426-438), +-- so the seams go on the live instance. +local function shimWorldSeams(world) + local towerBattle = false + local baseNew = Battle.new + Battle.new = function(opts) + if towerBattle then opts.battleTower = true end + return baseNew(opts) + end + + local baseStartBattle = World.startBattle + world.startBattle = function(self, opts, onDone) + towerBattle = (opts and opts.battleTower) and true or false + local started = baseStartBattle(self, opts, onDone) + towerBattle = false + return started + end + + local hooks = world.vm.specials + hooks.startTowerBattle = function(trainer, onDone) + return world:startBattle({ trainer = trainer, battleTower = true }, onDone) + end + hooks.setObjectSprite = function(objectId, spriteName) + local index = (objectId or 0) - 1 + local def = world.map and world.map.def + local obj = def and def.objects and def.objects[index] + local sheet = world.sprites and world.sprites[spriteName] + if not (obj and sheet) then return false end + obj.sprite = spriteName + local npc = world:objectEntity(objectId) + if npc and npc:setSpriteDef(sheet) then world:applySpritePalette(npc) end + world:rebuildPeople({ seamless = true }) + return true + end +end + +-- ---- the run -------------------------------------------------------------- + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/crystal-tower-battle" + local fails, shots = 0, 0 + + local function say(line) print("[driver] " .. line) end + local function ok(cond, line) + if not cond then fails = fails + 1 end + say((cond and "OK " or "FAIL ") .. line) + end + local function shot(name) + shots = shots + 1 + U.shot(game, ("%s/%02d-%s.png"):format(out, shots, name)) + end + local function tap(button, frames) + game.input.pressQueue[#game.input.pressQueue + 1] = button + game.input.state[button] = true + U.wait(2) + game.input.state[button] = false + U.wait(frames or 4) + end + + U.wait(60) + local world = game.world + assert(world and world.map, "crystal world did not boot") + + shimWorldSeams(world) + ok(type(world.vm.specials.startTowerBattle) == "function", + "the shimmed startTowerBattle hook reached the VM") + ok(type(world.vm.specials.setObjectSprite) == "function", + "and so did setObjectSprite") + + local roster = BattleTower.roster(game.data) + ok(roster ~= nil, "trainers.lua carries the Battle Tower roster") + if roster then + say(("roster: %d trainers, %d groups of %d, sample ceiling %d") + :format(#roster.trainers, roster.levelGroups, roster.uniqueMon, + roster.sampleTrainers)) + end + + -- Three legal mons over the L10 room, each with ONE strong move so a blind + -- A-press run cannot pick a status move for a hundred turns. + local party = {} + for _, row in ipairs({ { "TYPHLOSION", 60, "FLAMETHROWER" }, + { "FERALIGATR", 60, "SURF", "BERRY" }, + { "MEGANIUM", 60, "BODY_SLAM", "GOLD_BERRY" } }) do + local def = game.data.moves[row[3]] + local mon = Mon.new(game.data, row[1], row[2], { + moves = { { id = row[3], pp = def.pp, maxPp = def.pp } }, + }) + mon.item = row[4] + party[#party + 1] = mon + end + game.save.party = party + + -- The desk's own SAVELEVELGROUP write, so the room the script walks into is + -- the L10 one (battle_tower.asm:1129-1141). + local tower = BattleTower.state(game.save) + tower.levelGroup = 1 + tower.streak = 0 + tower.trainers = {} + tower.challenge = BattleTower.NO_CHALLENGE + + while game.stack:top() do game.stack:pop() end + assert(world:setMap("BATTLE_TOWER_BATTLE_ROOM", 3, 7, "up"), + "no BATTLE_TOWER_BATTLE_ROOM in the cache") + U.wait(30) + shot("room-entered") + + -- The scene script walks the player in, draws the opponent and starts the + -- battle; nothing here presses anything until a text box wants it. + local sawOpponent, sawBattle = false, false + for _ = 1, 900 do + local vm = world.vm + if vm and vm.btOpponent and not sawOpponent then + sawOpponent = true + say(("opponent: %s %s (row %d), sprite %s, group %d") + :format(tostring(vm.btOpponent.classId), tostring(vm.btOpponent.name), + vm.btOpponent.index, tostring(vm.btOpponent.sprite), + vm.btOpponent.group)) + for slot, mon in ipairs(vm.btOpponent.rows) do + say((" mon %d: %s L%d %s"):format(slot, mon.species, mon.level, + tostring(mon.item))) + end + U.wait(20) + shot("opponent-walked-in") + end + if world.battleActive then + sawBattle = true + break + end + if world.textbox or world.choicebox then tap("a", 4) else U.wait(2) end + end + ok(sawOpponent, "special LoadOpponentTrainerAndPokemonWithOTSprite drew one") + ok(sawBattle, "special BattleTowerBattle pushed the battle screen") + + local screen, battle = nil, nil + if sawBattle then + U.wait(90) + shot("battle-open") + screen = game.stack:top() + battle = screen and screen.battle + ok(battle ~= nil, "the battle screen owns a Battle") + if battle then + ok(battle.inBattleTowerBattle == true, + "wInBattleTowerBattle is set, so DoBadgeTypeBoosts is off") + say("enemy trainer: " .. tostring(battle.trainer and battle.trainer.name)) + say("enemy lead: " .. tostring(battle.enemy and battle.enemy.species) + .. " L" .. tostring(battle.enemy and battle.enemy.level)) + ok(battle.trainer ~= nil and #battle.trainer.party == 3, + "with a three-mon Tower party") + end + end + + -- One press per look at the phase: FIGHT off the 2x2 menu, then the hardest + -- move with PP left. + local function hardestMove(mon) + local best, bestPower = 1, -1 + for index, move in ipairs((mon and mon.moves) or {}) do + local def = game.data.moves and game.data.moves[move.id] + local power = (def and def.power) or 0 + if (move.pp or 0) > 0 and power > bestPower then + best, bestPower = index, power + end + end + return best + end + + -- PP topped up between turns: an unattended run otherwise Struggles itself + -- to death long before the third opponent mon is down. + local function refill(mon) + for _, move in ipairs((mon and mon.moves) or {}) do + move.pp = move.maxPp or move.pp + end + end + + local menus = 0 + for _ = 1, 900 do + if not world.battleActive or (battle and battle.over) then break end + local phase = screen and screen.phase + if phase == "menu" then + menus = menus + 1 + if menus == 1 then shot("battle-menu") end + refill(battle and battle.player) + screen.menuIndex = 1 + tap("a", 4) + elseif phase == "moves" then + screen.menuIndex = hardestMove(battle and battle.player) + tap("a", 4) + elseif phase == "submenu" then + -- A forced switch cannot be cancelled, so walk off the fainted lead. + tap("down", 3) + tap("a", 4) + else + tap("a", 3) + end + end + if battle then + say("battle over=" .. tostring(battle.over) + .. " outcome=" .. tostring(battle.outcome) + .. " phase=" .. tostring(screen and screen.phase)) + ok(battle.over, "the battle resolved") + ok(battle.outcome == "win", "and the L60 party won it") + shot("battle-end") + end + for _ = 1, 200 do + if not world.battleActive then break end + tap("a", 3) + end + ok(not world.battleActive, "the battle screen came down") + U.wait(30) + shot("after-battle") + + local vm = world.vm + local state = BattleTower.state(game.save) + say(("streak=%d challenge=%d scriptVar=%s wNrOfBeaten=%s strbuf=%q") + :format(state.streak, state.challenge, tostring(vm and vm.scriptVar), + tostring(vm and vm.mem and vm.mem[BattleTower.WRAM_NR_BEATEN]), + tostring(vm and vm.stringBuffer))) + ok(state.streak >= 1, "ReadBTTrainerParty stepped the streak counter") + ok(state.challenge == BattleTower.CHALLENGE_IN_PROGRESS, + "and armed sBattleTowerChallengeState") + ok(#(state.trainers or {}) >= 1, "sBTTrainers recorded who was fought") + ok(state.prevTeams and #state.prevTeams.prev == 3, + "and sBTMonPrevTrainer recorded the team") + + -- The receptionist's heal, the "next up, opponent no. N" prompt and the + -- second draw all follow on their own. + local pages, secondDraw = {}, nil + for _ = 1, 400 do + local body = world.lastText + if type(body) == "string" and body ~= "" and pages[#pages] ~= body then + pages[#pages + 1] = body + if #pages <= 6 then shot("page-" .. #pages) end + end + if world.battleActive then + secondDraw = world.vm and world.vm.btOpponent + break + end + if world.choicebox then tap("a", 4) else tap("a", 3) end + end + say("pages: " .. table.concat(pages, " | ")) + local joined = table.concat(pages, " | ") + ok(joined:find("healed to full health", 1, true) ~= nil + or joined:find("Next up", 1, true) ~= nil, + "the room script carried on past the battle") + ok(joined:find("Next up, opponent\nno.2", 1, true) ~= nil + or joined:find("no.2", 1, true) ~= nil, + "and wStringBuffer3 named the SECOND opponent") + + if world.battleActive then + U.wait(60) + shot("second-battle") + ok(true, "the loop came back round for opponent two") + say("second opponent: " .. tostring(secondDraw and secondDraw.name)) + end + + say(("streak after round two: %d"):format(BattleTower.state(game.save).streak)) + say(fails == 0 and "PASS" or (fails .. " FAILURES")) + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/drivers/crystal_unown_chambers.lua b/tests/drivers/crystal_unown_chambers.lua new file mode 100644 index 00000000..79a61a29 --- /dev/null +++ b/tests/drivers/crystal_unown_chambers.lua @@ -0,0 +1,182 @@ +-- The four Ruins of Alph secret chambers, opened in a real game. +-- +-- POKEPORT_IDENTITY=f3-crystal POKEPORT_GAME=crystal \ +-- POKEPORT_VERSION=crystal POKEPORT_SHOT_DIR=/tmp/f3 \ +-- POKEPORT_DRIVER=tests/drivers/crystal_unown_chambers.lua love . + +local U = require("tests.drivers.util") + +local GameVersion = require("src.core.GameVersion") +local Mon = require("src.battle.gen2.Mon") +local UnownWords = require("src.world.gen2.UnownWords") + +-- ../pokecrystal/maps/RuinsOfAlphOmanyteChamber.asm:25 closed, :42 open; +-- ../pokecrystal/engine/overworld/scripting.asm:2147 halves them to block 2,0. +local WALL_BLOCK = 3 +local WALL_SHUT, WALL_OPEN = 0x2e, 0x30 + +local CHAMBERS = { + { + key = "OMANYTE", word = "WATER", + -- ../pokecrystal/engine/events/unown_walls.asm:25 CheckItem, :38 MON_ITEM + shut = function(game) + game.save.inventory = { FIRE_STONE = 1 } + game.save.party = { Mon.new(game.data, "TYPHLOSION", 40, + { item = "FIRE_STONE" }) } + end, + arm = function(game) + game.save.inventory = {} + game.save.party = { + Mon.new(game.data, "TYPHLOSION", 40), + Mon.new(game.data, "LANTURN", 30, { item = "WATER_STONE" }), + } + end, + armed = "a WATER STONE held by the last party mon", + }, + { + key = "HO_OH", word = "HO-OH", + -- ../pokecrystal/engine/events/unown_walls.asm:2 wPartySpecies[0]. + shut = function(game) + game.save.inventory = {} + game.save.party = { + Mon.new(game.data, "TYPHLOSION", 40), + Mon.new(game.data, "HO_OH", 70), + } + end, + arm = function(game) + game.save.party = { + Mon.new(game.data, "HO_OH", 70), + Mon.new(game.data, "TYPHLOSION", 40), + } + end, + armed = "HO-OH moved into the FIRST party slot", + }, + { + key = "KABUTO", word = "ESCAPE", + shut = function(game) + game.save.inventory = { ESCAPE_ROPE = 1 } + game.save.party = { Mon.new(game.data, "TYPHLOSION", 40) } + end, + -- ../pokecrystal/engine/events/overworld.asm:809 farcall + -- SpecialKabutoChamber, off EscapeRopeOrDig's rope arm. + arm = function(_, world) + UnownWords.kabutoChamber(world.events, world.map and world.map.id) + end, + armed = "SpecialKabutoChamber, as the escape rope calls it", + }, + { + key = "AERODACTYL", word = "LIGHT", + shut = function(game) + game.save.inventory = {} + game.save.party = { Mon.new(game.data, "TYPHLOSION", 40) } + end, + -- ../pokecrystal/engine/events/overworld.asm:285 farcall + -- SpecialAerodactylChamber, inside FlashFunction.CheckUseFlash. + arm = function(_, world) + UnownWords.aerodactylChamber(world.events, world.map and world.map.id) + end, + armed = "SpecialAerodactylChamber, as FLASH calls it", + }, +} + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/crystal-unown-chambers" + local fails = 0 + + local function say(line) print("[driver] " .. line) end + local function ok(cond, line) + if not cond then fails = fails + 1 end + say((cond and "OK " or "FAIL ") .. line) + end + + local function tap(button, frames) + game.input.pressQueue[#game.input.pressQueue + 1] = button + game.input.state[button] = true + U.wait(2) + game.input.state[button] = false + U.wait(frames or 4) + end + + local function wordScreen() + local state = game.stack:top() + return (state and getmetatable(state) == UnownWords) and state or nil + end + + U.wait(60) + local world = game.world + assert(world and world.map, "crystal world did not boot") + say("version=" .. GameVersion.get() .. " engine=" .. GameVersion.engine()) + + game.save.player = game.save.player or {} + game.save.player.name = game.save.player.name or "CHRIS" + game.save.player.id = game.save.player.id or 30000 + + local function enter(mapId) + world:setMap("RUINS_OF_ALPH_OUTSIDE", 8, 15, "down") + U.wait(20) + world.mapScenes[mapId] = 0 + world:setMap(mapId, 3, 1, "up") + for _ = 1, 400 do + U.wait(2) + if not world:busy() and not world.pendingSceneScript then break end + end + U.wait(30) + end + + local function blockNow() + local blocks = world.map and world.map.def and world.map.def.blocks + return blocks and blocks[WALL_BLOCK] + end + + for index, chamber in ipairs(CHAMBERS) do + local mapId = UnownWords.CHAMBER_MAPS[chamber.key] + local flag = UnownWords.WALL_OPENED[chamber.key] + say(("%d %s (flag %d)"):format(index, mapId, flag)) + world.events:set(flag, false) + + chamber.shut(game, world) + enter(mapId) + ok(world.map and world.map.id == mapId, " stood in the chamber") + ok(not world.events:get(flag), " the wall flag is still clear") + ok(blockNow() == WALL_SHUT, + (" and the hidden-doors callback drew the closed wall (%s)") + :format(tostring(blockNow()))) + U.shot(game, ("%s/%d-%s-shut.png"):format(out, index, + chamber.key:lower())) + + chamber.arm(game, world) + say(" armed: " .. chamber.armed) + world.mapScenes[mapId] = 0 + enter(mapId) + ok(world.events:get(flag), " the wall flag is set now") + ok(blockNow() == WALL_OPEN, + (" and the wall-open script rewrote the block (%s)") + :format(tostring(blockNow()))) + U.shot(game, ("%s/%d-%s-open.png"):format(out, index, + chamber.key:lower())) + + -- ../pokecrystal/maps/RuinsOfAlphOmanyteChamber.asm:82 + -- RuinsOfAlphOmanyteChamberWallPatternLeft + local screen + for _ = 1, 200 do + screen = wordScreen() + if screen then break end + tap("a", 2) + end + ok(screen ~= nil, " the wall pattern reached DisplayUnownWords") + if screen then + U.wait(20) + ok(screen.wall and screen.wall.word == chamber.word, + (" showing %s (got %s)"):format(chamber.word, + tostring(screen.wall and screen.wall.word))) + U.shot(game, ("%s/%d-%s-word.png"):format(out, index, + chamber.key:lower())) + tap("a", 10) + U.wait(20) + end + end + + say(fails == 0 and "ALL OK" or (fails .. " FAILURES")) + U.wait(10) + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/drivers/crystal_unown_words_shots.lua b/tests/drivers/crystal_unown_words_shots.lua new file mode 100644 index 00000000..7fdf47f5 --- /dev/null +++ b/tests/drivers/crystal_unown_words_shots.lua @@ -0,0 +1,104 @@ +-- The Ruins of Alph wall words and word rooms, driven in a real game. +-- +-- POKEPORT_IDENTITY=unit-e-unown POKEPORT_GAME=crystal \ +-- POKEPORT_VERSION=crystal POKEPORT_SHOT_DIR=/tmp/unit-e \ +-- POKEPORT_DRIVER=tests/drivers/crystal_unown_words_shots.lua love . + + +local U = require("tests.drivers.util") + +local GameVersion = require("src.core.GameVersion") +local Mon = require("src.battle.gen2.Mon") +local UnownWords = require("src.world.gen2.UnownWords") + +-- maps/RuinsOfAlphKabutoChamber.asm:272 `bg_event 4, 0, BGEVENT_UP` +local CHAMBERS = { + { map = "RUINS_OF_ALPH_KABUTO_CHAMBER", word = "ESCAPE" }, + { map = "RUINS_OF_ALPH_AERODACTYL_CHAMBER", word = "LIGHT" }, + { map = "RUINS_OF_ALPH_OMANYTE_CHAMBER", word = "WATER" }, + { map = "RUINS_OF_ALPH_HO_OH_CHAMBER", word = "HO-OH" }, +} + +local WORD_ROOMS = { + { map = "RUINS_OF_ALPH_KABUTO_WORD_ROOM", x = 9, y = 6 }, + { map = "RUINS_OF_ALPH_AERODACTYL_WORD_ROOM", x = 9, y = 6 }, + { map = "RUINS_OF_ALPH_OMANYTE_WORD_ROOM", x = 9, y = 6 }, + { map = "RUINS_OF_ALPH_HO_OH_WORD_ROOM", x = 9, y = 6 }, +} + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/crystal-unown-words" + local fails = 0 + + local function say(line) print("[driver] " .. line) end + local function ok(cond, line) + if not cond then fails = fails + 1 end + say((cond and "OK " or "FAIL ") .. line) + end + + local function tap(button, frames) + game.input.pressQueue[#game.input.pressQueue + 1] = button + game.input.state[button] = true + U.wait(2) + game.input.state[button] = false + U.wait(frames or 4) + end + + local function top() return game.stack:top() end + local function wordScreen() + local state = top() + return (state and getmetatable(state) == UnownWords) and state or nil + end + + U.wait(60) + local world = game.world + assert(world and world.map, "crystal world did not boot") + say("version=" .. GameVersion.get() .. " engine=" .. GameVersion.engine()) + + local save = game.save + save.player = save.player or {} + save.player.name = save.player.name or "CHRIS" + save.player.id = save.player.id or 30000 + save.party = { Mon.new(game.data, "TYPHLOSION", 40) } + + for index, chamber in ipairs(CHAMBERS) do + say("A" .. index .. " " .. chamber.map) + world:setMap(chamber.map, 4, 1, "up") + U.wait(30) + U.shot(game, ("%s/a%d-%s-chamber.png"):format( + out, index, chamber.word:lower())) + local screen + for _ = 1, 300 do + screen = wordScreen() + if screen then break end + if not world:busy() then tap("a", 2) else tap("a", 2) end + end + ok(screen ~= nil, chamber.map .. " reached DisplayUnownWords") + if screen then + U.wait(20) + ok(screen.wall and screen.wall.word == chamber.word, + (" showing %s (got %s)"):format(chamber.word, + tostring(screen.wall and screen.wall.word))) + ok(#screen.squares == #chamber.word, + (" %d letter squares"):format(#screen.squares)) + U.shot(game, ("%s/a%d-%s-word.png"):format( + out, index, chamber.word:lower())) + tap("a", 10) + ok(wordScreen() == nil, " A closes the box") + U.wait(20) + end + end + + for index, room in ipairs(WORD_ROOMS) do + say("B" .. index .. " " .. room.map) + world:setMap(room.map, room.x, room.y, "up") + U.wait(40) + ok(world.map and world.map.id == room.map, " stood in " .. room.map) + U.shot(game, ("%s/b%d-%s.png"):format(out, index, + room.map:lower():gsub("ruins_of_alph_", ""))) + end + + say(fails == 0 and "ALL OK" or (fails .. " FAILURES")) + U.wait(10) + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/drivers/gate_chamber_fieldmoves.lua b/tests/drivers/gate_chamber_fieldmoves.lua new file mode 100644 index 00000000..800f9653 --- /dev/null +++ b/tests/drivers/gate_chamber_fieldmoves.lua @@ -0,0 +1,119 @@ +-- The two chamber walls the FIELD MOVES open, driven through the production +-- call sites rather than by calling the routines directly. +-- +-- ../pokecrystal/engine/events/overworld.asm:280-291 FlashFunction.CheckUseFlash +-- (badge FIRST, then SpecialAerodactylChamber) and :808-813 EscapeRopeOrDig's +-- `.escaperope` arm. +-- +-- POKEPORT_GAME=crystal POKEPORT_VERSION=crystal \ +-- POKEPORT_DRIVER=tests/drivers/gate_chamber_fieldmoves.lua love . +local U = require("tests.drivers.util") + +local Mon = require("src.battle.gen2.Mon") +local UnownWords = require("src.world.gen2.UnownWords") + +return function(game) + local fails = 0 + local function say(line) print("[driver] " .. line); io.stdout:flush() end + local function ok(cond, line) + if not cond then fails = fails + 1 end + say((cond and "OK " or "FAIL ") .. line) + end + + U.wait(60) + local world = game.world + assert(world and world.map, "crystal world did not boot") + + local function tap(button, frames) + game.input.pressQueue[#game.input.pressQueue + 1] = button + game.input.state[button] = true + U.wait(2) + game.input.state[button] = false + U.wait(frames or 4) + end + + local function clearText() + for _ = 1, 200 do + if not world:busy() then return true end + tap("a", 3) + end + return false + end + + local function enter(mapId) + while game.stack:top() do game.stack:pop() end + assert(world:setMap(mapId, 3, 7, "up"), "no " .. mapId .. " in this cache") + for _ = 1, 240 do + if not world:busy() then break end + U.wait(2) + end + U.wait(10) + end + + -- ---- the escape rope, in the Kabuto chamber ------------------------------ + say("--- KABUTO, through World:useEscapeRope()") + enter(UnownWords.CHAMBER_MAPS.KABUTO) + ok(not UnownWords.wallOpened(world.events, "KABUTO"), + "the Kabuto wall flag starts clear") + game.save.inventory = { ESCAPE_ROPE = 1 } + -- wDigWarpNumber / wBackupMapGroup: the cave's own entrance, which + -- ../pokecrystal/engine/events/overworld.asm:795-798 copies into wNextWarp. + world.backupWarp = { map = "RUINS_OF_ALPH_OUTSIDE", warp = 1 } + clearText() + local rope = world:useEscapeRope("ESCAPE_ROPE") + say("escape rope result: " .. tostring(rope)) + ok(rope == "escape_rope", "the rope was used") + ok(UnownWords.wallOpened(world.events, "KABUTO"), + "and SpecialKabutoChamber set the wall flag") + + -- The rope must not open a wall anywhere else. + world.queuedFieldMove = nil + enter("RUINS_OF_ALPH_OUTSIDE") + clearText() + local before = UnownWords.wallOpened(world.events, "OMANYTE") + game.save.inventory = { ESCAPE_ROPE = 1 } + world:useEscapeRope("ESCAPE_ROPE") + ok(UnownWords.wallOpened(world.events, "OMANYTE") == before, + "a rope used outside a chamber opens nothing") + + -- ---- FLASH, in the Aerodactyl chamber ------------------------------------ + say("--- AERODACTYL, through World:useFieldMove(\"FLASH\")") + enter(UnownWords.CHAMBER_MAPS.AERODACTYL) + ok(world.map.id == UnownWords.CHAMBER_MAPS.AERODACTYL, "in the chamber") + ok(not UnownWords.wallOpened(world.events, "AERODACTYL"), + "the wall flag starts clear") + + local flash = game.data.moves.FLASH + game.save.party = { Mon.new(game.data, "TYPHLOSION", 40, + { moves = { { id = "FLASH", pp = flash.pp, maxPp = flash.pp } } }) } + + -- :281-283, the ZEPHYRBADGE gate that runs BEFORE the special. + game.save.player = game.save.player or {} + game.save.player.badges = {} + local refused = world:useFieldMove("FLASH", game.save.party[1]) + ok(refused and refused.ok ~= true, "no ZEPHYRBADGE: FLASH is refused") + ok(refused and refused.badge == "ZEPHYR", "on the badge, not on the map") + ok(not UnownWords.wallOpened(world.events, "AERODACTYL"), + "and the badgeless press did NOT open the wall") + + -- The refusal opened a text box; the world is busy until it is dismissed. + clearText() + game.save.player.badges = { ZEPHYR = true } + local used = world:useFieldMove("FLASH", game.save.party[1]) + say("flash result: ok=" .. tostring(used and used.ok) + .. " action=" .. tostring(used and used.action)) + ok(used and used.ok == true, + "with the badge FLASH is allowed in a chamber that is not a dark cave") + ok(UnownWords.wallOpened(world.events, "AERODACTYL"), + "and SpecialAerodactylChamber set the wall flag") + + enter(UnownWords.CHAMBER_MAPS.AERODACTYL) + U.wait(40) + local _, block = world:blockIndexAt(3, 5) + say("aerodactyl block under the wall: " .. tostring(block)) + U.shot(game, (os.getenv("POKEPORT_SHOT_DIR") or "/tmp") + .. "/01-aerodactyl-flash-open.png") + + say(fails == 0 and "PASS" or (fails .. " FAILURES")) + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/drivers/gate_gold_untouched.lua b/tests/drivers/gate_gold_untouched.lua new file mode 100644 index 00000000..e373334d --- /dev/null +++ b/tests/drivers/gate_gold_untouched.lua @@ -0,0 +1,80 @@ +-- Gate check: the Crystal wave left Gold alone. No Battle Tower, no Buena's +-- Blue Card, no Ruins of Alph secret chambers, and the three beasts roam. +-- +-- POKEPORT_GAME=gold POKEPORT_VERSION=gold \ +-- POKEPORT_DRIVER=tests/drivers/gate_gold_untouched.lua love . +local U = require("tests.drivers.util") + +local Battle = require("src.battle.gen2.Battle") +local Roamers = require("src.core.gen2.Roamers") + +return function(game) + local fails = 0 + local function say(line) print("[driver] " .. line); io.stdout:flush() end + local function ok(cond, line) + if not cond then fails = fails + 1 end + say((cond and "OK " or "FAIL ") .. line) + end + + U.wait(60) + local world = game.world + assert(world and world.map, "gold world did not boot") + say("version=" .. tostring(game.version) .. " map=" .. tostring(world.map.id)) + + local order = {} + for _, name in ipairs((world.constants or {}).specialOrder or {}) do + order[name] = true + end + ok(next(order) ~= nil, "the Gold cache names its specials") + for _, name in ipairs({ "BattleTowerBattle", "BattleTowerAction", + "BattleTowerRoomMenu", "LoadOpponentTrainerAndPokemonWithOTSprite", + "BuenasPassword", "BuenaPrize", "AskRememberPassword", + "OmanyteChamber", "HoOhChamber", "CelebiShrineEvent", + "SampleKenjiBreakCountdown", "MoveTutor", "PokeSeer" }) do + ok(not order[name], "Gold has no " .. name .. " special") + end + + ok(world.maps.BATTLE_TOWER_1F == nil, "no BATTLE_TOWER_1F map") + ok(world.maps.BATTLE_TOWER_BATTLE_ROOM == nil, "no tower battle room") + ok((world.trainers or {}).battleTower == nil, + "trainers.lua carries no battleTower roster") + ok((world.eventTables or {}).unownWalls == nil, + "events.lua carries no unownWalls table") + + -- ../pokecrystal/constants/event_flags.asm:486-489 are Crystal-only. + for _, flag in ipairs({ 806, 807, 808, 809 }) do + ok(not world.events:get(flag), "wall-opened flag " .. flag .. " is clear") + end + + -- ../pokecrystal/constants/script_constants.asm:69-74, Crystal-only VARs. + for _, id in ipairs({ 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a }) do + ok(world:readVar(id) == 0, + ("VAR $%02x reads 0 on Gold"):format(id)) + end + + -- data/wild/roammon_maps.asm: RAIKOU, ENTEI and SUICUNE, all three. + local roster = Roamers.roster(world.encounters) + say("roamer roster: " .. #roster .. " rows") + for _, row in ipairs(roster) do + say((" %s L%s start=%s"):format(tostring(row.species), + tostring(row.level), tostring(row.map))) + end + ok(#roster == 3, "three beasts in the roster") + local list = Roamers.init(game.save, { encounters = world.encounters, force = true }) + local live = 0 + for _, slot in ipairs(list or {}) do + if Roamers.active(slot) then live = live + 1 end + end + ok(live == 3, "all three are active after InitRoamMons") + + -- pokegold/engine/battle/core.asm:3476-3479: TRAP and FORCESHINY only. + ok(Battle.noEscapeBattleType({ battleType = 9 }) == true, "TRAP: no escape") + ok(Battle.noEscapeBattleType({ battleType = 7 }) == true, + "FORCESHINY: no escape") + ok(Battle.noEscapeBattleType({ battleType = 10 }) == false, + "FORCEITEM: Lugia and Ho-Oh can still be run from on Gold") + ok(Battle.noEscapeBattleType({ battleType = 8 }) == false, "TREE: escapable") + + say(fails == 0 and "PASS" or (fails .. " FAILURES")) + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/drivers/gate_tower_noshim.lua b/tests/drivers/gate_tower_noshim.lua new file mode 100644 index 00000000..15994fe3 --- /dev/null +++ b/tests/drivers/gate_tower_noshim.lua @@ -0,0 +1,236 @@ +-- maps/BattleTowerBattleRoom.asm's own loop, driven in a real game: the +-- opponent draw, the walk-in, the battle, and the counter the room script +-- reads back. +-- +-- Nothing is shimmed: the startTowerBattle / setObjectSprite hooks and the +-- battleTower field all come from World itself. +-- +-- POKEPORT_GAME=crystal POKEPORT_VERSION=crystal \ +-- POKEPORT_SHOT_DIR=/tmp/tower-battle \ +-- POKEPORT_DRIVER=tests/drivers/gate_tower_noshim.lua love . +local U = require("tests.drivers.util") + +local BattleTower = require("src.core.gen2.BattleTower") +local Mon = require("src.battle.gen2.Mon") + +-- ---- the run -------------------------------------------------------------- + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/crystal-tower-battle" + local fails, shots = 0, 0 + + local function say(line) print("[driver] " .. line) end + local function ok(cond, line) + if not cond then fails = fails + 1 end + say((cond and "OK " or "FAIL ") .. line) + end + local function shot(name) + shots = shots + 1 + U.shot(game, ("%s/%02d-%s.png"):format(out, shots, name)) + end + local function tap(button, frames) + game.input.pressQueue[#game.input.pressQueue + 1] = button + game.input.state[button] = true + U.wait(2) + game.input.state[button] = false + U.wait(frames or 4) + end + + U.wait(60) + local world = game.world + assert(world and world.map, "crystal world did not boot") + + ok(type(world.vm.specials.startTowerBattle) == "function", + "World:specialHooks supplies startTowerBattle, unshimmed") + ok(type(world.vm.specials.setObjectSprite) == "function", + "World:specialHooks supplies setObjectSprite, unshimmed") + + local roster = BattleTower.roster(game.data) + ok(roster ~= nil, "trainers.lua carries the Battle Tower roster") + if roster then + say(("roster: %d trainers, %d groups of %d, sample ceiling %d") + :format(#roster.trainers, roster.levelGroups, roster.uniqueMon, + roster.sampleTrainers)) + end + + -- Three legal mons over the L10 room, each with ONE strong move so a blind + -- A-press run cannot pick a status move for a hundred turns. + local party = {} + for _, row in ipairs({ { "TYPHLOSION", 60, "FLAMETHROWER" }, + { "FERALIGATR", 60, "SURF", "BERRY" }, + { "MEGANIUM", 60, "BODY_SLAM", "GOLD_BERRY" } }) do + local def = game.data.moves[row[3]] + local mon = Mon.new(game.data, row[1], row[2], { + moves = { { id = row[3], pp = def.pp, maxPp = def.pp } }, + }) + mon.item = row[4] + party[#party + 1] = mon + end + game.save.party = party + + -- The desk's own SAVELEVELGROUP write, so the room the script walks into is + -- the L10 one (battle_tower.asm:1129-1141). + local tower = BattleTower.state(game.save) + tower.levelGroup = 1 + tower.streak = 0 + tower.trainers = {} + tower.challenge = BattleTower.NO_CHALLENGE + + while game.stack:top() do game.stack:pop() end + assert(world:setMap("BATTLE_TOWER_BATTLE_ROOM", 3, 7, "up"), + "no BATTLE_TOWER_BATTLE_ROOM in the cache") + U.wait(30) + shot("room-entered") + + -- The scene script walks the player in, draws the opponent and starts the + -- battle; nothing here presses anything until a text box wants it. + local sawOpponent, sawBattle = false, false + for _ = 1, 900 do + local vm = world.vm + if vm and vm.btOpponent and not sawOpponent then + sawOpponent = true + say(("opponent: %s %s (row %d), sprite %s, group %d") + :format(tostring(vm.btOpponent.classId), tostring(vm.btOpponent.name), + vm.btOpponent.index, tostring(vm.btOpponent.sprite), + vm.btOpponent.group)) + for slot, mon in ipairs(vm.btOpponent.rows) do + say((" mon %d: %s L%d %s"):format(slot, mon.species, mon.level, + tostring(mon.item))) + end + U.wait(20) + shot("opponent-walked-in") + end + if world.battleActive then + sawBattle = true + break + end + if world.textbox or world.choicebox then tap("a", 4) else U.wait(2) end + end + ok(sawOpponent, "special LoadOpponentTrainerAndPokemonWithOTSprite drew one") + ok(sawBattle, "special BattleTowerBattle pushed the battle screen") + + local screen, battle = nil, nil + if sawBattle then + U.wait(90) + shot("battle-open") + screen = game.stack:top() + battle = screen and screen.battle + ok(battle ~= nil, "the battle screen owns a Battle") + if battle then + ok(battle.inBattleTowerBattle == true, + "wInBattleTowerBattle is set, so DoBadgeTypeBoosts is off") + say("enemy trainer: " .. tostring(battle.trainer and battle.trainer.name)) + say("enemy lead: " .. tostring(battle.enemy and battle.enemy.species) + .. " L" .. tostring(battle.enemy and battle.enemy.level)) + ok(battle.trainer ~= nil and #battle.trainer.party == 3, + "with a three-mon Tower party") + end + end + + -- One press per look at the phase: FIGHT off the 2x2 menu, then the hardest + -- move with PP left. + local function hardestMove(mon) + local best, bestPower = 1, -1 + for index, move in ipairs((mon and mon.moves) or {}) do + local def = game.data.moves and game.data.moves[move.id] + local power = (def and def.power) or 0 + if (move.pp or 0) > 0 and power > bestPower then + best, bestPower = index, power + end + end + return best + end + + -- PP topped up between turns: an unattended run otherwise Struggles itself + -- to death long before the third opponent mon is down. + local function refill(mon) + for _, move in ipairs((mon and mon.moves) or {}) do + move.pp = move.maxPp or move.pp + end + end + + local menus = 0 + for _ = 1, 900 do + if not world.battleActive or (battle and battle.over) then break end + local phase = screen and screen.phase + if phase == "menu" then + menus = menus + 1 + if menus == 1 then shot("battle-menu") end + refill(battle and battle.player) + screen.menuIndex = 1 + tap("a", 4) + elseif phase == "moves" then + screen.menuIndex = hardestMove(battle and battle.player) + tap("a", 4) + elseif phase == "submenu" then + -- A forced switch cannot be cancelled, so walk off the fainted lead. + tap("down", 3) + tap("a", 4) + else + tap("a", 3) + end + end + if battle then + say("battle over=" .. tostring(battle.over) + .. " outcome=" .. tostring(battle.outcome) + .. " phase=" .. tostring(screen and screen.phase)) + ok(battle.over, "the battle resolved") + ok(battle.outcome == "win", "and the L60 party won it") + shot("battle-end") + end + for _ = 1, 200 do + if not world.battleActive then break end + tap("a", 3) + end + ok(not world.battleActive, "the battle screen came down") + U.wait(30) + shot("after-battle") + + local vm = world.vm + local state = BattleTower.state(game.save) + say(("streak=%d challenge=%d scriptVar=%s wNrOfBeaten=%s strbuf=%q") + :format(state.streak, state.challenge, tostring(vm and vm.scriptVar), + tostring(vm and vm.mem and vm.mem[BattleTower.WRAM_NR_BEATEN]), + tostring(vm and vm.stringBuffer))) + ok(state.streak >= 1, "ReadBTTrainerParty stepped the streak counter") + ok(state.challenge == BattleTower.CHALLENGE_IN_PROGRESS, + "and armed sBattleTowerChallengeState") + ok(#(state.trainers or {}) >= 1, "sBTTrainers recorded who was fought") + ok(state.prevTeams and #state.prevTeams.prev == 3, + "and sBTMonPrevTrainer recorded the team") + + -- The receptionist's heal, the "next up, opponent no. N" prompt and the + -- second draw all follow on their own. + local pages, secondDraw = {}, nil + for _ = 1, 400 do + local body = world.lastText + if type(body) == "string" and body ~= "" and pages[#pages] ~= body then + pages[#pages + 1] = body + if #pages <= 6 then shot("page-" .. #pages) end + end + if world.battleActive then + secondDraw = world.vm and world.vm.btOpponent + break + end + if world.choicebox then tap("a", 4) else tap("a", 3) end + end + say("pages: " .. table.concat(pages, " | ")) + local joined = table.concat(pages, " | ") + ok(joined:find("healed to full health", 1, true) ~= nil + or joined:find("Next up", 1, true) ~= nil, + "the room script carried on past the battle") + ok(joined:find("Next up, opponent\nno.2", 1, true) ~= nil + or joined:find("no.2", 1, true) ~= nil, + "and wStringBuffer3 named the SECOND opponent") + + if world.battleActive then + U.wait(60) + shot("second-battle") + ok(true, "the loop came back round for opponent two") + say("second opponent: " .. tostring(secondDraw and secondDraw.name)) + end + + say(("streak after round two: %d"):format(BattleTower.state(game.save).streak)) + say(fails == 0 and "PASS" or (fails .. " FAILURES")) + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/drivers/gold_battle_bg_bug1709_test.lua b/tests/drivers/gold_battle_bg_bug1709_test.lua new file mode 100644 index 00000000..742f0fdb --- /dev/null +++ b/tests/drivers/gold_battle_bg_bug1709_test.lua @@ -0,0 +1,278 @@ +-- #1709: BATTLE BG on Gold, the WHITE / BLACK void around the battle screen, +-- and whether it survives a menu opened over the battle. The cart has no such +-- setting; maps/Route29.asm:432 is only where this parks the player. +-- +-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/gold_battle_bg_bug1709_test.lua \ +-- POKEPORT_SHOT_DIR=/tmp/gold-battle-bg love . +-- +-- No POKEPORT_SPEED: the shots land a fixed number of frames after a +-- transition, and a logic clock running ahead of the render moves them. +local U = require("tests.drivers.util") + +local Chrome = require("src.ui.gen2.Chrome") +local Mon = require("src.battle.gen2.Mon") +local OptionsMenu = require("src.ui.gen2.OptionsMenu") +local Permissions = require("src.world.gen2.Permissions") +local Save = require("src.core.gen2.Save") + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-battle-bg" + local failures = 0 + + local function ok(label, condition, detail) + if condition then + print("[battlebg] ok " .. label) + else + failures = failures + 1 + print("[battlebg] FAIL " .. label .. " " .. tostring(detail)) + end + end + + local function tap(button, frames) + game.input.pressQueue[#game.input.pressQueue + 1] = button + game.input.state[button] = true + U.wait(2) + game.input.state[button] = false + U.wait(frames or 6) + end + + local function shot(path) + if not U.shot(game, path) then failures = failures + 1 end + end + + U.wait(45) + local world = game.world + assert(world and world.map, "gold world did not boot") + + -- ---- what the eye cannot check ----------------------------------------- + -- Each of these fails the same way the bug did: the void just stays white. + ok("battleBg has a default in the Gold options table", + Save.DEFAULT_OPTIONS.battleBg == "white", Save.DEFAULT_OPTIONS.battleBg) + + local bgRow + for i, row in ipairs(OptionsMenu.ROWS) do + if row.label == "BATTLE BG" then bgRow = i end + end + ok("OPTION carries a BATTLE BG row", bgRow ~= nil, bgRow) + if bgRow then + ok("and it is the last row before CANCEL", + OptionsMenu.ROWS[bgRow + 1] and OptionsMenu.ROWS[bgRow + 1].cancel == true, + bgRow) + end + + local battleModule = require("src.ui.gen2.BattleState") + ok("the Gold battle screen answers bgMode", + type(battleModule.bgMode) == "function", type(battleModule.bgMode)) + ok("and Game2 owns the repaint, not the battle screen", + type(game.paintBattleSurround) == "function", + type(game.paintBattleSurround)) + + -- At the letterbox size there is no void, so nothing below would show. + if love.window and love.window.setMode then + love.window.setMode(1280, 840, { resizable = true }) + U.wait(6) + end + local winW, winH = love.graphics.getDimensions() + local scale = Chrome.fitScale(winW, winH) + local ox, oy = Chrome.fitOrigin(winW, winH, scale) + ok(("the window leaves a void to look at (%dx%d, panel at %d,%d x%d)") + :format(winW, winH, ox, oy, scale), ox > 8 and oy > 8, ox .. "," .. oy) + + local player = Mon.new(game.data, "CYNDAQUIL", 12) + local wild = Mon.new(game.data, "PIDGEY", 4) + ok("CYNDAQUIL builds from the extracted tables", + player ~= nil and #player.moves > 0, player and #player.moves) + ok("and so does the wild PIDGEY", wild ~= nil and #wild.moves > 0, + wild and #wild.moves) + game.save.party = { player } + game.save.inventory = { POKE_BALL = 5, POTION = 3 } + + -- maps/Route29.asm:432, the teacher's WALK_LEFT_RIGHT lane, so (15,11) and + -- its neighbours are floor; a map edit that moves it falls back below. + assert(world:setMap("ROUTE_29", 15, 11, "down"), "setMap ROUTE_29 failed") + U.wait(8) + if not Permissions.isWalkable(world:playerCollision()) then + for _, step in ipairs({ { 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 }, + { 2, 0 }, { -2, 0 } }) do + if world:setMap("ROUTE_29", 15 + step[1], 11 + step[2], "down") + and Permissions.isWalkable(world:playerCollision()) then + break + end + end + U.wait(8) + end + ok("the player is standing on floor, not in a wall", + Permissions.isWalkable(world:playerCollision()), + tostring(world:playerCollision())) + + print(failures == 0 + and "[battlebg] preflight PASS -- the shots below are worth looking at" + or ("[battlebg] preflight FAIL (%d) -- fix these before judging a pixel") + :format(failures)) + + -- ---- the run ----------------------------------------------------------- + -- Start from WHITE whatever the gold-dev identity was left on, so the first + -- half of the run is the same every time. + game.options.battleBg = "white" + shot(out .. "/00-route29.png") + + -- OPTION, walked to off the START menu the way a player gets there. + tap("start") + local startMenu = game.stack:top() + ok("START opened the menu", startMenu ~= nil and startMenu.items ~= nil, + startMenu) + if startMenu and startMenu.list then + for _ = 1, #startMenu.items do + local item = startMenu.items[startMenu.list.index] + if item and item.value == "option" then break end + tap("down", 3) + end + local landed = startMenu.items[startMenu.list.index] + ok("the cursor found OPTION", landed and landed.value == "option", + landed and landed.value) + end + tap("a", 10) + + local options = game.stack:top() + ok("the OPTION screen is up", options ~= nil and options.rows ~= nil, options) + if options then + -- UP from the first row wraps onto CANCEL, so BATTLE BG is two presses + -- away however many rows the build has. + for _ = 1, #options.rows do + if options:row() and options:row().key == "battleBg" then break end + tap("up", 3) + end + local row = options:row() + ok("the cursor is on BATTLE BG", row and row.key == "battleBg", + row and row.label) + U.log("01: the OPTION screen, cursor on BATTLE BG. it should read WHITE,") + U.log("sitting under MAX FPS and above CANCEL.") + shot(out .. "/01-option-white.png") + tap("right", 6) + ok("right stored black", game.options.battleBg == "black", + game.options.battleBg) + U.log("02: same row after one press right. it should read BLACK, and no") + U.log("other row on screen should have changed.") + shot(out .. "/02-option-black.png") + end + -- Gold plays with an empty stack: the world is not a state, so "back on the + -- map" is nothing on top rather than the overworld being on top. + tap("b", 10) + for _ = 1, 20 do + if game.stack:top() == nil then break end + tap("b", 4) + end + ok("the menus closed", game.stack:top() == nil, game.stack:top()) + + U.log("03: Route 29 with BATTLE BG on BLACK. the overworld is not a battle,") + U.log("so there must be NO black bars here -- the surround around the map is") + U.log("whatever VOID FILL draws, exactly as it was before.") + shot(out .. "/03-route29-black-set.png") + + -- ---- the battle -------------------------------------------------------- + assert(world:startBattle({ wild = wild }), "startBattle failed") + local battle + for _ = 1, 900 do + local top = game.stack:top() + if top and top.battle then battle = top break end + U.wait(1) + end + ok("the battle screen came up after the transition", battle ~= nil, battle) + if not battle then + print(("[battlebg] FAIL no battle to shoot (%d)"):format(failures)) + while true do coroutine.yield() end + end + for _ = 1, 150 do + if battle.phase == "menu" then break end + tap("a", 2) + end + ok("the battle reached the FIGHT menu", battle.phase == "menu", battle.phase) + ok("and it reports the black surround", + battle:bgMode() == "black", battle:bgMode()) + U.wait(10) + + U.log("04: the battle on BLACK. the void on all four sides of the GB screen") + U.log("is solid black; the battle's own field, HUD and message box stay") + U.log("white. black creeping over the panel edge -- a dark frame eating a") + U.log("row of the HUD or the box border -- is the band maths being wrong.") + shot(out .. "/04-battle-black.png") + + -- The 2x2 is FIGHT, / PACK, RUN. Clamp onto FIGHT first and step + -- from there: one cell off is RUN, which ends the battle instead of a menu. + local function pointAt(index) + tap("left", 3) + tap("up", 3) + if index == 2 or index == 4 then tap("right", 3) end + if index == 3 or index == 4 then tap("down", 3) end + return battle.menuIndex == index + end + + ok("the cursor is on PKMN", pointAt(2), battle.menuIndex) + tap("a", 12) + ok("the party list opened over the battle", battle.phase == "submenu", + battle.phase) + U.log("05: the party list over the same battle. this is the one that used to") + U.log("go wrong: the surround must still be black. white returning the") + U.log("instant the list goes up means the repaint is on the wrong layer.") + shot(out .. "/05-party-over-black.png") + tap("b", 12) + + ok("the cursor is on PACK", pointAt(3), battle.menuIndex) + tap("a", 14) + U.log("06: the PACK over the battle, same rule -- still black behind it.") + shot(out .. "/06-pack-over-black.png") + tap("b", 12) + for _ = 1, 20 do + if battle.phase == "menu" then break end + tap("b", 4) + end + + -- SCREEN POS moves the panel off centre, which is the other way the bands + -- can stop lining up with where the panel actually landed. + local positions = { + { 7, "upper", "the panel sits high, so the black above it is thin and", + "the band below is deep. both stop dead at the panel edge." }, + { 8, "top", "the panel is flush with the top, so there is no black", + "above it at all. a band up there means the lift was ignored." }, + } + for _, pos in ipairs(positions) do + game.options.screenPos = pos[2] + game:applyOptions() + U.wait(8) + local px, py = Chrome.fitOrigin(love.graphics.getDimensions()) + U.log(("0%d: SCREEN POS %s, panel at %d,%d."):format( + pos[1], pos[2], px, py)) + U.log(pos[3]) + U.log(pos[4]) + shot(out .. ("/0%d-screenpos-%s.png"):format(pos[1], pos[2])) + end + game.options.screenPos = "center" + game:applyOptions() + U.wait(8) + + -- The no-regression half: WHITE has to look exactly like it always did. + game.options.battleBg = "white" + U.wait(6) + ok("and back to white", battle:bgMode() == "white", battle:bgMode()) + U.log("09: the same battle flipped back to WHITE. paper white all the way to") + U.log("the window edge, no seam where the panel ends -- compare it with 04.") + shot(out .. "/09-battle-white.png") + + ok("back on PKMN for the white pair", pointAt(2), battle.menuIndex) + tap("a", 12) + U.log("10: the party list over the white battle, for the same comparison.") + shot(out .. "/10-party-over-white.png") + tap("b", 12) + + game.options.battleBg = "black" + U.wait(6) + + print(failures == 0 and "[battlebg] PASS gold_battle_bg_bug1709" + or ("[battlebg] FAIL gold_battle_bg_bug1709 (%d)"):format(failures)) + U.log("the battle is still up on BLACK and the controls are yours. leaving") + U.log("the OPTION screen wrote BLACK into the gold-dev options.lua, so the") + U.log("next boot starts there; set it back from OPTION if you want white.") + + while true do coroutine.yield() end +end diff --git a/tests/drivers/gold_cycling_road_bug1718_1749_test.lua b/tests/drivers/gold_cycling_road_bug1718_1749_test.lua new file mode 100644 index 00000000..ee3d17b8 --- /dev/null +++ b/tests/drivers/gold_cycling_road_bug1718_1749_test.lua @@ -0,0 +1,274 @@ +-- #1718 / #1749: START while the Cycling Road rolls the bike downhill, and SURF +-- refused there. Assertion driver -- every line it prints is PASS or FAIL. +-- Covers the halves tests/gen2_cycling_road_test.lua cannot: Game2's joypad +-- latch, which needs a real Input, and the A press at the water. +-- ../pokegold/maps/Route17.asm:11-16, engine/overworld/events.asm:193-231. +-- Never add POKEPORT_SPEED: which fixed step a press lands on IS the subject. +-- +-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/gold_cycling_road_bug1718_1749_test.lua \ +-- POKEPORT_SHOT_DIR=/tmp/gold-cycling-road \ +-- perl -e 'alarm 240; exec @ARGV' \ +-- python3 -c "import pty; pty.spawn(['love','.'])" +local U = require("tests.drivers.util") + +local FieldMoves = require("src.world.gen2.FieldMoves") +local Mon = require("src.battle.gen2.Mon") +local Permissions = require("src.world.gen2.Permissions") +local Screens = require("src.ui.Screens") + +-- The lane: x=9 is road, x=10 the water beside it, and Route 17's four bikers +-- stand at (4,17) (16,32) (3,53) (6,80) (../pokegold/maps/Route17.asm:147-150). +local ROAD = { map = "ROUTE_17", x = 9, y = 36 } +-- The control, a route with no ALWAYS_ON_BIKE on it: the shore south of the +-- Union Cave mouth (../pokegold/maps/Route32.asm:861 is the nearest object). +local SEA = { map = "ROUTE_32", x = 11, y = 41 } + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-cycling-road" + local fails = 0 + + local function report(ok, line) + if not ok then fails = fails + 1 end + U.log((ok and "PASS " or "FAIL ") .. line) + return ok + end + + U.wait(45) + local world = game.world + if not (world and world.map) then + U.log("FAIL gold world did not boot; nothing below ran") + while true do coroutine.yield() end + end + local save = game.save + + -- --------------------------------------------------------------- helpers + + local function tap(button, frames) + game.input.pressQueue[#game.input.pressQueue + 1] = button + game.input.state[button] = true + U.wait(2) + game.input.state[button] = false + U.wait(frames or 6) + end + + -- One edge then a sustained hold: re-queueing every frame would hand the + -- gate a fresh edge on the landing tick and prove nothing. + local function holdUntilMenu(button, frames) + game.input.pressQueue[#game.input.pressQueue + 1] = button + game.input.state[button] = true + local opened = false + for _ = 1, frames do + coroutine.yield() + if game.stack:top() then opened = true break end + end + game.input.state[button] = false + U.wait(2) + return opened + end + + local function tapAndRelease(button) + game.input.pressQueue[#game.input.pressQueue + 1] = button + game.input.state[button] = true + coroutine.yield() + game.input.state[button] = false + end + + local function closeMenus() + for _ = 1, 40 do + if not game.stack:top() then return true end + tap("b", 4) + end + return game.stack:top() == nil + end + + local function dismiss() + for _ = 1, 80 do + if not (world.textbox or world.choicebox or world:busy()) then return true end + if world.choicebox then tap("b", 4) else tap("a", 4) end + end + return not (world.textbox or world.choicebox) + end + + -- Put the roll back at the top of the lane, standing, so every trial starts + -- from the same step phase and none of them drifts into a biker. + local function park(x, y, facing) + local p = world.player + p.moving = false + p.progress = 0 + p.targetX, p.targetY = nil, nil + p.turnTimer = 0 + p.cellX, p.cellY = x, y + p.px, p.py = x * 16, y * 16 + p.facing = facing or "down" + world.stepFinished = false + world.heldDir = nil + end + + -- ---- preflight: everything here fails the same silent way the bugs do ---- + + local okScreen = pcall(Screens.get, game, "Gen2StartMenu") + report(okScreen, "the Gen2StartMenu screen id resolves") + + local badges = save.player.badges or {} + save.player.badges = badges + badges[FieldMoves.BADGE.SURF] = true + report(FieldMoves.hasBadge(save, FieldMoves.BADGE.SURF), "the FOGBADGE is on") + + local swimmer = Mon.new(game.data, "LAPRAS", 30, { moves = { { id = "SURF" } } }) + report(swimmer ~= nil, "the cache builds a LAPRAS") + save.party = { swimmer } + report(FieldMoves.partyMoveUser(save.party, "SURF") ~= nil, + "and it knows SURF, so CheckPartyMove has a mon to find") + + report(world:setMap(ROAD.map, ROAD.x, ROAD.y, "down") ~= false, + "ROUTE_17 loads") + U.wait(10) + report(world.map.id == ROAD.map, "and the world is on it") + report(world:alwaysOnBike() == true, + "MAPCALLBACK_NEWMAP set ALWAYS_ON_BIKE") + report(world:downhill() == true, "and DOWNHILL") + report(FieldMoves.isBiking(world.playerState), + "CheckUpdatePlayerSprite put the player on the bike") + + for cy = ROAD.y, ROAD.y + 12 do + local road = Permissions.isLand(world.map:cellCollision(ROAD.x, cy)) + local sea = Permissions.isWater(world.map:cellCollision(ROAD.x + 1, cy)) + if not (road and sea) then + report(false, ("ROUTE_17 (%d,%d) is no longer road-beside-water") + :format(ROAD.x, cy)) + break + end + end + report(true, "the lane is road all the way down with water on its right") + + local clear = true + for _, npc in ipairs(world.npcs or {}) do + if npc.cellX == ROAD.x and npc.cellY >= ROAD.y - 2 + and npc.cellY <= ROAD.y + 14 then + clear = false + end + end + report(clear, "and no biker is parked in it") + + if fails > 0 then + U.log("preflight failed -- the trials below would be measuring the setup,") + U.log("not the fix. fix the lines above first.") + while true do coroutine.yield() end + end + + -- ---- #1718: the nine offsets in an 8-frame STEP_BIKE cell --------------- + -- Offset 0 is the one tick the gate opens; 1..8 only the latch can carry. + + local opened = 0 + for offset = 0, 8 do + park(ROAD.x, ROAD.y, "down") + U.wait(offset) + local moving = world.player.moving and "mid-step" or "standing" + local ok = holdUntilMenu("start", 24) + if ok then opened = opened + 1 end + report(ok, ("START at offset %d (%s) opened the menu"):format(offset, moving)) + if offset == 4 then U.shot(game, out .. "/01-start-mid-roll.png") end + closeMenus() + end + report(opened == 9, ("all nine offsets opened the menu (%d/9)"):format(opened)) + + -- A press RELEASED inside the step is one the frozen mirror never sees again. + park(ROAD.x, ROAD.y, "down") + U.wait(3) + report(world.player.moving == true, "three ticks in, the roll is mid-step") + tapAndRelease("start") + U.wait(24) + report(game.stack:top() == nil, + "a START tapped and released inside the step opens nothing") + closeMenus() + + -- And the dropped press must not poison the latch for the next one. + park(ROAD.x, ROAD.y, "down") + U.wait(3) + report(holdUntilMenu("start", 24), + "the next held START still opens the menu") + closeMenus() + + -- ---- #1749: SURF refused on the road ------------------------------------ + + park(ROAD.x, ROAD.y, "down") + -- Held RIGHT into the water the step is refused every tick, so the player + -- stands facing the sea instead of rolling and an A press can land at all. + game.input.pressQueue[#game.input.pressQueue + 1] = "right" + game.input.state.right = true + U.wait(20) + report(world.player.facing == "right", "facing the water off the Cycling Road") + report(world.player.moving == false, "and held there by the water it bumps") + local ctx = world:fieldContext() + report(Permissions.isWater(ctx.facingColl), "the faced cell really is water") + report(ctx.alwaysOnBike == true, + "and the field-move context carries ALWAYS_ON_BIKE (#1749)") + U.shot(game, out .. "/02-road-facing-water.png") + + -- TrySurfOW's arm is `.quit`: xor a, no script, no text + -- (engine/events/overworld.asm:513-515). Pressed through the real Input. + game.input.pressQueue[#game.input.pressQueue + 1] = "a" + game.input.state.a = true + U.wait(2) + game.input.state.a = false + U.wait(12) + game.input.state.right = false + report(world.textbox == nil, "A at the water put up no text box") + report(world.queuedFieldMove == nil, "and queued no field move") + report(FieldMoves.isBiking(world.playerState), "the player is still on the bike") + + -- SurfFunction.TrySurf's arm is .FailSurf, which TALKS + -- (engine/events/overworld.asm:350-352, :387-390). The world has to be idle + -- first or useFieldMove answers CANT_USE_HERE off its own busy gate, which + -- is a refusal for the wrong reason and would read as a pass. + dismiss() + report(not world:busy(), "the world is idle before the PACK's SURF is asked") + local menu = world:useFieldMove("SURF", swimmer) + report(menu and menu.ok == false, "the PACK's SURF refuses on the road") + report(menu and menu.text == FieldMoves.TEXT.CANT_SURF, + "with CantSurfText, not the badge line and not silence") + U.wait(6) + report(world.textbox ~= nil, "and the refusal is on screen") + U.wait(70) -- let the line finish typing so the shot is readable + U.shot(game, out .. "/03-road-cant-surf.png") + dismiss() + + -- ---- the control: plain water, ALWAYS_ON_BIKE clear --------------------- + + report(world:setMap(SEA.map, SEA.x, SEA.y, "right") ~= false, "ROUTE_32 loads") + U.wait(10) + world:applyPlayerState(FieldMoves.PLAYER_NORMAL) + report(world:alwaysOnBike() == false, "ROUTE_32 has no ALWAYS_ON_BIKE") + local sea = world:fieldContext() + report(sea.alwaysOnBike == false, "so the context reports it clear") + report(Permissions.isWater(sea.facingColl), "and the player faces its water") + + local allowed = world:useFieldMove("SURF", swimmer) + report(allowed and allowed.ok == true, "the PACK's SURF still works there") + world.queuedFieldMove = nil + + tap("a", 10) + report(world.textbox ~= nil or world.choicebox ~= nil, + "and A at the water still offers to SURF") + U.wait(70) + U.shot(game, out .. "/04-route32-offer.png") + dismiss() + + if fails == 0 then + U.log("all trials passed. shots are in " .. out) + else + U.log(fails .. " trial(s) failed -- see the FAIL lines above") + end + U.log("what a wrong fix looks like: START opening the menu on roughly one") + U.log("press in eight is the World half applied without the Game2 latch, and") + U.log("a menu from the tapped-and-released press is the latch re-pressing a") + U.log("button the player already let go of.") + + world:setMap(ROAD.map, ROAD.x, ROAD.y, "down") + U.wait(10) + U.log("parked back on the Cycling Road; the controls are yours from here") + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/gold_field_anim_shots.lua b/tests/drivers/gold_field_anim_shots.lua index a0b79c6a..54c1638b 100644 --- a/tests/drivers/gold_field_anim_shots.lua +++ b/tests/drivers/gold_field_anim_shots.lua @@ -118,7 +118,10 @@ return function(game) for _, row in ipairs(FieldMoves.FLYPOINTS) do save.engineFlags[row.flag] = true end - ok("openFlyMap opens a screen", world:openFlyMap() == true) + -- The mon that used the move: FlyFunction_InitGFX and TownMapMon both draw + -- wCurPartyMon's icon (engine/events/field_moves.asm:390). + local flyMon = (save.party and save.party[1]) or { species = "PIDGEY" } + ok("openFlyMap opens a screen", world:openFlyMap(flyMon) == true) U.wait(4) local picker = game.stack:top() ok("and it is the town-map picker, not a yes/no box", @@ -130,18 +133,26 @@ return function(game) U.wait(4) U.shot(game, out .. "/06-flymap-moved.png") - -- A takes the destination: the fade out lifts the player off the map first. + -- A takes the destination: FlyFromAnim hovers over the tile for 64 frames, + -- climbs off the top of the screen, and FlyToAnim drops back in after the warp. U.tap(game, "a") - local lifted = 0 - for frame = 1, 40 do + U.wait(4) + ok("FLY starts the take-off animation", world.flyAnim ~= nil) + U.shot(game, out .. "/07-fly-takeoff.png") + local risen, landing = 0, false + for _ = 1, 300 do U.wait(1) - lifted = math.min(lifted, (world.player and world.player.spriteYOffset) or 0) - if frame == 4 then U.shot(game, out .. "/07-fly-takeoff.png") end + local fa = world.flyAnim + if fa then + risen = math.min(risen, fa.y or 0) + if fa.phase == "to" then landing = true end + elseif landing then + break + end end - ok("FLY lifts the player under the fade", lifted < 0, lifted) - U.wait(30) - ok("and lands them back on the tile", - (world.player.spriteYOffset or 0) == 0) + ok("the mon climbs off the map", risen < 0, risen) + ok("and FlyToAnim brings it back down", landing) + ok("with the player standing again", world.flyAnim == nil) U.shot(game, out .. "/08-fly-landed.png") -- --------------------------------------------------- tilt and the void diff --git a/tests/drivers/gold_fishing_rod_bug1708_test.lua b/tests/drivers/gold_fishing_rod_bug1708_test.lua new file mode 100644 index 00000000..d3d877d7 --- /dev/null +++ b/tests/drivers/gold_fishing_rod_bug1708_test.lua @@ -0,0 +1,369 @@ +-- #1708: Gold's fishing pose and rod. The cast is reached the way a player +-- reaches it -- walk to the shore, START, PACK, KEY ITEMS, OLD ROD, USE -- once +-- per facing, and the pose row and rod tile are sampled out of the real draw. +-- FacingFishDown/Up/Left/Right ../pokegold/data/sprites/facings.asm:122-152; +-- LoadFishingGFX ../pokegold/engine/events/fishing_gfx.asm:1-24. +-- No POKEPORT_SPEED: it scales the logic clock only, so the sampled frames and +-- the rendered ones drift apart and the rod is read off the wrong frame. +-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/gold_fishing_rod_bug1708_test.lua \ +-- POKEPORT_SHOT_DIR=/tmp/gold-fishing \ +-- perl -e 'alarm 420; exec @ARGV' \ +-- python3 -c "import pty; pty.spawn(['love','.'])" +local U = require("tests.drivers.util") + +local Assets = require("src.render.Assets") +local Map = require("src.world.gen2.Map") +local PackMenu = require("src.ui.gen2.PackMenu") +local Permissions = require("src.world.gen2.Permissions") +local SpriteRenderer = require("src.render.SpriteRenderer") +local StartMenu = require("src.ui.gen2.StartMenu") + +local ROD_ITEM = "OLD_ROD" +local FACINGS = { "down", "up", "left", "right" } +local DELTA = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } } + +-- The loose rod OAM, `db y, x, attr, tile`, off the sprite's top-left +-- (data/sprites/facings.asm:122-152). Read here a second time so a typo in the +-- engine's copy of the table is a mismatch rather than a shared mistake. +local ROD_OAM = { + down = { dx = 0, dy = 16, tile = 0xfc, flip = false }, + up = { dx = 0, dy = -8, tile = 0xfc, flip = false }, + left = { dx = -8, dy = 5, tile = 0xfd, flip = true }, + right = { dx = 16, dy = 5, tile = 0xfd, flip = false }, +} +-- LoadFishingGFX's three destinations, vTiles $02 / $06 / $0a, which are the +-- bottom tile rows of the down / up / left standing frames; right is left +-- x-flipped (fishing_gfx.asm:13-18, facings.asm:143-152). +local POSE_ROW = { down = 0, up = 1, left = 2, right = 2 } +local POSE_FLIP = { down = false, up = false, left = false, right = true } +-- Four rows of two tiles: the three pose rows then $fc/$fd. +local SHEET_W, SHEET_H, ROD_ROW_Y = 16, 32, 24 + +-- Ponds with a shore on all four sides. Route 28's has no object events at +-- all (../pokegold/maps/Route28.asm:29), so nothing walks into a shot and no +-- trainer's line of sight crosses one; the other two are the fallbacks if its +-- blocks are ever edited (../pokegold/maps/Route28.blk). +local MAPS = { "ROUTE_28", "VIRIDIAN_CITY", "ROUTE_43" } + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-fishing" + local failures = 0 + + local function check(label, condition, detail) + if condition then + U.log("PASS", label) + else + failures = failures + 1 + U.log("FAIL", label, detail ~= nil and tostring(detail) or "") + end + return condition and true or false + end + + local function tap(btn, frames) + game.input.pressQueue[#game.input.pressQueue + 1] = btn + game.input.state[btn] = true + U.wait(2) + game.input.state[btn] = false + U.wait(frames or 6) + end + + U.wait(45) + local world = game.world + assert(world and world.map, "gold world did not boot") + local save = game.save + + -- ---- the halves of the drawing, before anything is on screen ------------- + -- A cache with no sheet, a sheet of the wrong size and a draw branch that + -- never fires all look identical on screen: a plain standing player. + + local sheet = world.fishingSheet + if not check("the cache carries LoadFishingGFX's sheet (world.fishingSheet)", + type(sheet) == "string", sheet) then + U.log("no emotes/fishing.png in this cache, so nothing below can draw.") + U.log("re-import the cart first:") + U.log(" POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_IMPORT_ONLY=1 \\") + U.log(" POKEPORT_IMPORT_ROM=/path/to/gold.gbc love .") + while true do coroutine.yield() end + end + + local okImg, image = pcall(Assets.image, sheet) + check("and it loads: " .. tostring(sheet), okImg and image ~= nil) + if okImg and image then + local w, h = image:getDimensions() + check(("it is %dx%d, the four rows the quads are written against") + :format(SHEET_W, SHEET_H), + w == SHEET_W and h == SHEET_H, ("%dx%d"):format(w, h)) + if h ~= SHEET_H then + U.log("a taller sheet moves the rod row, and both draws then sample") + U.log("the wrong pixels -- Gen 1 shipped exactly that as #321.") + end + end + + local Player = require("src.world.gen2.Player") + check("Player:drawFishing exists (the branch that reads player.fishing)", + type(Player.drawFishing) == "function") + check("SpriteRenderer:drawTile exists (it bakes the OBJ palette; a raw " + .. "love.graphics.draw would leave the rod in DMG greys)", + type(SpriteRenderer.drawTile) == "function") + + local rodDef = game.data and game.data.items and game.data.items[ROD_ITEM] + check("OLD ROD is a real item in this cache", rodDef ~= nil) + check("and it lives in the KEY ITEMS pocket", + rodDef ~= nil and rodDef.pocket == "KEY_ITEM", + rodDef and rodDef.pocket) + + -- ---- pick the pond ------------------------------------------------------ + -- Shores are re-derived from the generated blocks every run, so a re-extract + -- that shifts the water degrades to another cell instead of parking the + -- player at a wall. + + local function shores(mapId) + local def = world.maps and world.maps[mapId] + local tileset = def and world.tilesets and world.tilesets[def.tileset] + if not (def and tileset) then return nil end + local probe = Map.new(def, tileset) + local taken = {} + for _, obj in ipairs(def.objects or {}) do + taken[obj.x .. "," .. obj.y] = true + end + local function land(cx, cy) + if not probe:inBounds(cx, cy) then return false end + if taken[cx .. "," .. cy] then return false end + local coll = probe:cellCollision(cx, cy) + return Permissions.isWalkable(coll) and not Permissions.isWater(coll) + end + local found = {} + for _, facing in ipairs(FACINGS) do + local d = DELTA[facing] + for cy = 0, probe.heightCells - 1 do + for cx = 0, probe.widthCells - 1 do + if not found[facing] and land(cx, cy) + and probe:inBounds(cx + d[1], cy + d[2]) + and Permissions.isWater(probe:cellCollision(cx + d[1], cy + d[2])) + and land(cx - d[1], cy - d[2]) then + found[facing] = { x = cx, y = cy } + end + end + end + if not found[facing] then return nil end + end + return found + end + + local MAP, SHORE + for _, id in ipairs(MAPS) do + SHORE = shores(id) + if SHORE then MAP = id break end + end + if not check("a pond with all four shores and a step of room behind each", + MAP ~= nil, table.concat(MAPS, ", ")) then + while true do coroutine.yield() end + end + U.log("fishing on " .. MAP .. ", from " .. + ("down (%d,%d), up (%d,%d), left (%d,%d), right (%d,%d)"):format( + SHORE.down.x, SHORE.down.y, SHORE.up.x, SHORE.up.y, + SHORE.left.x, SHORE.left.y, SHORE.right.x, SHORE.right.y)) + + -- ---- the bag, and a pond that never bites ------------------------------- + -- FISHGROUP_NONE is .FishNoFish: the cast, the pose and the rod are the same + -- as a bite's, and every run ends on "Not even a nibble!" back on the map + -- rather than in a battle nobody asked for. Put back before the hand-off. + + save.inventory = { [ROD_ITEM] = 1 } + save.bagOrder = { ROD_ITEM } + local mapDef = world.maps[MAP] + local realGroup = mapDef.fishGroup + mapDef.fishGroup = "FISHGROUP_NONE" + + -- ---- what the sprite actually drew -------------------------------------- + + local watch = nil + local realDrawTile = SpriteRenderer.drawTile + SpriteRenderer.drawTile = function(self, path, x, y, flip, quad) + if watch and path == sheet then + local qx, qy, qw, qh = 0, 0, 0, 0 + if quad and quad.getViewport then qx, qy, qw, qh = quad:getViewport() end + watch[#watch + 1] = { x = x, y = y, flip = flip and true or false, + qx = qx, qy = qy, qw = qw, qh = qh } + end + return realDrawTile(self, path, x, y, flip, quad) + end + + local function lastOfSize(calls, w, h) + local hit + for _, c in ipairs(calls) do + if c.qw == w and c.qh == h then hit = c end + end + return hit + end + + -- ---- walk to a shore ---------------------------------------------------- + + local function goToShore(facing) + local cell, d = SHORE[facing], DELTA[facing] + world:setMap(MAP, cell.x - d[1], cell.y - d[2], facing) + U.wait(24) + U.hold(game, facing, 26) + U.wait(10) + local p = world.player + if p.cellX ~= cell.x or p.cellY ~= cell.y then + world:setMap(MAP, cell.x, cell.y, facing) + U.wait(24) + p = world.player + end + local fx, fy = p.cellX + d[1], p.cellY + d[2] + check(("%s: standing at (%d,%d) with water in front") + :format(facing, p.cellX, p.cellY), + p.facing == facing and world.map:inBounds(fx, fy) + and Permissions.isWater(world.map:cellCollision(fx, fy)), + ("facing %s"):format(tostring(p.facing))) + end + + -- ---- START, PACK, KEY ITEMS, OLD ROD, USE -------------------------------- + + local function castRod(facing) + tap("start") + local menu = game.stack:top() + if not check(("%s: START opened the menu"):format(facing), + getmetatable(menu) == StartMenu) then + return false + end + for _ = 1, #menu.items do + local row = menu.list and menu.list:current() + if row and row.value == "pack" then break end + tap("down", 4) + end + tap("a", 10) + local pack = game.stack:top() + if not check(("%s: and the PACK is open"):format(facing), + getmetatable(pack) == PackMenu) then + return false + end + for _ = 1, 4 do + if pack:pocket().id == "KEY_ITEM" then break end + tap("right", 6) + end + check(("%s: on the KEY ITEMS pocket"):format(facing), + pack:pocket().id == "KEY_ITEM", pack:pocket().id) + for _ = 1, 20 do + local row = pack.rows[pack.index] + if row and row.id == ROD_ITEM then break end + tap("down", 4) + end + local row = pack.rows[pack.index] + if not check(("%s: cursor on the OLD ROD"):format(facing), + row ~= nil and row.id == ROD_ITEM, row and row.id) then + return false + end + tap("a", 8) + local sub = pack.submenu + check(("%s: the item submenu opened on USE"):format(facing), + sub ~= nil and sub.rows[sub.index] == "use", + sub and sub.rows[sub.index]) + tap("a", 10) + return true + end + + -- ---- one facing, end to end --------------------------------------------- + + local function castAndSample(facing, index) + goToShore(facing) + if not castRod(facing) then return end + + for _ = 1, 60 do + if world.fishing then break end + U.wait(1) + end + check(("%s: the rod is out (world.fishing)"):format(facing), + world.fishing ~= nil) + check(("%s: the pose is up (player.fishing)"):format(facing), + world.player.fishing == true) + check(("%s: and it drew from the fishing sheet"):format(facing), + world.player.fishSheet == sheet, world.player.fishSheet) + check(("%s: the PACK closed, so the cast is on screen"):format(facing), + getmetatable(game.stack:top()) ~= PackMenu) + + watch = {} + U.wait(6) + local calls = watch + watch = nil + local pose = lastOfSize(calls, 16, 8) + local rod = lastOfSize(calls, 8, 8) + check(("%s: a 16x8 pose row was drawn"):format(facing), pose ~= nil) + check(("%s: and the 8x8 rod tile beside it"):format(facing), rod ~= nil) + if pose and rod then + check(("%s: the pose row is sheet row %d") + :format(facing, POSE_ROW[facing]), + pose.qx == 0 and pose.qy == POSE_ROW[facing] * 8, + ("%d,%d"):format(pose.qx, pose.qy)) + check(("%s: the pose is%s x-flipped") + :format(facing, POSE_FLIP[facing] and "" or " not"), + pose.flip == POSE_FLIP[facing]) + local oam = ROD_OAM[facing] + check(("%s: the rod tile is $%02x on the sheet's rod row") + :format(facing, oam.tile), + rod.qy == ROD_ROW_Y and rod.qx == (oam.tile - 0xfc) * 8, + ("%d,%d"):format(rod.qx, rod.qy)) + check(("%s: the rod tile is%s x-flipped") + :format(facing, oam.flip and "" or " not"), + rod.flip == oam.flip) + -- The pose row sits at the sprite's bottom tile row, so the OAM offsets + -- are read back relative to it. + local drop = math.max(0, (world.player.sprite.frameHeight or 16) - 8) + check(("%s: the rod sits at OAM offset %d,%d from the sprite") + :format(facing, oam.dx, oam.dy), + rod.x - pose.x == oam.dx and rod.y - pose.y == oam.dy - drop, + ("%d,%d"):format(rod.x - pose.x, rod.y - pose.y + drop)) + end + U.shot(game, ("%s/%02d-fishing-%s.png"):format(out, index, facing)) + + -- PutTheRodAway: the pose comes down with the verdict box, not later. + local down = nil + for frame = 1, 300 do + if world.textbox then tap("a", 2) else U.wait(1) end + if not down and world.player.fishing == nil then down = frame end + if down and not world.textbox then break end + end + check(("%s: the pose came down with the box"):format(facing), down ~= nil) + watch = {} + U.wait(8) + local after = #watch + watch = nil + check(("%s: and nothing draws the rod afterwards"):format(facing), + after == 0, after) + end + + for i, facing in ipairs(FACINGS) do + castAndSample(facing, i) + end + + SpriteRenderer.drawTile = realDrawTile + mapDef.fishGroup = realGroup + + -- ---- hand off on a live cast -------------------------------------------- + + goToShore("right") + castRod("right") + U.wait(20) + + U.log(("%d shot(s) in %s, one per facing (#1708)."):format(#FACINGS, out)) + U.log("in each one the player's lower half is the two-handed fishing pose,") + U.log("not the standing legs, and one thin dark rod line runs from the hands") + U.log("into the water: straight down below the feet facing down, straight up") + U.log("above the head facing up, and a short stroke starting a tile clear of") + U.log("the sprite and sloping away from it facing left or right.") + U.log("the near miss to look for is the pose swapping in with no rod, or the") + U.log("rod as a garbage block, which is the sheet and the quads disagreeing;") + U.log("the other is a rod in flat DMG greys while the player is in colour.") + U.log("the pond bit on nothing for the four shots; the group is back now, so") + U.log("this last cast can bite. the controls are yours.") + if failures > 0 then + U.log(("%d check(s) failed above -- read those before looking at the shots") + :format(failures)) + end + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/gold_fly_anim_bug1711_test.lua b/tests/drivers/gold_fly_anim_bug1711_test.lua new file mode 100644 index 00000000..d8cb70d7 --- /dev/null +++ b/tests/drivers/gold_fly_anim_bug1711_test.lua @@ -0,0 +1,305 @@ +-- #1711: FLY is an animation, not a warp. .FlyScript hides the map's objects +-- and runs FlyFromAnim before WarpToSpawnPoint and FlyToAnim after the +-- newloadmap (../pokegold/engine/events/overworld.asm:595-609, and the two +-- animations at engine/events/field_moves.asm:300 and :334). Flown the way a +-- player flies: START > POKeMON > FLY out of New Bark Town. +-- +-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/gold_fly_anim_bug1711_test.lua \ +-- POKEPORT_SHOT_DIR=/tmp/gold-fly \ +-- perl -e 'alarm 240; exec @ARGV' \ +-- python3 -c "import pty; pty.spawn(['love','.'])" +-- +-- Do not add POKEPORT_SPEED. It scales the logic clock only, and the beat +-- being judged here is SFX_FLY against the wingbeat, which is audio against +-- logic -- fast-forward pulls those two apart. +local U = require("tests.drivers.util") + +local FieldMoves = require("src.world.gen2.FieldMoves") +local Mon = require("src.battle.gen2.Mon") + +local FLY_SPECIES = "PIDGEOTTO" + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-fly" + local fails = 0 + + local function ok(cond, msg) + if cond then + print("[fly] ok " .. msg) + else + fails = fails + 1 + print("[fly] FAIL " .. msg) + end + return cond + end + + local function tap(btn) U.tap(game, btn) U.wait(3) end + local function top() return game.stack:top() end + + U.wait(45) + local world = game.world + local save, data = game.save, game.data + if not ok(world and world.map, "gold booted into the overworld") then + error("gold fly: no world") + end + + local opts = save.options or {} + local sfxVol = opts.sfxVol or 7 + if sfxVol == 0 then + U.log("SFX volume is 0, so this run is silent whether the fix works or") + U.log("not. turn it up in OPTIONS and run again before judging the sound.") + end + ok(sfxVol > 0, ("SFX volume is %d"):format(sfxVol)) + + -- The wingbeat itself: no Sfx_Fly in the cache and the whole flight is + -- silent, which sounds exactly like the bug. + local audio = data and data.audio + local flySfx = world:sfxIdNamed("Sfx_Fly", 0x18) + local flySfxName = audio and audio.sfxOrder and audio.sfxOrder[flySfx + 1] + ok(flySfxName == "Sfx_Fly" and audio.sfx and audio.sfx[flySfxName] ~= nil, + ("the cache has Sfx_Fly at id %d"):format(flySfx)) + + -- FlyFunction .TryFly is CheckBadge STORMBADGE then CheckOutdoorMap + -- (../pokegold/engine/events/overworld.asm:544-551). + save.player = save.player or {} + if type(save.player.badges) ~= "table" then save.player.badges = {} end + save.player.badges.STORM = true + save.engineFlags = save.engineFlags or {} + for _, row in ipairs(FieldMoves.FLYPOINTS) do + save.engineFlags[row.flag] = true + end + + -- Mon.learnMove refuses a fifth move (src/battle/gen2/Mon.lua:650), and a + -- level-24 PIDGEOTTO already knows four. + local flyer = Mon.new(data, FLY_SPECIES, 24) + table.remove(flyer.moves, 1) + Mon.learnMove(flyer, "FLY", data) + save.party = { flyer } + local knowsFly = false + for _, entry in ipairs(flyer.moves or {}) do + if entry.id == "FLY" then knowsFly = true end + end + ok(knowsFly, FLY_SPECIES .. " knows FLY") + + -- FlyFunction_InitGFX's GetSpeciesIcon: no icon row for the species and + -- World:startFlyAnim refuses, which is the old instant warp back again. + local icons = data and data.gen2Icons + local iconId = icons and icons.species and icons.species[FLY_SPECIES] + local entry = iconId and icons.icons and icons.icons[iconId] + ok(entry and entry.image ~= nil, + ("%s flies on %s (%s)"):format(FLY_SPECIES, tostring(iconId), + tostring(entry and entry.image))) + + -- New Bark Town: a TOWN, so CheckOutdoorMap passes, and it keeps two objects + -- in view for HideSprites to take away -- the teacher at (6,8) and the + -- fisher at (12,9), ../pokecrystal/maps/NewBarkTown.asm:301-304. Scene 1 is + -- SCENE_NEWBARKTOWN_NOOP, so the teacher's coord event stays out of the way. + world.mapScenes = world.mapScenes or {} + world.mapScenes.NEW_BARK_TOWN = 1 + world:setMap("NEW_BARK_TOWN", 9, 8, "down") + U.wait(15) + local p = world.player + local function free(cx, cy) + return world.map:isWalkable(cx, cy) and not world:npcAt(cx, cy) + end + local DIRS = { down = { 0, 1 }, up = { 0, -1 }, left = { -1, 0 }, + right = { 1, 0 } } + if not free(p.cellX, p.cellY) then + for _, d in pairs(DIRS) do + if free(9 + d[1], 8 + d[2]) then + world:setMap("NEW_BARK_TOWN", 9 + d[1], 8 + d[2], "down") + U.wait(10) + break + end + end + end + ok(world.map.id == "NEW_BARK_TOWN" and free(p.cellX, p.cellY), + ("standing free on New Bark Town (%d,%d)"):format(p.cellX, p.cellY)) + ok((world.map.def and world.map.def.environment) == "TOWN", + "which is a TOWN, so FLY is allowed here") + ok(#world.npcs > 0, + ("with %d object(s) on the map to hide"):format(#world.npcs)) + + -- One real step through the pad, on a free neighbour. + local fromX, fromY = p.cellX, p.cellY + for name, d in pairs(DIRS) do + if free(fromX + d[1], fromY + d[2]) then + U.hold(game, name, 24) + break + end + end + U.wait(8) + ok(p.cellX ~= fromX or p.cellY ~= fromY, + ("the pad walked the player from (%d,%d) to (%d,%d)") + :format(fromX, fromY, p.cellX, p.cellY)) + local takeoffX, takeoffY = p.cellX, p.cellY + + -- Count the wingbeats, and only the ones the animation itself asks for. + local sfxFrom, sfxTo = 0, 0 + local realPlaySfx = world.playSfx + world.playSfx = function(self, id) + local fa = self.flyAnim + if fa and id == flySfx then + if fa.phase == "to" then sfxTo = sfxTo + 1 else sfxFrom = sfxFrom + 1 end + end + return realPlaySfx(self, id) + end + + -- START > POKeMON. CheckMenuOW only runs while nothing else owns the frame + -- (../pokegold/engine/overworld/events.asm), so wait the map's own settling + -- out rather than pressing into it. + for _ = 1, 240 do + if not world:busy() then break end + U.wait(2) + end + local menu + for _ = 1, 10 do + tap("start") + menu = top() + if menu and menu.screenId == "Gen2StartMenu" then break end + U.wait(6) + end + if not ok(menu and menu.screenId == "Gen2StartMenu", + "START opened the menu (top is " + .. tostring(menu and (menu.screenId or "?")) .. ")") then + error("gold fly: no start menu") + end + for _ = 1, 10 do + if menu.list:current().value == "pokemon" then break end + tap("down") + end + ok(menu.list:current().value == "pokemon", "the cursor found POKeMON") + tap("a") + + local party = top() + if not ok(party and party.screenId == "Gen2PartyMenu", + "POKeMON opened the party list") then + error("gold fly: no party list") + end + tap("a") + local sub = party.submenu + if not ok(sub ~= nil, "and A opened the action submenu") then + error("gold fly: no submenu") + end + local flyRow + for index, item in ipairs(sub.items) do + if item.id == "FLY" then flyRow = index end + end + if not ok(flyRow ~= nil, "which lists FLY") then + error("gold fly: the submenu has no FLY row") + end + for _ = 1, #sub.items do + if sub.index == flyRow then break end + tap("down") + end + tap("a") + U.wait(10) + + local picker = top() + if not ok(picker and picker.screenId == "Gen2Pokegear" and picker.fly, + "FLY opened the destination map") then + error("gold fly: no picker") + end + -- _FlyMap's .HandleDPad walks the visited rows; up is the next one along, + -- so one press leaves New Bark for the town after it. + tap("up") + U.wait(6) + local destination = picker:flyRow() + -- Landmark names carry TownMap_ConvertLineBreakCharacters' own , which + -- would break a log line in half. + ok(destination ~= nil, ("picked %s (%s)"):format( + (tostring(destination and destination.name):gsub("%s+", " ")), + tostring(destination and destination.spawn))) + + -- A commits, and .FlyScript starts on this map, not the far one. + U.tap(game, "a") + U.wait(3) + if not ok(world.flyAnim ~= nil and world.flyAnim.phase == "from", + "A started FlyFromAnim over the map being left") then + error("gold fly: the flight collapsed into a warp") + end + ok(world:busy(), "and the world is frozen under it, as the callasm is") + + local hoverFrames, minY, maxSwing = 0, 0, 0 + local toAmpFirst, toAmpLast, sawTo = nil, nil, false + local shots = { hover = false, climb = false, drop = false } + for _ = 1, 900 do + local fa = world.flyAnim + if fa and fa.phase == "from" then + if (fa.hover or 0) > 0 then hoverFrames = hoverFrames + 1 end + minY = math.min(minY, fa.y or 0) + maxSwing = math.max(maxSwing, math.abs(fa.xoff or 0)) + if not shots.hover and hoverFrames >= 20 then + shots.hover = true + U.shot(game, out .. "/01-fly-hover.png") + elseif not shots.climb and (fa.y or 0) <= -40 then + shots.climb = true + U.shot(game, out .. "/02-fly-climb.png") + end + elseif fa then + sawTo = true + toAmpFirst = toAmpFirst or fa.amp + toAmpLast = fa.amp + -- Mid-drop, not the first frame of it: the swing opens at 11 * 8 px, so + -- the icon starts the descent most of a screen off to one side. + if not shots.drop and (fa.y or 0) >= -44 and (fa.y or 0) <= -16 then + shots.drop = true + U.shot(game, out .. "/03-fly-drop.png") + end + elseif sawTo then + break + end + U.wait(1) + end + + -- FlyFromAnim: 0x40 frames on the tile, then 2px a frame off the top with + -- Sprites_Cosine widening the swing (engine/events/field_moves.asm:300-333). + ok(hoverFrames >= 48, + ("the icon held the take-off tile for %d frames"):format(hoverFrames)) + ok(minY <= -84, ("and climbed %d px off it"):format(-minY)) + ok(maxSwing >= 32, ("swinging up to %d px wide"):format(maxSwing)) + -- Nine beats going up (left 128 down to 64, every eighth), one coming down. + ok(sfxFrom == 9, ("SFX_FLY flapped %d times on the way up"):format(sfxFrom)) + ok(sfxTo == 1, ("and %d time on the way down"):format(sfxTo)) + ok(sawTo, "FlyToAnim ran on the far side of the warp") + ok(toAmpFirst and toAmpLast and toAmpLast < toAmpFirst, + ("with its swing damping from %s to %s"):format(tostring(toAmpFirst), + tostring(toAmpLast))) + ok(shots.hover and shots.climb and shots.drop, + "hover, climb and drop are all shot") + + U.wait(30) + ok(world.flyAnim == nil, "the animation is over") + ok(world.map.id ~= "NEW_BARK_TOWN", + ("and the player landed on %s at (%d,%d)"):format(world.map.id, + p.cellX, p.cellY)) + ok(not world:busy(), "with the controls handed back") + U.shot(game, out .. "/04-fly-landed.png") + ok(world.player.spriteYOffset == 0 or world.player.spriteYOffset == nil, + "standing flat on the tile, not still lifted") + + U.log(("flew %s out of New Bark Town from (%d,%d) for you (#1711). the"): + format(FLY_SPECIES, takeoffX, takeoffY)) + U.log("player's own sprite and every other object on the map should vanish") + U.log("the moment A is pressed, replaced by the mon's 16x16 party icon on") + U.log("that same tile, wings beating four times a second under a chirp of") + U.log("SFX_FLY, for about a second. only then does it climb off the top of") + U.log("the screen in a widening swing, and only then does the screen fade.") + U.log("on the far side the icon falls back in from above, its swing damping") + U.log("out, one more chirp, and the player pops in standing.") + U.log("shots are in " .. out .. ".") + U.log("the player still visible on the tile while the icon flies is the near") + U.log("miss for the hide; an icon sitting dead still for two seconds and") + U.log("then jumping is the hover without the climb.") + if fails > 0 then + U.log(("%d assertion(s) failed above -- read those before the pictures.") + :format(fails)) + end + U.log("the party still holds a flyer and the badge, so FLY again from the") + U.log("menu whenever you want another look. the controls are yours.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/gold_flymap_cursor_bug1712_test.lua b/tests/drivers/gold_flymap_cursor_bug1712_test.lua new file mode 100644 index 00000000..393642f6 --- /dev/null +++ b/tests/drivers/gold_flymap_cursor_bug1712_test.lua @@ -0,0 +1,234 @@ +-- #1712: the FLY map's cursor is the flying mon's party icon, not the MAP +-- card's arrow. FlyMap's .MapHud calls TownMapMon, which reads wCurPartyMon +-- and spawns SPRITE_ANIM_OBJ_PARTY_MON on that species' icon +-- (../pokecrystal/engine/pokegear/pokegear.asm:2326, :2708-2721). Reached the +-- way a player reaches it: START > POKeMON > FLY, standing in New Bark Town. +-- +-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/gold_flymap_cursor_bug1712_test.lua \ +-- POKEPORT_SHOT_DIR=/tmp/gold-flymap \ +-- perl -e 'alarm 240; exec @ARGV' \ +-- python3 -c "import pty; pty.spawn(['love','.'])" +-- +-- Do not add POKEPORT_SPEED: the icon's two-frame beat is half of what is +-- being judged here, and the logic clock is the only thing speed scales. +local U = require("tests.drivers.util") + +local FieldMoves = require("src.world.gen2.FieldMoves") +local Mon = require("src.battle.gen2.Mon") + +local FLY_SPECIES = "PIDGEOTTO" + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-flymap" + local fails = 0 + + local function ok(cond, msg) + if cond then + print("[flymap] ok " .. msg) + else + fails = fails + 1 + print("[flymap] FAIL " .. msg) + end + return cond + end + + local function tap(btn) U.tap(game, btn) U.wait(3) end + local function top() return game.stack:top() end + + U.wait(45) + local world = game.world + local save, data = game.save, game.data + if not ok(world and world.map, "gold booted into the overworld") then + error("gold flymap: no world") + end + + -- FlyFunction .TryFly is CheckBadge STORMBADGE then CheckOutdoorMap + -- (../pokegold/engine/events/overworld.asm:544-551). + save.player = save.player or {} + if type(save.player.badges) ~= "table" then save.player.badges = {} end + save.player.badges.STORM = true + save.engineFlags = save.engineFlags or {} + for _, row in ipairs(FieldMoves.FLYPOINTS) do + save.engineFlags[row.flag] = true + end + + -- Mon.learnMove refuses a fifth move (src/battle/gen2/Mon.lua:650), and a + -- level-24 PIDGEOTTO already knows four. + local flyer = Mon.new(data, FLY_SPECIES, 24) + table.remove(flyer.moves, 1) + Mon.learnMove(flyer, "FLY", data) + save.party = { flyer } + local knowsFly = false + for _, entry in ipairs(flyer.moves or {}) do + if entry.id == "FLY" then knowsFly = true end + end + ok(knowsFly, FLY_SPECIES .. " knows FLY") + + -- The asset side, which fails exactly as silently as the bug: no icon row + -- for the species and drawFlyMonCursor gives the arrow back. + local icons = data and data.gen2Icons + local iconId = icons and icons.species and icons.species[FLY_SPECIES] + local entry = iconId and icons.icons and icons.icons[iconId] + ok(iconId ~= nil, "the cache maps " .. FLY_SPECIES .. " to an icon (" + .. tostring(iconId) .. ")") + ok(entry and entry.image ~= nil, "and that icon has a sheet (" + .. tostring(entry and entry.image) .. ")") + if entry and entry.image and love.filesystem.getInfo then + ok(love.filesystem.getInfo(entry.image) ~= nil, + "and the sheet is on disk") + end + + -- New Bark Town is a TOWN, so TryFly's CheckOutdoorMap passes, and it has + -- two wandering objects to watch -- the teacher at (6,8) and the fisher at + -- (12,9), ../pokecrystal/maps/NewBarkTown.asm:301-304. Scene 1 is + -- SCENE_NEWBARKTOWN_NOOP, so the teacher's coord event stays out of the way. + world.mapScenes = world.mapScenes or {} + world.mapScenes.NEW_BARK_TOWN = 1 + world:setMap("NEW_BARK_TOWN", 9, 8, "down") + U.wait(15) + local p = world.player + local function free(cx, cy) + return world.map:isWalkable(cx, cy) and not world:npcAt(cx, cy) + end + local DIRS = { down = { 0, 1 }, up = { 0, -1 }, left = { -1, 0 }, + right = { 1, 0 } } + if not free(p.cellX, p.cellY) then + for _, d in pairs(DIRS) do + if free(9 + d[1], 8 + d[2]) then + world:setMap("NEW_BARK_TOWN", 9 + d[1], 8 + d[2], "down") + U.wait(10) + break + end + end + end + ok(world.map.id == "NEW_BARK_TOWN" and free(p.cellX, p.cellY), + ("standing free on New Bark Town (%d,%d)"):format(p.cellX, p.cellY)) + ok((world.map.def and world.map.def.environment) == "TOWN", + "which is a TOWN, so FLY is allowed here") + + -- One real step through the pad, on a free neighbour. + local fromX, fromY = p.cellX, p.cellY + for name, d in pairs(DIRS) do + if free(fromX + d[1], fromY + d[2]) then + U.hold(game, name, 24) + break + end + end + U.wait(8) + ok(p.cellX ~= fromX or p.cellY ~= fromY, + ("the pad walked the player from (%d,%d) to (%d,%d)") + :format(fromX, fromY, p.cellX, p.cellY)) + + -- START > POKeMON. CheckMenuOW only runs while nothing else owns the frame + -- (../pokegold/engine/overworld/events.asm), so wait the map's own settling + -- out rather than pressing into it. + for _ = 1, 240 do + if not world:busy() then break end + U.wait(2) + end + local menu + for _ = 1, 10 do + tap("start") + menu = top() + if menu and menu.screenId == "Gen2StartMenu" then break end + U.wait(6) + end + if not ok(menu and menu.screenId == "Gen2StartMenu", + "START opened the menu (top is " + .. tostring(menu and (menu.screenId or "?")) .. ")") then + error("gold flymap: no start menu") + end + for _ = 1, 10 do + if menu.list:current().value == "pokemon" then break end + tap("down") + end + ok(menu.list:current().value == "pokemon", "the cursor found POKeMON") + tap("a") + + local party = top() + if not ok(party and party.screenId == "Gen2PartyMenu", + "POKeMON opened the party list") then + error("gold flymap: no party list") + end + + -- A on the flyer, then down the submenu to its FLY row. + tap("a") + local sub = party.submenu + if not ok(sub ~= nil, "and A opened the action submenu") then + error("gold flymap: no submenu") + end + local flyRow + for index, item in ipairs(sub.items) do + if item.id == "FLY" then flyRow = index end + end + if not ok(flyRow ~= nil, "which lists FLY") then + error("gold flymap: the submenu has no FLY row") + end + for _ = 1, #sub.items do + if sub.index == flyRow then break end + tap("down") + end + ok(sub.index == flyRow, "the cursor found FLY") + tap("a") + U.wait(10) + + local picker = top() + if not ok(picker and picker.screenId == "Gen2Pokegear", + "FLY opened the town map") then + error("gold flymap: FLY did not open the picker") + end + ok(picker.fly ~= nil and #picker.fly > 0, + "in _FlyMap dress, with " .. tostring(picker.fly and #picker.fly) + .. " flypoints to walk") + -- wCurPartyMon, as TownMapMon reads it (World:openFlyMap's flyMon). + ok(picker.flyMon == flyer, "and it was handed the mon that used the move") + ok(picker.icons ~= nil, "and it can see the icon table") + + U.wait(12) + ok(picker.flyMonIcon ~= nil and picker.flyMonIcon ~= false, + "the mon icon loaded for the cursor") + -- No objColors, no OBP bake, and the bake is what keys colour 0 transparent + -- (src/render/SpriteRenderer.lua:243): the icon would sit in a white box. + ok(picker.flyMonIcon and picker.flyMonIcon.objColors ~= nil, + "with an OBJ palette baked onto it") + + -- Landmark names carry TownMap_ConvertLineBreakCharacters' own , which + -- would break a log line in half. + local function flat(entry) + return (tostring(entry and entry.name):gsub("%s+", " ")) + end + + local before = picker:mapLandmark() + local player = picker:playerLandmark() + ok(before and before.x and before.y, "the cursor is on " .. flat(before)) + U.shot(game, out .. "/01-flymap-newbark.png") + + tap("up") + U.wait(8) + local after = picker:mapLandmark() + ok(after and (after.x ~= before.x or after.y ~= before.y), + ("up walked the cursor to %s at (%s,%s)"):format(flat(after), + tostring(after and after.x), tostring(after and after.y))) + ok(picker:playerLandmark() == player, + "and the player's own icon stayed where it was") + U.shot(game, out .. "/02-flymap-cherrygrove.png") + + U.log("flew the party list into the FLY map for you (#1712). the cursor over") + U.log("the highlighted town should be " .. FLY_SPECIES .. "'s 16x16 party") + U.log("icon, red like the little Chris icon parked on New Bark, swapping") + U.log("between its two frames about twice a second, and it moves with") + U.log("up/down while Chris stays put. shots are in " .. out .. ".") + U.log("the arrow cursor instead of a mon is the bug. a mon in a solid white") + U.log("box is the near miss -- the icon is right, the palette bake is not.") + if fails > 0 then + U.log(("%d assertion(s) failed above; the picture below is not worth") + :format(fails)) + U.log("reading until those are green.") + end + U.log("the picker is open and the controls are yours.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/gold_frame_seams.lua b/tests/drivers/gold_frame_seams.lua index 68526a20..e09d8639 100644 --- a/tests/drivers/gold_frame_seams.lua +++ b/tests/drivers/gold_frame_seams.lua @@ -8,22 +8,27 @@ -- Two halves, and the second is the one that matters. A hook that fires is -- easy; a hook that fires and MOVES A PIXEL is worse than no hook at all, -- because it silently changes what every existing Gold screenshot means. So --- this shoots the overworld, a menu over the overworld, CLASSIC, and CLASSIC + --- GBC FX with nothing subscribed, wraps all six hooks with pass-throughs that --- draw nothing, shoots the same four frames again, and compares the PNG bytes. +-- this shoots the overworld, CLASSIC, and a menu over the overworld with +-- nothing subscribed, wraps all six hooks with pass-throughs that draw +-- nothing, shoots the same three frames again, and compares the PNG bytes. -- Identical files are the claim; the shots are left on disk either way so a -- human can look at the picture the port is actually producing. -- --- The four frames are chosen to cover every branch of Game2:draw: no canvas at --- all, the zone pass alone, the zone pass into a texture GBC FX then reads, and --- (once the wraps are on) the render.compose path that forces a canvas even --- when no display mode wanted one. +-- The three frames are chosen to cover every branch of Game2:draw: no canvas +-- at all, the zone pass alone, and (once the wraps are on) the render.compose +-- path that forces a canvas even when no display mode wanted one. +-- +-- The zone-pass-into-a-texture-then-reread branch (`reread` in +-- Game2:drawViewportFrame, formerly exercised by CLASSIC + GBC FX before +-- GBCFX.lua's removal) is not covered here any more: +-- ShaderFX is the only thing left that triggers it, and activating it needs +-- a converted on-disk preset this headless driver does not set up. Flagged, +-- not silently dropped. local U = require("tests.drivers.util") local Runtime = require("src.mods.Runtime") local Hooks = require("src.mods.Hooks") local GbcPalette = require("src.render.GbcPalette") -local GBCFX = require("src.render.GBCFX") local OWNER = "driver_frame_seams" @@ -50,23 +55,17 @@ return function(game) U.wait(45) assert(game.world and game.world.map, "gold world did not boot") - -- The four frames, each named for the display state it exercises. `fx` is - -- requested rather than asserted: GBCFX.isSupported refuses on mobile GPUs - -- and a headless checkout may have no shader at all, in which case that - -- frame simply repeats the CLASSIC one and the comparison still means - -- something. + -- The three frames, each named for the display state it exercises. local frames = { - { name = "world", color = "gbc", fx = 0 }, - { name = "classic", color = "classic", fx = 0 }, - { name = "classicfx", color = "classic", fx = 2 }, - { name = "menu", color = "gbc", fx = 0, menu = true }, + { name = "world", color = "gbc" }, + { name = "classic", color = "classic" }, + { name = "menu", color = "gbc", menu = true }, } local function shoot(tag) local menuOpen = false for _, frame in ipairs(frames) do GbcPalette.setMode(frame.color) - GBCFX.setLevel(frame.fx) if frame.menu and not menuOpen then game:openStartMenu() menuOpen = true @@ -82,7 +81,6 @@ return function(game) U.wait(8) end GbcPalette.setMode("gbc") - GBCFX.setLevel(0) end -- POKEPORT_SEAM_BASELINE= shoots the four frames under that tag and diff --git a/tests/drivers/gold_ledge_hop_bug1713_test.lua b/tests/drivers/gold_ledge_hop_bug1713_test.lua new file mode 100644 index 00000000..10c4084e --- /dev/null +++ b/tests/drivers/gold_ledge_hop_bug1713_test.lua @@ -0,0 +1,272 @@ +-- #1713: a ledge hop advanced only every other logic frame, so the whole map +-- scrolled at 30Hz in 2px lurches and the sprite bobbed against its own arc. +-- +-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/gold_ledge_hop_bug1713_test.lua \ +-- POKEPORT_SHOT_DIR=/tmp/gold-ledge \ +-- perl -e 'alarm 240; exec @ARGV' \ +-- python3 -c "import pty; pty.spawn(['love','.'])" +-- +-- jump_step is STEP_WALK (pokegold/engine/overworld/movement.asm:595-597), so +-- StepFunction_PlayerJump runs the `db 0, 2, 8, 2` StepVectors row as two +-- 8-frame beats (engine/overworld/map_objects.asm:365-381, :1163-1200) and +-- UpdateJumpPosition walks its 16-entry arc one entry a frame (:1796-1817). +-- We render at twice that resolution, so the hop owes 1px and half an arc +-- entry on every one of its 32 frames. +-- +-- No POKEPORT_SPEED here on purpose: the whole subject is per-frame motion, +-- and fast-forward scales only the logic clock. +local U = require("tests.drivers.util") + +local Player = require("src.world.gen2.Player") +local Permissions = require("src.world.gen2.Permissions") + +-- ROUTE_30's ledge run at y=10 (data/generated/maps.lua, TILESET_JOHTO +-- collision $a3 COLL_HOP_DOWN): open ground two cells above it, a wall in +-- between, floor on the landing. Re-derived by scanning if a re-import moves. +local MAP = "ROUTE_30" +local LEDGE = { x = 4, y = 10 } + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-ledge" + local fails, lines = 0, {} + + local function claim(ok, text) + if not ok then fails = fails + 1 end + lines[#lines + 1] = (ok and "PASS " or "FAIL ") .. text + return ok + end + + local function stop() + for _, line in ipairs(lines) do U.log(line) end + while true do coroutine.yield() end + end + + U.wait(45) + local world = game.world + if not (world and world.map) then + U.log("FAIL the gold world never booted, nothing to look at") + while true do coroutine.yield() end + end + + claim(Player.STEP_FRAMES == 16, "a walk is 16 logic frames per cell") + claim(type(world.tryLedgeJump) == "function", + "World:tryLedgeJump is wired for the refused step to fall into") + + -- ---- find a ledge with room to run up to it ------------------------------ + world:setMap(MAP, LEDGE.x, LEDGE.y - 2, "down") + U.wait(10) + world.noWildEncounters = true + local map = world.map + + local function hopsDown(x, y) + if not map:inBounds(x, y) then return false end + local facings = Permissions.ledgeFacings(map:cellCollision(x, y)) + return facings ~= nil and facings.down == true + end + local function walkable(x, y) + return map:inBounds(x, y) and map:isWalkable(x, y) + end + -- A usable ledge: two free cells to walk down through, a refused cell in the + -- middle (that refusal is what becomes the jump) and a free landing. + local function usable(x, y) + return hopsDown(x, y) and not walkable(x, y + 1) and walkable(x, y + 2) + and walkable(x, y - 1) and walkable(x, y - 2) + end + + local target + if usable(LEDGE.x, LEDGE.y) then target = { x = LEDGE.x, y = LEDGE.y } end + if not target then + local def = world.maps[MAP] + for y = 0, (def.height or 0) * 2 - 1 do + for x = 0, (def.width or 0) * 2 - 1 do + if not target and usable(x, y) then target = { x = x, y = y } end + end + end + end + claim(target ~= nil, + ("%s still has a hop-down ledge with a run-up to it"):format(MAP)) + if not target then + U.log("nothing to hop off; stopping rather than parking you at a wall") + stop() + end + if target.x ~= LEDGE.x or target.y ~= LEDGE.y then + U.log(("note: using the ledge at (%d,%d), not the (%d,%d) in the header") + :format(target.x, target.y, LEDGE.x, LEDGE.y)) + end + local START = { x = target.x, y = target.y - 2 } + + local function press(dir) + table.insert(game.input.pressQueue, dir) + game.input.state[dir] = true + coroutine.yield() + end + + -- Back to the top of the run-up. The settle first is load bearing: a + -- setMap on top of a hop that is still in the air carries the leftover jump + -- into the new position and the next run measures nonsense. + local function reset() + game.input.state.down = false + for _ = 1, 120 do + if not (world.player and world.player.moving) then break end + coroutine.yield() + end + world:setMap(MAP, START.x, START.y, "down") + world.noWildEncounters = true + U.wait(8) + end + + -- ---- the run-up and the hop, measured ------------------------------------ + -- + -- Nobody can count pixels per frame by eye, and this is the whole bug: the + -- per-frame travel of the camera-following position, plus the arc riding on + -- top of it. Every sample is "pixels travelled == frames elapsed", never a + -- difference between two driver frames -- the fixed-step loop can run two + -- logic steps for one yield, and a delta would read that as a lurch. + reset() + local p = world.player + local hopRows, takeoff = {}, 0 + local walkSeen, walkWrong = 0, 0 + for _ = 1, 240 do + press("down") + if p.jumping then + if #hopRows == 0 then takeoff = p.cellY * 16 end + hopRows[#hopRows + 1] = { n = p.progress, py = p.py, + off = p.spriteYOffset or 0 } + elseif #hopRows > 0 then + break + elseif p.moving and (p.progress or 0) > 0 then + walkSeen = walkSeen + 1 + if p.py - p.cellY * 16 ~= p.progress then walkWrong = walkWrong + 1 end + end + end + game.input.state.down = false + + local wrong, lurch, last = 0, 0, 0 + for _, r in ipairs(hopRows) do + if r.py - takeoff ~= r.n then wrong = wrong + 1 end + if r.n > last then last = r.n end + end + -- The old quantum, named directly: an even number of pixels on an odd frame. + for _, r in ipairs(hopRows) do + if (r.py - takeoff) % 2 == 0 and r.n % 2 == 1 then lurch = lurch + 1 end + end + + claim(#hopRows > 0, "walking down into the ledge started a hop") + claim(last >= 30, + ("the hop ran its full 32 frames (the last one sampled was %d)") + :format(last)) + claim(p.cellY == target.y + 2, + ("it landed two cells down at y=%d (the player is at y=%d)") + :format(target.y + 2, p.cellY)) + claim(p.py - takeoff == 32, + ("and 32 pixels down, which is two cells (it moved %d)") + :format(p.py - takeoff)) + claim(wrong == 0, + ("on every one of the %d frames sampled the player had moved a pixel per" + .. " frame elapsed (%d had not)"):format(#hopRows, wrong)) + claim(lurch == 0, + ("so no frame of it stood still waiting to lurch two (%d did)") + :format(lurch)) + claim(walkSeen > 0 and walkWrong == 0, + ("the two ordinary steps before it move a pixel a frame too, on all %d" + .. " frames sampled (%d wrong)"):format(walkSeen, walkWrong)) + + -- The composed screen position: the camera-following py plus the sprite + -- offset. The armed frame is skipped because the first frame after it is + -- the take-off, and rising there is the jump starting. + local air, back = {}, 0 + for _, r in ipairs(hopRows) do + if r.n >= 1 then air[#air + 1] = r.py + r.off end + end + for i = 2, #air do + if air[i] < air[i - 1] then back = back + 1 end + end + claim(back == 0, + ("after the take-off the sprite never reversed against its own arc" + .. " (%d frames did)"):format(back)) + + -- ---- one frame per run, so the strip really is consecutive --------------- + -- + -- A capture costs the driver an unknown number of frames, so shooting four + -- frames in one hop would skip some. Four identical run-ups, each shot on a + -- different frame, gives a strip that can be flipped through. + -- One run-up, ending either on the frame asked for or on the landing. The + -- fixed-step loop can occasionally run two logic steps for one driver yield, + -- so the shot is taken at the first frame AT OR PAST the one asked for, and + -- the frame it actually landed on is reported rather than assumed. + local function hopRun(frame, path) + reset() + local airborne = false + for _ = 1, 240 do + press("down") + local pl = world.player + if pl.jumping then + airborne = true + if frame and pl.progress >= frame then + local at, py = pl.progress, pl.py + game.input.state.down = false + U.shot(game, path) + return at, py + end + elseif airborne then + break + end + end + game.input.state.down = false + end + + reset() + U.shot(game, out .. "/01-standing.png") + local strip = {} + for i, frame in ipairs({ 12, 13, 14, 15 }) do + local at, py = hopRun(frame, ("%s/02-strip-%d.png"):format(out, i)) + strip[#strip + 1] = { at = at or -1, py = py or -1 } + end + hopRun(4, out .. "/03-rising.png") + hopRun(16, out .. "/04-peak.png") + hopRun(28, out .. "/05-falling.png") + hopRun() + U.wait(20) + U.shot(game, out .. "/06-landed.png") + + local consecutive = true + local shown = {} + for i, s in ipairs(strip) do + shown[i] = ("%d"):format(s.at) + if i > 1 and (s.at - strip[i - 1].at ~= 1 + or s.py - strip[i - 1].py ~= 1) then + consecutive = false + end + end + claim(consecutive, + ("the strip really is four consecutive frames, %s -- if this is FAIL the" + .. " images are not comparable"):format(table.concat(shown, ", "))) + + for _, line in ipairs(lines) do U.log(line) end + U.log(("%d checks, %d failed"):format(#lines, fails)) + if fails > 0 then + U.log("something above is FAIL, so do not spend time watching the replay") + end + + -- ---- the replay, live ---------------------------------------------------- + U.log("shots are in " .. out .. "; 02-strip-* are consecutive frames of the") + U.log("same hop, so the ground should shift one pixel between each, and") + U.log("03 / 04 / 05 are the rise, the top of the arc and the drop.") + U.log("the replay walks down into the ledge three times, then hands over.") + U.wait(120) + for _ = 1, 3 do + hopRun() + U.wait(60) + end + + U.log("two ordinary steps down, then the hop: the map should scroll at the") + U.log("same speed through all three, and the sprite should rise, hang and") + U.log("fall once. a stutter, a 2px lurch, or the sprite bobbing back up") + U.log("during the rise is the old behaviour.") + U.log("the near miss to watch for: a smooth hop that clears only one cell,") + U.log("or one that takes twice as long as it should -- that is the two-cell") + U.log("span applied without the doubled duration.") + U.log("gen 1 is the reference; a Red ledge hop already looked like this.") + while true do coroutine.yield() end +end diff --git a/tests/drivers/gold_ow_bounce_bug1748_test.lua b/tests/drivers/gold_ow_bounce_bug1748_test.lua new file mode 100644 index 00000000..da492890 --- /dev/null +++ b/tests/drivers/gold_ow_bounce_bug1748_test.lua @@ -0,0 +1,328 @@ +-- #1748: overworld Pokemon objects (SPRITEMOVEDATA_POKEMON, $16) never bounce. +-- +-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/gold_ow_bounce_bug1748_test.lua \ +-- POKEPORT_SHOT_DIR=/tmp/gold-bounce \ +-- perl -e 'alarm 240; exec @ARGV' \ +-- python3 -c "import pty; pty.spawn(['love','.'])" +-- +-- The $16 row is SPRITEMOVEFN_BOUNCE + OBJECT_ACTION_BOUNCE +-- (data/sprites/map_objects.asm:181-187). SetFacingBounce steps +-- OBJECT_STEP_FRAME once a frame and reads bit 3, swapping FACING_STEP_DOWN_0 +-- for FACING_STEP_UP_0 -- eight frames on each (map_object_action.asm:184-202). +-- Its frozen column, SetFacingFreezeBounce, pins FACING_STEP_DOWN_0. +-- +-- No POKEPORT_SPEED here on purpose: an eight-frame dwell is the whole thing +-- being watched, and fast-forward scales only the logic clock. +local U = require("tests.drivers.util") + +local NPC = require("src.world.gen2.Npc") + +-- ../pokegold/maps/PokemonFanClub.asm:311-316 -- the bouncing SPRITE_ODDISH at +-- (7,3) shares the room with a SPRITE_FAIRY doll at (2,4) on +-- SPRITEMOVEDATA_STANDING_DOWN, which is the negative control standing next to +-- it. ../pokegold/maps/PewterPokecenter1F.asm is the reporter's own room. +local ROOM = { map = "POKEMON_FAN_CLUB", x = 7, y = 3 } +local PEWTER = { map = "PEWTER_POKECENTER_1F", x = 1, y = 3 } + +local SPRITEMOVEDATA_POKEMON = 0x16 +local DELTA = { + up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 }, +} +local FACE_FROM = { up = "down", down = "up", left = "right", right = "left" } + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bounce" + local fails, lines = 0, {} + + local function claim(ok, text) + if not ok then fails = fails + 1 end + lines[#lines + 1] = (ok and "PASS " or "FAIL ") .. text + return ok + end + + local function stop(why) + for _, line in ipairs(lines) do U.log(line) end + U.log(why) + while true do coroutine.yield() end + end + + U.wait(45) + local world = game.world + if not (world and world.map) then + U.log("FAIL the gold world never booted, nothing to look at") + while true do coroutine.yield() end + end + + -- ---- the things a human's eyes cannot check ------------------------------ + -- + -- Every one of these fails as silence: a missing movement byte, a sheet with + -- no second frame and a doll that quietly took the animation all look exactly + -- like "the object sits there", which is also what the bug looked like. + claim(NPC.MOVE.POKEMON == SPRITEMOVEDATA_POKEMON, + "SPRITEMOVEDATA_POKEMON is modelled as $16") + claim(type(NPC.bounceFrame) == "function", + "NPC:bounceFrame exists for the draw call to spend") + + local function findObject(mapId, x, y) + local def = world.maps[mapId] + if not def then return nil end + local exact, any + for _, obj in ipairs(def.objects or {}) do + if obj.movement == SPRITEMOVEDATA_POKEMON then + any = any or obj + if obj.x == x and obj.y == y then exact = obj end + end + end + return exact or any + end + + local monDef = findObject(ROOM.map, ROOM.x, ROOM.y) + claim(monDef ~= nil, + ("%s still carries a $16 object"):format(ROOM.map)) + if not monDef then + stop("no bouncing object on the map; stopping rather than faking the moment") + end + if monDef.x ~= ROOM.x or monDef.y ~= ROOM.y then + U.log(("note: using the mon at (%d,%d), not the (%d,%d) in the header") + :format(monDef.x, monDef.y, ROOM.x, ROOM.y)) + end + + local sheet = type(monDef.sprite) == "string" + and world.sprites and world.sprites[monDef.sprite] + claim(sheet ~= nil, + ("its sprite %s resolves to a sheet"):format(tostring(monDef.sprite))) + claim(sheet and (sheet.frames or 1) >= 2, + ("and that sheet is two frames deep (frames = %s) -- one frame is the half" + .. " of #1748 a re-import fixes"):format(sheet and tostring(sheet.frames))) + claim(world.scripts and world.scripts[monDef.scriptKey] ~= nil, + ("its script %s is in the cache, so the A press below opens a box") + :format(tostring(monDef.scriptKey))) + + -- ---- walk in and stand in front of it ------------------------------------ + world:setMap(ROOM.map, monDef.x, monDef.y + 1, "up") + U.wait(20) + local map = world.map + + -- Stand on the mon's neighbour and face back at it; any free neighbour will + -- do, so a re-import that shuffles the room degrades instead of parking the + -- player at a wall. + local spot + for _, dir in ipairs({ "down", "left", "right", "up" }) do + local d = DELTA[dir] + local x, y = monDef.x + d[1], monDef.y + d[2] + if not spot and map:isWalkable(x, y) then + spot = { x = x, y = y, facing = FACE_FROM[dir] } + end + end + claim(spot ~= nil, "there is a free cell beside it to stand on") + if not spot then + stop("nowhere to stand; stopping rather than parking the player at a wall") + end + world:setMap(ROOM.map, spot.x, spot.y, spot.facing) + U.wait(20) + + local mon, dolls = nil, {} + for _, npc in ipairs(world.npcs or {}) do + if npc.def == monDef then mon = npc + elseif npc.def and npc.def.movement ~= SPRITEMOVEDATA_POKEMON then + dolls[#dolls + 1] = npc + end + end + claim(mon ~= nil, "the object spawned as an NPC") + if not mon then stop("the mon never spawned; nothing to watch") end + claim(mon.bouncing == true, "and it spawned bouncing") + + local stillBouncers = 0 + for _, npc in ipairs(dolls) do + if npc.bouncing then stillBouncers = stillBouncers + 1 end + end + claim(stillBouncers == 0, + ("none of the %d other objects in the room bounce -- the Clefairy doll" + .. " beside it must not move"):format(#dolls)) + + -- ---- the cycle, measured on the logic clock ------------------------------ + -- One sample per frame, and the run lengths are what SetFacingBounce's bit 3 + -- decides. Measured rather than eyeballed because four flips a second is + -- exactly the rate a human reads as "about right" when it is wrong. + local samples = {} + for i = 1, 96 do + samples[i] = mon:bounceFrame() + coroutine.yield() + end + local runs, values = {}, {} + for i = 1, #samples do + if i > 1 and samples[i] == samples[i - 1] then + runs[#runs] = runs[#runs] + 1 + else + runs[#runs + 1] = 1 + values[#values + 1] = samples[i] + end + end + claim(#runs >= 5, ("the pose changed %d times in 96 frames"):format(#runs - 1)) + local alternates, dwell, uneven = true, nil, nil + for i = 2, #runs - 1 do + dwell = dwell or runs[i] + if runs[i] ~= dwell then uneven = uneven or runs[i] end + if values[i] == values[i - 1] then alternates = false end + end + claim(alternates and values[1] ~= nil, + "it alternates between two poses and only two") + claim(uneven == nil, ("every whole dwell is the same length" + .. (uneven and (" -- saw %d and %d"):format(dwell or 0, uneven) or ""))) + claim(dwell == 8, ("and it is the eight frames bit 3 buys (measured %s)") + :format(tostring(dwell))) + + -- ---- what the renderer was actually handed ------------------------------- + -- A bounce the draw call drops looks exactly like no bounce, and a doll that + -- picked the override up is the other way to pass this by accident. + local seen, watched = {}, { { key = "mon", npc = mon } } + for i, npc in ipairs(dolls) do + watched[#watched + 1] = { key = "doll" .. i, npc = npc } + end + for _, row in ipairs(watched) do + local sprite = row.npc.sprite + row.sprite, row.real = sprite, sprite.draw + seen[row.key] = {} + sprite.draw = function(s, ...) + local args = { ... } + local log = seen[row.key] + -- "none" rather than a hole, so a sprite that was never drawn at all is + -- not mistaken for one that was drawn with no override. + log[#log + 1] = args[10] == nil and "none" or args[10] + return row.real(s, ...) + end + end + U.wait(40) + for _, row in ipairs(watched) do row.sprite.draw = row.real end + + local monPoses = {} + for _, v in ipairs(seen.mon) do monPoses[v] = true end + claim(#seen.mon > 0, "the mon was drawn") + claim(monPoses[0] and monPoses[1], + "and both icon frames reached SpriteRenderer over 40 frames") + local dollDraws, dollOverrides = 0, 0 + for i = 1, #dolls do + for _, v in ipairs(seen["doll" .. i] or {}) do + dollDraws = dollDraws + 1 + if v ~= "none" then dollOverrides = dollOverrides + 1 end + end + end + claim(dollDraws > 0, ("the other %d objects were drawn too"):format(#dolls)) + claim(dollOverrides == 0, "and not one of them was handed a frame override") + + -- ---- the pair of shots --------------------------------------------------- + -- The counter is held while each capture lands so the file is definitely one + -- pose rather than whichever the spin drifted onto; the cycle above is what + -- proves it runs on its own. + local function holdShot(path, step) + os.execute('mkdir -p "' .. path:match("^(.*)/[^/]+$") .. '" 2>/dev/null') + game.capturePath = path + for _ = 1, 120 do + mon.bounceStep = step + if not game.capturePath then break end + coroutine.yield() + end + mon.bounceStep = step + coroutine.yield() + local f = io.open(path, "rb") + if not f then return nil end + local bytes = f:read("*a") + f:close() + return bytes + end + + local down = holdShot(out .. "/01-pose-down.png", 0) + local up = holdShot(out .. "/02-pose-up.png", 8) + claim(down ~= nil and up ~= nil, "both pose shots reached disk") + claim(down and up and down ~= up, + "and the two files differ, so the screen really changes between poses") + + -- ---- talking to it holds the first pose ---------------------------------- + -- OBJECT_ACTION_BOUNCE's frozen column is SetFacingFreezeBounce, which writes + -- FACING_STEP_DOWN_0 and never touches the counter. + mon.bounceStep = 12 + local phaseBefore = mon.bounceStep + U.tap(game, "a") + U.wait(30) + claim(world.talkNpc == mon or mon.frozen, + "the A press reached the mon and froze it") + local heldWrong = nil + for _ = 1, 60 do + if mon:bounceFrame() ~= 0 then heldWrong = mon:bounceFrame() end + coroutine.yield() + end + claim(heldWrong == nil, + "and it holds its first pose for the whole conversation") + claim(mon.bounceStep == phaseBefore, + ("with the step counter left at %d, so the bounce resumes on the phase it" + .. " froze at (it is %s)"):format(phaseBefore, tostring(mon.bounceStep))) + U.shot(game, out .. "/03-frozen-talking.png") + + U.tap(game, "a") + U.wait(40) + U.tap(game, "a") + U.wait(40) + + -- ---- the reporter's own room --------------------------------------------- + local pewterDef = findObject(PEWTER.map, PEWTER.x, PEWTER.y) + if not pewterDef then + U.log("note: no $16 object on " .. PEWTER.map .. ", skipping its shots") + else + world:setMap(PEWTER.map, pewterDef.x, pewterDef.y + 2, "up") + U.wait(30) + local jiggly + for _, npc in ipairs(world.npcs or {}) do + if npc.def == pewterDef then jiggly = npc end + end + claim(jiggly ~= nil and jiggly.bouncing == true, + ("the %s at (%d,%d) bounces too"):format(PEWTER.map, + pewterDef.x, pewterDef.y)) + if jiggly then + local a, b + local function pin(path, step) + os.execute('mkdir -p "' .. path:match("^(.*)/[^/]+$") .. '" 2>/dev/null') + game.capturePath = path + for _ = 1, 120 do + jiggly.bounceStep = step + if not game.capturePath then break end + coroutine.yield() + end + coroutine.yield() + local f = io.open(path, "rb") + if not f then return nil end + local bytes = f:read("*a") + f:close() + return bytes + end + a = pin(out .. "/04-pewter-down.png", 0) + b = pin(out .. "/05-pewter-up.png", 8) + claim(a ~= nil and b ~= nil and a ~= b, + "and its two pose shots differ as well") + end + end + + for _, line in ipairs(lines) do U.log(line) end + U.log(("%d checks, %d failed"):format(#lines, fails)) + if fails > 0 then + U.log("something above says FAIL, so do not spend time watching the room.") + end + + -- ---- what to look at ----------------------------------------------------- + U.log("shots are in " .. out .. ". 01 and 02 are the two poses of the same") + U.log("mon; they should look like the two halves of its party menu icon,") + U.log("the second one sitting a pixel or two differently. 03 is the same mon") + U.log("mid-conversation, which must match 01.") + U.log("") + U.log("the run ends back in the Fan Club with the Oddish on screen at (7,3).") + U.log("it should visibly flip between its two icon poses about four times a") + U.log("second, without moving off its tile or turning to face anything.") + U.log("the Clefairy doll at (2,4) and the people in the room must stay dead") + U.log("still: a doll that bobs means the bounce got keyed off the sprite") + U.log("instead of the movement byte. a mon that keeps bobbing while its text") + U.log("box is open is the other near miss -- it must hold one pose there and") + U.log("pick the cycle back up where it left off when the box closes.") + + world:setMap(ROOM.map, spot.x, spot.y, spot.facing) + while true do coroutine.yield() end +end diff --git a/tests/drivers/gold_pack_rows_bug1695_test.lua b/tests/drivers/gold_pack_rows_bug1695_test.lua new file mode 100644 index 00000000..2efb9cf7 --- /dev/null +++ b/tests/drivers/gold_pack_rows_bug1695_test.lua @@ -0,0 +1,243 @@ +-- The PACK's TM/HM rows (#1695), the list cursor's colour (#1694) and the rows +-- a message prints on in the description box (#1725). +-- engine/items/tmhm.asm:355-403 and engine/gfx/cgb_layouts.asm:723-726 (pokegold). +-- +-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/gold_pack_rows_bug1695_test.lua \ +-- POKEPORT_SHOT_DIR=/tmp/gold-pack-rows love . +-- +-- No POKEPORT_SPEED: it scales the logic clock only, so every shot here would +-- race the keypress that redrew the list. +local U = require("tests.drivers.util") + +local Bag = require("src.inventory.Bag") +local GbcPalette = require("src.render.GbcPalette") +local PackMenu = require("src.ui.gen2.PackMenu") + +-- ../pokecrystal/maps/NewBarkTown.asm:287 -- the tile in front of the player's +-- own front door. An outdoor map is what makes the ESCAPE ROPE below print +-- OakThisIsntTheTimeText instead of warping the PACK shut. +local HOME = { map = "NEW_BARK_TOWN", x = 13, y = 6 } + +-- One-digit and two-digit counts side by side, both HM ends of the pocket, and +-- the two key items whose submenus reach a SEL and a USE. +local SEED = { + { "POTION", 5 }, + { "SUPER_POTION", 50 }, + { "ESCAPE_ROPE", 3 }, + { "POKE_BALL", 7 }, + { "GREAT_BALL", 12 }, + { "ITEMFINDER", 1 }, + { "BICYCLE", 1 }, + { "TM_DYNAMICPUNCH", 1 }, + { "TM_HEADBUTT", 3 }, + { "TM_THUNDER", 24 }, + { "HM_CUT", 1 }, + { "HM_SURF", 1 }, + { "HM_WATERFALL", 1 }, +} + +-- TM/HM pocket order is wTMsHMs order (engine/items/tmhm.asm:341), and each row +-- carries the number the cart prints at column 5 and the move name at column 8. +local TMHM = { + { "TM_DYNAMICPUNCH", "01", "DYNAMICPUNCH" }, + { "TM_HEADBUTT", "02", "HEADBUTT" }, + { "TM_THUNDER", "25", "THUNDER" }, + { "HM_CUT", "H1", "CUT" }, + { "HM_SURF", "H3", "SURF" }, + { "HM_WATERFALL", "H7", "WATERFALL" }, +} + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-pack-rows" + local failed = 0 + + local function pass(ok, line) + if not ok then failed = failed + 1 end + U.log((ok and "PASS " or "FAIL ") .. line) + end + + local function shot(name) + U.wait(4) + U.shot(game, ("%s/%s.png"):format(out, name)) + end + + local function tap(button, frames) + U.tap(game, button) + U.wait(frames or 5) + end + + U.wait(45) + assert(game.world and game.world.map, "gold world did not boot") + + local world, save = game.world, game.save + + -- A later map edit must not park the player against a wall: try the door + -- tile, then any free neighbour of it. + local placed = world:setMap(HOME.map, HOME.x, HOME.y, "down") + if not placed then + for _, step in ipairs({ { 0, -1 }, { 1, 0 }, { -1, 0 }, { 0, 1 } }) do + placed = world:setMap(HOME.map, HOME.x + step[1], HOME.y + step[2], "down") + if placed then break end + end + end + U.wait(12) + + local mapId = world.map and world.map.id + local env = world.map and world.map.def and world.map.def.environment + pass(placed and env ~= "CAVE" and env ~= "DUNGEON", + ("standing in %s (environment %s), where an ESCAPE ROPE has nowhere to " .. + "pay out to"):format(tostring(mapId), tostring(env))) + + save.inventory = {} + save.bagOrder = {} + for _, entry in ipairs(SEED) do + local id, count = entry[1], entry[2] + if game.data.items and game.data.items[id] then + save.inventory[id] = count + table.insert(save.bagOrder, id) + else + pass(false, id .. " is not in this cache, so its row will be missing") + end + end + Bag.order(save, { items = game.data.items }) + + -- A probe pack for the checks, so the one on screen opens on a clean cursor. + local probe = PackMenu.new(game, { save = save, world = world, + pocket = "TM_HM" }) + + for i, want in ipairs(TMHM) do + local row = probe.rows[i] + pass(row and row.id == want[1] and row.tmhmLabel == want[2], + ("row %d is %s and its number reads %q (got %s / %s)"):format( + i, want[1], want[2], tostring(row and row.id), + tostring(row and row.tmhmLabel))) + pass(row and row.teaches == want[3], + ("row %d prints the move name %q rather than the item name %s"):format( + i, want[3], tostring(row and row.name))) + end + + pass(probe.gfx and probe.gfx:available(), + "this cache has the PACK's own tiles, so the screen is the cart's chrome " .. + "and not the fallback boxes") + pass(GbcPalette.available(), + "the palette shader is loaded, so a coloured cursor is drawable at all") + pass(GbcPalette.mode == "gbc", + ("colour mode is %q; anything else draws the cursor in DMG greys and " .. + "looks exactly like the bug"):format(tostring(GbcPalette.mode))) + + local cursorPal = probe.gfx and probe.gfx:available() + and probe.gfx:colorsAt(7, 2) + local ink = cursorPal and cursorPal[4] + pass(ink and ink[1] == 255 and ink[2] == 0 and ink[3] == 0, + ("the cursor column (7,2) resolves to a palette whose colour 3 is " .. + "255,0,0 (got %s)"):format(ink and table.concat(ink, ",") or "nil")) + local lastPal = probe.gfx and probe.gfx:available() + and probe.gfx:colorsAt(7, 10) + pass(lastPal == cursorPal, + "and the fifth list row's cursor cell (7,10) is in the same 1x9 zone") + + local finder = game.data.items and game.data.items.ITEMFINDER + pass(finder and finder.canSelect == true and world.registerItem ~= nil, + "the ITEMFINDER is registerable, so SEL prints \"Registered the\" and " .. + "not the refusal") + local rope = game.data.items and game.data.items.ESCAPE_ROPE + pass(rope and rope.fieldMenu ~= "ITEMMENU_NOUSE", + "the ESCAPE ROPE's submenu carries a USE row to reach the three-line " .. + "OAK message with") + + if failed > 0 then + U.log(("%d check(s) failed above, so the shots below cannot be read as " .. + "a pass."):format(failed)) + end + + game.packCursor = nil + local pack = PackMenu.new(game, { save = save, world = world, + onClose = function() game.stack:pop() end }) + game.stack:push(pack) + + U.log("00-items: the arrow beside POTION should be the same red as the") + U.log("pocket plaque on the left, not black. black in every pocket means") + U.log("the palette never reached it (#1694).") + shot("00-items") + + tap("select") + tap("down") + U.log("01-items-select: row 1 now carries the hollow arrow and row 2 the") + U.log("solid one, both red. \"Where should this\" / \"be moved to?\" sit on") + U.log("rows 14 and 16 with a blank row between them (#1725).") + U.log("switching row is " .. tostring(pack.switching) .. + ", cursor on " .. tostring(pack.index)) + shot("01-items-select") + tap("b") + tap("up") + + tap("right") + shot("02-balls") + tap("right") + shot("03-key-items") + U.log("02 and 03: same red arrow in POKe BALLS and KEY ITEMS.") + + tap("right") + U.log("04-tmhm-top: the rows read \"01 DYNAMICPUNCH\", \"02 HEADBUTT\",") + U.log("\"25 THUNDER\", \"H1 CUT\", \"H3 SURF\". the number is hard against the") + U.log("left edge of the item area where the blue pattern column ends, the") + U.log("arrow one tile later, the move name from column 8. the word TM or HM") + U.log("printed anywhere, or a right-aligned number, is the bug (#1695).") + U.log("pocket is " .. tostring(pack:pocket().id) .. + " with " .. tostring(#pack.rows) .. " rows") + shot("04-tmhm-top") + + for _ = 1, 5 do tap("down") end + U.log("05-tmhm-hms: scrolled to the last HM. \"H7 WATERFALL\" reads with a") + U.log("single digit and no count; \"H07\" would mean the TM branch took it.") + U.log("the three TM rows above it still carry their xNN on the line below.") + shot("05-tmhm-hms") + + tap("left") + tap("left") + tap("left") + tap("a") + tap("down") + tap("down") + tap("a") + U.log("06-toss-how-many: \"Throw away how\" on row 14 and \"many?\" on row 16,") + U.log("with a full blank row between them and the first line clear of the") + U.log("box's top border. adjacent lines mean the message path is still") + U.log("single-spaced (#1725).") + U.log("message is " .. + (pack.message and table.concat(pack.message, " / ") or "nil")) + shot("06-toss-how-many") + + tap("b") + tap("down") + tap("down") + tap("a") + tap("a") + U.log("07-oak-three-lines: a message too tall for two rows still packs one") + U.log("row apart, so \"OAK: GOLD!\" / \"This isn't the\" / \"time to use that!\"") + U.log("fill rows 13, 14 and 15. a gap here would clip the third line.") + U.log("message is " .. + (pack.message and table.concat(pack.message, " / ") or "nil")) + shot("07-oak-three-lines") + + tap("b") + tap("right") + tap("right") + tap("a") + tap("down") + tap("a") + U.log("08-registered: the case the report filed. \"Registered the\" on row 14") + U.log("and \"ITEMFINDER.\" on row 16, lining up with the item description") + U.log("that shares this box. row 13 plus row 14 is the old behaviour; row 15") + U.log("plus a clipped row 17 means the offset went the other way.") + U.log("message is " .. + (pack.message and table.concat(pack.message, " / ") or "nil")) + shot("08-registered") + + U.log("shots in " .. out .. "; the PACK is open on KEY ITEMS, controls yours") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/gold_shaderfx_menu_black_crop_test.lua b/tests/drivers/gold_shaderfx_menu_black_crop_test.lua new file mode 100644 index 00000000..0e3cbbb9 --- /dev/null +++ b/tests/drivers/gold_shaderfx_menu_black_crop_test.lua @@ -0,0 +1,90 @@ +-- Regression test: with SHADER FX active on Gen 2 (Gold), opening the +-- START menu used to render as a flat black fill +-- instead of menu content. Root cause: ShaderFX.lua's cropToGbSource() +-- samples the scene canvas via love.graphics.draw(), which multiplies by +-- the current draw color -- the menu's own drawing (black text/border) +-- left that color at (0,0,0,1) and never reset it, so cropToGbSource's own +-- draw silently multiplied its whole output to black. push("all") at the +-- top of cropToGbSource saves/restores state for its caller but does not +-- reset color, so the function must set its own white explicitly rather +-- than trust whatever the caller left active. Fixed by an explicit +-- love.graphics.setColor(1, 1, 1, 1) immediately before that draw call. +-- +-- Samples ShaderFX._lastCrop directly rather than a screenshot of the +-- final window: ShaderFX.render() always draws the correct, unmodified +-- `canvas` as a base layer before compositing the (possibly-broken) shader +-- chain output on top, so a whole-window screenshot stays mostly readable +-- even when the crop itself is fully black -- it just loses the shader +-- effect under a faint dark overlay. Only the crop canvas itself catches +-- the bug precisely. +-- +-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_shaderfx_menu_black_crop_test.lua \ +-- lovec.exe . +-- +-- Skips itself (does not fail) if no ShaderFX preset is installed in the +-- save dir's shaders/ folder (see ShaderFX.presetDir()) -- this test needs +-- a real preset to exercise cropToGbSource at all. +local U = require("tests.drivers.util") +local StartMenu = require("src.ui.gen2.StartMenu") + +-- Same 4x4-grid luminance-range technique this bug was originally +-- characterized with. +local function luminanceRange(canvas) + local id = canvas:newImageData() + local w, h = id:getDimensions() + local minL, maxL = 1, 0 + for gy = 0, 3 do + for gx = 0, 3 do + local x = math.min(w - 1, math.floor((gx + 0.5) * w / 4)) + local y = math.min(h - 1, math.floor((gy + 0.5) * h / 4)) + local r, g, b = id:getPixel(x, y) + local l = 0.299 * r + 0.587 * g + 0.114 * b + if l < minL then minL = l end + if l > maxL then maxL = l end + end + end + return maxL - minL +end + +return function(game) + U.wait(45) + assert(game.world and game.world.map, "gold world did not boot") + + local ShaderFX = require("src.render.ShaderFX") + local entry = ShaderFX.findEntry("gameboy-color-dot-matrix.slangp") + if not entry then + print("[driver] SKIP no ShaderFX preset installed -- cannot exercise cropToGbSource") + love.event.quit(0) + return + end + if not entry.converted then + local ok, err = ShaderFX.convert(entry) + assert(ok, "ShaderFX.convert failed: " .. tostring(err)) + end + game.options = game.options or {} + game.options.shaderfx = entry.name + game:applyOptions() + assert(ShaderFX.active("main"), "ShaderFX main slot did not activate") + + -- Baseline: no menu on the stack, crop should already have real content. + U.wait(10) + local baseline = luminanceRange(ShaderFX._lastCrop) + assert(baseline > 0.05, + ("sanity check failed: baseline crop (no menu) is already near-flat " .. + "(variance %.3f) -- something else is broken, not this bug"):format(baseline)) + + -- The actual repro: push the START menu, one more frame renders through + -- it, then sample the same crop canvas again. + game.stack:push(StartMenu.new(game, { save = game.save })) + U.wait(3) + local withMenu = luminanceRange(ShaderFX._lastCrop) + game.stack:pop() + + assert(withMenu > 0.05, + ("crop is near-flat with the START menu open (variance %.3f, baseline " .. + "was %.3f) -- the ShaderFX Gen2 blank-menu bug is back"):format(withMenu, baseline)) + + print(("[driver] PASS gold shaderfx menu-blank regression, baseline=%.3f withMenu=%.3f") + :format(baseline, withMenu)) + love.event.quit(0) +end diff --git a/tests/drivers/gold_shaderfx_zoom_sizing_test.lua b/tests/drivers/gold_shaderfx_zoom_sizing_test.lua new file mode 100644 index 00000000..4914d184 --- /dev/null +++ b/tests/drivers/gold_shaderfx_zoom_sizing_test.lua @@ -0,0 +1,73 @@ +-- Regression test: on Gen 2, SHADER FX only ever shaded the small centred +-- "faithful ratio" 160x144 box, never the +-- real on-screen footprint of the live overworld -- unlike Gen 1, whose +-- Renderer.lua grows ShaderFX's rect to the real world canvas/zoom footprint +-- whenever the live overworld is what's on screen (src/render/Renderer.lua, +-- the `self.worldActive` branch around line 977). Gen2's drawViewportFrame +-- instead always built ShaderFX's rect from Chrome.fitScale's fixed +-- faithful-ratio box (src/core/Game2.lua), so on any window that is not +-- exactly 4:3 -- every real phone, and any resized desktop window -- the +-- live world already draws edge to edge past that box (Chrome.lua's own +-- comment: "The live overworld IS the background here -- it draws edge to +-- edge... with no surround to paint first"), and SHADER FX only ever shaded +-- the small box in the middle, leaving the rest of the visible map unshaded. +-- +-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_shaderfx_zoom_sizing_test.lua \ +-- lovec.exe . +-- +-- Skips itself (does not fail) if no ShaderFX preset is installed in the +-- save dir's shaders/ folder (see ShaderFX.presetDir()). +local U = require("tests.drivers.util") +local GameViewport = require("src.render.GameViewport") + +return function(game) + U.wait(45) + assert(game.world and game.world.map, "gold world did not boot") + + local ShaderFX = require("src.render.ShaderFX") + local entry = ShaderFX.findEntry("gameboy-color-dot-matrix.slangp") + if not entry then + print("[driver] SKIP no ShaderFX preset installed -- cannot exercise ShaderFX.render") + love.event.quit(0) + return + end + if not entry.converted then + local ok, err = ShaderFX.convert(entry) + assert(ok, "ShaderFX.convert failed: " .. tostring(err)) + end + game.options = game.options or {} + game.options.shaderfx = entry.name + game:applyOptions() + assert(ShaderFX.active("main"), "ShaderFX main slot did not activate") + + -- conf.lua's bare-desktop default (1024x768) is exactly 4:3, which + -- coincidentally hides this bug -- resize to a 16:9 window, the shape of + -- every real phone/handheld this project actually ships on, where the + -- live world draws well past the small faithful-ratio box. + love.window.setMode(1280, 720, { resizable = true }) + U.wait(5) + + local w, h = GameViewport.dimensions() + local pw, ph = GameViewport.pixelDimensions() + assert(game.frameWorldActive, "expected the live overworld to be on screen") + + local rect = ShaderFX._lastRect + assert(rect, "ShaderFX._lastRect was never set -- ShaderFX.render did not run") + print(("[driver] window %dx%d (%dx%d px), faithful box would be %dx%d, " .. + "ShaderFX rect is %.1fx%.1f"):format( + w, h, pw, ph, + 160 * math.floor(math.min(w / 160, h / 144)), + 144 * math.floor(math.min(w / 160, h / 144)), + rect.w, rect.h)) + + -- Real assertion: with the live overworld on screen, ShaderFX's rect + -- should track the world's real edge-to-edge footprint (~ the full + -- window), not the small integer-multiple 4:3 box centered inside it. + assert(rect.w >= pw - 2 and rect.h >= ph - 2, + ("SHADER FX's rect only covers %.1fx%.1f of a %dx%d px window -- still " .. + "the fixed faithful-ratio box, not the live world's real on-screen " .. + "footprint"):format(rect.w, rect.h, pw, ph)) + + print("[driver] PASS gold shaderfx zoom-sizing regression") + love.event.quit(0) +end diff --git a/tests/drivers/gold_splash_skip.lua b/tests/drivers/gold_splash_skip.lua new file mode 100644 index 00000000..94f4cb04 --- /dev/null +++ b/tests/drivers/gold_splash_skip.lua @@ -0,0 +1,42 @@ +-- A button during the GS splash skips the intro movie +-- (pokegold engine/menus/intro_menu.asm:848-851 IntroSequence). +local U = require("tests.drivers.util") +local GameFreakPresents = require("src.ui.gen2.GameFreakPresents") +local GoldSilverIntro = require("src.ui.gen2.GoldSilverIntro") +local TitleState = require("src.ui.gen2.TitleState") + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-splash" + + U.wait(10) + game:showGameFreak() + U.wait(5) + assert(getmetatable(game.stack:top()) == GameFreakPresents, + "showGameFreak did not open the GS splash (top " + .. tostring(game.stack:top()) .. ")") + U.wait(40) + U.shot(game, out .. "/splash.png") + U.tap(game, "b") + for _ = 1, 60 do + if getmetatable(game.stack:top()) == TitleState then break end + assert(getmetatable(game.stack:top()) ~= GoldSilverIntro, + "a skipped splash still played the intro movie") + U.wait(1) + end + assert(getmetatable(game.stack:top()) == TitleState, + "a skipped splash did not land on the title (top " + .. tostring(game.stack:top()) .. ")") + U.wait(30) + U.shot(game, out .. "/title.png") + + -- Watched through, the splash still hands off to the movie. + game:showGameFreak() + for _ = 1, 900 do + if getmetatable(game.stack:top()) == GoldSilverIntro then break end + U.wait(5) + end + assert(getmetatable(game.stack:top()) == GoldSilverIntro, + "a watched splash did not hand off to the intro movie (top " + .. tostring(game.stack:top()) .. ")") + U.log("PASS gold splash skip in " .. out) +end diff --git a/tests/drivers/gold_stats_page_tint_bug1693_test.lua b/tests/drivers/gold_stats_page_tint_bug1693_test.lua new file mode 100644 index 00000000..327d5e99 --- /dev/null +++ b/tests/drivers/gold_stats_page_tint_bug1693_test.lua @@ -0,0 +1,279 @@ +-- #1693: the mon SUMMARY's lower half never took the selected page's colour. +-- StatsScreen_LoadGFX .LoadPals farcalls LoadStatsScreenPals with the page +-- index, and that writes one gfx/stats/stats.pal colour over colour 0 of BG +-- palette 0 AND BG palette 2 (engine/gfx/color.asm:373-393); WipeAttrmap +-- leaves rows 8-17 on palette 0 with the exp strip at (10,16) on palette 2 +-- (engine/gfx/cgb_layouts.asm:219-229), so both writes land below the rule. +-- Never add POKEPORT_SPEED here: fast-forward scales the logic clock only, so +-- the cry and the page redraw stop landing in the order a reader is judging. +-- +-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/gold_stats_page_tint_bug1693_test.lua \ +-- POKEPORT_SHOT_DIR=/tmp/gold-bug1693 \ +-- perl -e 'alarm 240; exec @ARGV' \ +-- python3 -c "import pty; pty.spawn(['love','.'])" +local U = require("tests.drivers.util") + +local GbcPalette = require("src.render.GbcPalette") +local HpBar = require("src.battle.gen2.HpBar") +local Mon = require("src.battle.gen2.Mon") +local SummaryMenu = require("src.ui.gen2.SummaryMenu") + +-- gfx/stats/stats.pal, 5-bit the way the ROM keeps it: pink, green, blue +local ROM_TINTS = { { 31, 19, 31 }, { 21, 31, 14 }, { 17, 31, 31 } } +local PAGE_NAMES = { "pink", "green", "blue" } + +local function up(v) return math.floor(v * 255 / 31 + 0.5) end +local function pct(v) return math.floor(v + 0.5) end + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1693" + local fails = 0 + + local function ok(cond, msg) + if cond then U.log("PASS", msg) else fails = fails + 1 U.log("FAIL", msg) end + return cond and true or false + end + local function tap(btn) U.tap(game, btn) U.wait(4) end + local function top() return game.stack:top() end + local function shot(name) + return ok(U.shot(game, ("%s/%s.png"):format(out, name)), + name .. " reached disk") + end + + U.wait(45) + if not ok(game.world and game.world.map ~= nil, + "gold booted into the world") then + error("gold stats tint: no world, nothing to open a summary over") + end + + -- ---- the party the pink page needs --------------------------------------- + -- Mon.new is the only Gen 2 party builder; a mon out of Gen 1's Pokemon.new + -- comes back with no moves and no growth rate, so both bars would be empty. + local save = game.save + save.player.name = "GOLD" + save.player.id = 12345 + save.party = { + Mon.new(game.data, "CYNDAQUIL", 22), + Mon.new(game.data, "TOTODILE", 18), + } + local lead = save.party[1] + lead.item = "BERRY" + lead.hp = math.max(1, math.floor((lead.maxHp or 1) * 0.45)) + local def = game.data.pokemon[lead.species] + local rates = game.data.pokemon.growthRates + local rate = rates and def and rates[def.growthRate] + if rate then + local base = Mon.experienceForLevel(rate, lead.level) + local next_ = Mon.experienceForLevel(rate, lead.level + 1) + lead.experience = base + math.floor((next_ - base) * 0.55) + end + ok(game.world.giveEgg and game.world:giveEgg(175, 5) and save.party[3] + and save.party[3].isEgg == true, "slot 3 holds an egg") + + -- ---- everything the eye cannot check ------------------------------------- + for page = 1, 3 do + local tint = SummaryMenu.PAGE_TINTS and SummaryMenu.PAGE_TINTS[page] + ok(tint ~= nil and tint[1] == up(ROM_TINTS[page][1]) + and tint[2] == up(ROM_TINTS[page][2]) + and tint[3] == up(ROM_TINTS[page][3]), + ("the %s tint is stats.pal's own colour"):format(PAGE_NAMES[page])) + local probe = setmetatable({ page = page }, { __index = SummaryMenu }) + local lower = probe.lowerColors and probe:lowerColors() + ok(lower ~= nil and lower[1] == tint and lower[4][1] == 0, + ("the %s lower palette is that tint over black ink"):format( + PAGE_NAMES[page])) + end + ok(GbcPalette.available(), "the GBC shade-remap shader compiled") + + -- COLOR is the port's own display row and DMG flattens every palette in the + -- game, so a run started on it looks exactly like the bug. + game.options = game.options or {} + local startedOn = game.options.color or "gbc" + local function setColor(mode) + game.options.color = mode + if game.save then game.save.options = game.options end + GbcPalette.applyOptions(game.options) + return GbcPalette.mode + end + if startedOn ~= "gbc" then + U.log("COLOR was on " .. GbcPalette.modeLabel(startedOn) .. ", which would") + U.log("grey every shot below for the wrong reason. forcing it to GBC.") + end + ok(setColor("gbc") == "gbc", "COLOR is GBC for the run") + + -- ---- the live screen, counted rather than admired ------------------------ + -- The summary draws at GB coordinates, so a 160x144 canvas at scale 1 makes + -- a GB pixel a pixel and the tint countable. + local function frameOf(screen) + local G = love.graphics + local canvas = G.newCanvas(160, 144) + G.setCanvas(canvas) + G.clear(0, 0, 0, 1) + G.setColor(1, 1, 1, 1) + screen:draw() + G.setCanvas() + G.setColor(1, 1, 1, 1) + return canvas:newImageData() + end + + local function near(a, b) return math.abs(a - b) <= 2 end + + -- Counted against stats.pal itself, not the port's table, so a run that has + -- no PAGE_TINTS cannot agree with itself. + local function tintFor(page) + local rom = ROM_TINTS[page] or ROM_TINTS[1] + return { up(rom[1]), up(rom[2]), up(rom[3]) } + end + + local function scan(img, want, x0, y0, x1, y1) + local hit, white, total = 0, 0, 0 + for y = y0, y1 do + for x = x0, x1 do + local r, g, b = img:getPixel(x, y) + r, g, b = r * 255, g * 255, b * 255 + total = total + 1 + if near(r, want[1]) and near(g, want[2]) and near(b, want[3]) then + hit = hit + 1 + end + if near(r, 255) and near(g, 255) and near(b, 255) then + white = white + 1 + end + end + end + return hit / total * 100, white / total * 100 + end + + -- StatsScreen_LoadGFX .ClearBox: hlcoord 0, 8 / lb bc, 10, 20 + local LOWER = { 0, 64, 159, 143 } + local function report(label, hit, white) + U.log((" %-24s %3d%% tinted %3d%% white"):format(label, pct(hit), + pct(white))) + end + + local function zone(img, tint, label, rect, wantTint) + local hit, white = scan(img, tint, rect[1], rect[2], rect[3], rect[4]) + report(label, hit, white) + ok(hit >= wantTint, label .. " carries the page tint") + ok(white <= 2, label .. " has no white left in it") + end + + local function inspect(screen, name) + local img = frameOf(screen) + local tint = tintFor(screen.page) + zone(img, tint, "rows 8-17", LOWER, 40) + if screen.page == SummaryMenu.PINK_PAGE then + -- DrawPlayerHP: "HP:" at (0,9), six bar cells from (2,9) + zone(img, tint, "the HP bar's cells", { 16, 72, 63, 79 }, 8) + -- LoadPinkPage's divider column, and FillInExpBar at (11,16) + zone(img, tint, "the divider, column 9", { 72, 64, 79, 143 }, 20) + zone(img, tint, "the exp bar", { 88, 128, 151, 135 }, 8) + elseif screen.page == SummaryMenu.BLUE_PAGE then + zone(img, tint, "the divider, column 10", { 80, 64, 87, 143 }, 20) + end + -- The upper half is on the mon palette, which the cart never tints. + local _, white = scan(img, tint, 0, 0, 159, 55) + ok(white >= 20, "the upper half stayed white") + shot(name) + end + + -- ---- walk in through the pad --------------------------------------------- + tap("start") + local menu = top() + if not ok(menu and menu.screenId == "Gen2StartMenu", + "START opened the menu") then + error("gold stats tint: no start menu, cannot reach the summary") + end + for _ = 1, 10 do + if menu.list:current().value == "pokemon" then break end + tap("down") + end + ok(menu.list:current().value == "pokemon", "the cursor found the party row") + tap("a") + local party = top() + if not ok(party and party.screenId == "Gen2PartyMenu", + "that opened the party list") then + error("gold stats tint: no party list, cannot reach the summary") + end + + local function openStats() + tap("a") + ok(party.submenu ~= nil and party.submenu.items[1].id == "STATS", + "the mon submenu leads with STATS") + tap("a") + local screen = top() + ok(screen and screen.screenId == "Gen2SummaryMenu", + "STATS opened the summary") + return screen + end + + local summary = openStats() + ok(summary.hud and summary.hud:available(), + "the HUD sheet is cached, so the bars are the cart's own tiles") + ok(summary.statsTiles and summary:statsTiles() ~= nil, + "menu_gfx.stats is cached, so the divider and caps are too") + local fraction = HpBar.expFraction(summary.mon, summary:growth(), + Mon.experienceForLevel) + ok(fraction > 0.05 and fraction < 0.95, + "the lead is part way to its next level, so the exp bar is half drawn") + ok((summary.mon.hp or 0) > 0 and summary.mon.hp < (summary.mon.maxHp or 0), + "and hurt, so the HP bar has empty cells as well as full ones") + + inspect(summary, "01-pink-page") + tap("right") + ok(summary.page == SummaryMenu.GREEN_PAGE, "right reached the green page") + inspect(summary, "02-green-page") + tap("right") + ok(summary.page == SummaryMenu.BLUE_PAGE, "right reached the blue page") + inspect(summary, "03-blue-page") + tap("right") + ok(summary.page == SummaryMenu.PINK_PAGE, "and wrapped back to pink") + + -- ---- the two screens that must stay white -------------------------------- + tap("b") + ok(top() == party, "b came back to the list") + tap("down") + tap("down") + ok(party.index == 3, "the cursor reached the egg") + local egg = openStats() + ok(egg.mon and egg.mon.isEgg == true, "on the egg") + local eggTint, eggWhite = scan(frameOf(egg), tintFor(SummaryMenu.PINK_PAGE), + LOWER[1], LOWER[2], LOWER[3], LOWER[4]) + report("the egg's rows 8-17", eggTint, eggWhite) + -- EggStatsScreen never calls LoadStatsScreenPals (stats_screen.asm:786) + ok(eggTint <= 1 and eggWhite >= 40, "the egg page stayed white") + shot("04-egg-white") + + tap("b") + tap("up") + tap("up") + ok(setColor("dmg") == "dmg", "COLOR switched to DMG") + local dmg = openStats() + local dmgTint, dmgWhite = scan(frameOf(dmg), tintFor(dmg.page), + LOWER[1], LOWER[2], LOWER[3], LOWER[4]) + report("DMG's rows 8-17", dmgTint, dmgWhite) + -- LoadStatsScreenPals opens `call CheckCGB / ret z` (color.asm:373-375) + ok(dmgTint <= 1 and dmgWhite >= 40, "DMG flattened the tint back to white") + shot("05-dmg-white") + tap("b") + ok(setColor("gbc") == "gbc", "COLOR is back on GBC for the handoff") + + -- ---- what a reader is looking at ----------------------------------------- + openStats() + U.wait(4) + + if fails > 0 then + U.log(("%d check(s) failed above. do not judge the colours -- the run is"): + format(fails)) + U.log("not showing you what it claims to.") + end + U.log("shots are in " .. out .. ". the summary is open on the pink page and") + U.log("left/right turn it: everything under the horizontal rule should be") + U.log("washed pink, then green, then cyan, matching whichever page square is") + U.log("the large one, while the pic, nickname and level stay white.") + U.log("a partial fix leaves white in one of three places -- an 8px stripe") + U.log("down the divider column, the bands above and below the HP bar's fill,") + U.log("or the exp bar's interior inside its black frame.") + + while true do coroutine.yield() end +end diff --git a/tests/drivers/gold_tm_fanfare_bug1483_test.lua b/tests/drivers/gold_tm_fanfare_bug1483_test.lua new file mode 100644 index 00000000..0724bd4c --- /dev/null +++ b/tests/drivers/gold_tm_fanfare_bug1483_test.lua @@ -0,0 +1,338 @@ +-- #1483: the TM/HM jingle under `verbosegiveitem`, and the item jingle beside it. +-- +-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/gold_tm_fanfare_bug1483_test.lua \ +-- POKEPORT_SHOT_DIR=/tmp/gold-tm-fanfare \ +-- perl -e 'alarm 300; exec @ARGV' \ +-- python3 -c "import pty; pty.spawn(['love','.'])" +-- +-- GiveItemScript is `waitsfx / specialsound / waitbutton`, the drain sitting +-- ABOVE the sound (engine/overworld/scripting.asm:441-449). Without it PlaySFX +-- drops SFX_GET_TM ($9b) under the beep the box rang on its own press, +-- SFX_READ_TEXT_2 ($08) -- while SFX_ITEM ($01) outranks that beep and survives. +-- +-- No POKEPORT_SPEED here on purpose: audio runs on its own real-time +-- accumulator, so fast-forward slides the jingle off the press it belongs to, +-- and the ordering is the whole thing being judged. +local U = require("tests.drivers.util") + +local FieldMoves = require("src.world.gen2.FieldMoves") +local Sound = require("src.core.Sound") + +-- ../pokegold/maps/Route32.asm:866 -- Route32RoarTMGuyScript, `verbosegiveitem +-- TM_ROAR`, gated on nothing but its own EVENT_GOT_TM05_ROAR latch. +local TM_GIVER = { map = "ROUTE_32", x = 15, y = 13, item = "TM_ROAR" } +-- ../pokegold/maps/Route32Pokecenter1F.asm:109 -- the fishing guru, +-- `verbosegiveitem OLD_ROD`, one warp away and the control for the same gate. +local ITEM_GIVER = + { map = "ROUTE_32_POKECENTER_1F", x = 1, y = 4, item = "OLD_ROD" } + +-- side the player stands on, and the way they have to look from there +local SIDES = { + { dx = -1, dy = 0, face = "right" }, + { dx = 1, dy = 0, face = "left" }, + { dx = 0, dy = -1, face = "down" }, + { dx = 0, dy = 1, face = "up" }, +} + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-tm-fanfare" + local fails, lines = 0, {} + + local function claim(ok, text) + if not ok then fails = fails + 1 end + lines[#lines + 1] = (ok and "PASS " or "FAIL ") .. text + return ok + end + + local function stop() + for _, line in ipairs(lines) do U.log(line) end + while true do coroutine.yield() end + end + + U.wait(45) + local world = game.world + if not (world and world.map) then + U.log("FAIL the gold world never booted, nothing to listen to") + while true do coroutine.yield() end + end + local data = game.data + + -- ---- everything that fails as silence -------------------------------------- + local audio = data.audio or {} + local ids = {} + for i, name in ipairs(audio.sfxOrder or {}) do ids[name] = i - 1 end + for _, name in ipairs({ "Sfx_GetTm", "Sfx_Item", "Sfx_ReadText2" }) do + claim(ids[name] ~= nil and audio.sfx and audio.sfx[name] ~= nil, + ("the cache can play %s (id %s)"):format(name, tostring(ids[name]))) + end + -- The gate is a numeric comparison, so the whole bug only exists while these + -- three ids sit in this order (constants/sfx_constants.asm:4, :11). + claim((ids.Sfx_Item or 0) < (ids.Sfx_ReadText2 or 0) + and (ids.Sfx_ReadText2 or 0) < (ids.Sfx_GetTm or 0), + "and SFX_ITEM outranks the box beep, which outranks SFX_GET_TM") + claim(type(Sound.waitSfxDone) == "function", + "Sound.waitSfxDone exists for specialsound's `waitsfx` to call") + + local vol = game.save.options and game.save.options.sfxVol + claim(vol ~= 0, ("SFX VOL is %s"):format(tostring(vol))) + if vol == 0 then + U.log("SFX VOL is ZERO -- a muted run sounds exactly like the bug.") + U.log("turn it up in OPTION before trusting anything you hear here.") + end + + for _, spot in ipairs({ TM_GIVER, ITEM_GIVER }) do + local def = data.items and data.items[spot.item] + claim(def ~= nil, ("the cache names the item %s"):format(spot.item)) + spot.pocket = def and def.pocket + spot.index = def and def.index + spot.want = (spot.pocket == "TM_HM") and "Sfx_GetTm" or "Sfx_Item" + end + claim(TM_GIVER.pocket == "TM_HM", + ("TM_ROAR sits in the TM_HM pocket (got %s)"):format( + tostring(TM_GIVER.pocket))) + claim(ITEM_GIVER.pocket ~= "TM_HM", + ("OLD_ROD does not (got %s)"):format(tostring(ITEM_GIVER.pocket))) + + -- ---- the recorder ---------------------------------------------------------- + -- + -- Sound.play returns nil when the priority gate DROPS the request, which is + -- the bug itself: the sound neither rings nor ducks the music. + local calls, frame, inSpecial = {}, 0, false + local realPlay = Sound.play + Sound.play = function(d, name) + local src = realPlay(d, name) + calls[#calls + 1] = { + name = Sound.resolve(d, name), frame = frame, + started = src ~= nil, special = inSpecial, + } + return src + end + local drains = 0 + local realDrain = Sound.waitSfxDone + Sound.waitSfxDone = function() + if inSpecial then drains = drains + 1 end + return realDrain() + end + local realSpecial = world.specialSound + world.specialSound = function(self, itemIndex) + inSpecial = true + local ok, err = pcall(realSpecial, self, itemIndex) + inSpecial = false + if not ok then error(err) end + end + + local function tap(button, gap) + table.insert(game.input.pressQueue, button) + game.input.state[button] = true + frame = frame + 1 + coroutine.yield() + game.input.state[button] = false + for _ = 1, (gap or 12) do frame = frame + 1 coroutine.yield() end + end + + local function walk(button, n) + for _ = 1, n do + table.insert(game.input.pressQueue, button) + game.input.state[button] = true + frame = frame + 1 + coroutine.yield() + end + game.input.state[button] = false + U.wait(6) + end + + -- The VM finishes its last `showRaw` and stops while the boxes it queued are + -- still on the stack, so "the script ended" is not "the conversation ended": + -- the stack has to be back where it was before A was ever pressed. + local baseDepth = #game.stack.states + local function idle() + return #game.stack.states <= baseDepth + and not world.vm:running() and not world:busy() + end + + -- Press first, then look: at rest the Gold overworld holds nothing on the + -- stack at all, so a leading idle() check would return before the A that + -- starts the conversation was ever sent. + local function mash(limit, gap) + for _ = 1, (limit or 40) do + tap("a", gap) + if idle() then return true end + end + return idle() + end + + local function clear(limit) + for _ = 1, (limit or 12) do + if idle() then return end + tap("b", 8) + end + end + + -- ---- walk up to one giver and take what they hand over --------------------- + local function visit(spot, shots) + clear() -- nothing from the last giver may still be on the stack + world:applyPlayerState(FieldMoves.PLAYER_NORMAL) + -- Land on the map first so the collision under the giver is this map's. + world:setMap(spot.map, spot.x, spot.y + 1, "up") + U.wait(20) + world.noWildEncounters = true + + -- A later map edit must degrade to a different side rather than park the + -- player in a wall, so the standing tile is picked from the live map. + local side + for _, s in ipairs(SIDES) do + local sx, sy = spot.x + s.dx, spot.y + s.dy + if not side and world.map:isWalkable(sx, sy) + and not world:npcAt(sx, sy) then + side = s + end + end + if not claim(side ~= nil, + ("a free tile beside the giver at (%d,%d) on %s"):format( + spot.x, spot.y, spot.map)) then + return nil + end + + -- Start two cells back and walk in, so the approach is the player's own. + local steps, sx, sy = 0, spot.x + side.dx, spot.y + side.dy + for n = 2, 3 do + local tx, ty = spot.x + side.dx * n, spot.y + side.dy * n + if steps == n - 2 and world.map:isWalkable(tx, ty) + and not world:npcAt(tx, ty) then + steps, sx, sy = n - 1, tx, ty + end + end + spot.side, spot.steps, spot.sx, spot.sy = side, steps, sx, sy + world:setMap(spot.map, sx, sy, side.face) + U.wait(20) + world.noWildEncounters = true + if steps > 0 then walk(side.face, 20 * steps) end + U.wait(10) + + local npc = world:facingObject() + local key = npc and npc.def and npc.def.scriptKey + local script = key and world.vm.scripts and world.vm.scripts[key] + if not claim(type(script) == "table", + ("%s has a script to run at (%d,%d)"):format(spot.map, spot.x, spot.y)) then + return nil + end + + -- The give is behind a `checkevent` latch (EVENT_GOT_TM05_ROAR, + -- EVENT_GOT_OLD_ROD); those are the only flags either script reads. + local gives = nil + spot.events = {} + for _, row in ipairs(script) do + if type(row) == "table" then + if row.event then + world.events:set(row.event, false) + spot.events[#spot.events + 1] = row.event + end + if row.op == "verbosegiveitem" then gives = row.item end + end + end + claim(gives == spot.index, + ("its `verbosegiveitem` hands over item %s, wanted %s (%s)"):format( + tostring(gives), tostring(spot.index), spot.item)) + if gives ~= spot.index then return nil end + + game.save.inventory = game.save.inventory or {} + game.save.inventory[spot.item] = nil + local first = #calls + 1 + U.shot(game, shots[1]) + + -- Mash A the way a player does: through the intro line, the yes/no the + -- guru asks (YES is the resting cursor), the received line and the pocket + -- line. Nothing here waits for the jingle; that is the point. + local function rang() + for i = first, #calls do + if calls[i].name == spot.want and calls[i].special then return true end + end + return false + end + for _ = 1, 40 do + tap("a") + if rang() or idle() then break end + end + U.shot(game, shots[2]) -- the box the jingle is meant to be ringing under + local finished = mash(40) + claim(finished, ("the %s conversation ran to the end"):format(spot.item)) + U.wait(30) + + local got = (game.save.inventory[spot.item] or 0) > 0 + claim(got, ("the %s reached the pack"):format(spot.item)) + + local heard, want, blipBefore = {}, nil, false + for i = first, #calls do + local c = calls[i] + heard[#heard + 1] = ("%s@%d%s"):format(c.name, c.frame, + c.started and "" or "(dropped)") + if c.name == spot.want and c.special then want = want or c end + if c.name == "Sfx_ReadText2" and c.started and not want then + blipBefore = true + end + end + U.log(("sfx across the %s hand-over: %s"):format( + spot.item, table.concat(heard, " "))) + return { want = want, blipBefore = blipBefore, heard = heard } + end + + local tm = visit(TM_GIVER, + { out .. "/01-tm-giver.png", out .. "/02-tm-jingle.png" }) + if not tm then + U.log("could not reach the TM giver; stopping rather than faking it") + Sound.play = realPlay + Sound.waitSfxDone = realDrain + stop() + end + claim(tm.blipBefore, + "the box rang its own beep before the TM jingle was asked for") + claim(drains > 0, "specialsound drained the channels first, as `waitsfx` does") + claim(tm.want ~= nil, "Sfx_GetTm was asked for from specialsound at all") + claim(tm.want and tm.want.started, + "and the priority gate let it start instead of dropping it") + + local item = visit(ITEM_GIVER, + { out .. "/03-item-giver.png", out .. "/04-item-jingle.png" }) + if item then + claim(item.want ~= nil, "Sfx_Item was asked for from specialsound too") + claim(item.want and item.want.started, "and it started, as it always did") + end + + Sound.play = realPlay + Sound.waitSfxDone = realDrain + + for _, line in ipairs(lines) do U.log(line) end + U.log(("%d checks, %d failed"):format(#lines, fails)) + if fails > 0 then + U.log("something above is FAIL, so do not spend time listening") + end + + -- ---- the replay, live ------------------------------------------------------ + -- + -- EVENT_GOT_TM05_ROAR is the only latch the Roar guy reads, so clearing what + -- his own script checked puts the whole hand-over back. + clear() + for _, ev in ipairs(TM_GIVER.events or {}) do world.events:set(ev, false) end + game.save.inventory[TM_GIVER.item] = nil + world:setMap(TM_GIVER.map, TM_GIVER.sx, TM_GIVER.sy, TM_GIVER.side.face) + world.noWildEncounters = true + U.wait(30) + U.log("the Roar guy is a couple of steps away; the replay starts in three") + U.log("seconds and mashes A the whole way through.") + U.wait(180) + if TM_GIVER.steps > 0 then walk(TM_GIVER.side.face, 20 * TM_GIVER.steps) end + U.wait(10) + mash(40, 16) + U.wait(60) + + U.log("what right sounds like: the route music cuts out on the received line") + U.log("and the TM jingle rings under it, then the music comes back.") + U.log("dead silence with the music still playing is the drop this fixes.") + U.log("expect the jingle to be cut short by the beep on the next box -- that") + U.log("is the missing trailing WaitSFX, not this fix failing.") + U.log("the guru in the Route 32 centre is the control: same beep, but his") + U.log("OLD ROD jingle rang before the fix as well.") + U.log("the controls are yours.") + while true do coroutine.yield() end +end diff --git a/tests/drivers/gold_whirlpool_forceturn_bug1716_test.lua b/tests/drivers/gold_whirlpool_forceturn_bug1716_test.lua new file mode 100644 index 00000000..e46eae12 --- /dev/null +++ b/tests/drivers/gold_whirlpool_forceturn_bug1716_test.lua @@ -0,0 +1,213 @@ +-- #1716: a whirlpool tile force-turns the player instead of being swum through. +-- +-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/gold_whirlpool_forceturn_bug1716_test.lua \ +-- POKEPORT_SHOT_DIR=/tmp/gold-whirlpool \ +-- perl -e 'alarm 240; exec @ARGV' \ +-- python3 -c "import pty; pty.spawn(['love','.'])" +-- +-- DoPlayerMovement's .CheckTile runs CheckWhirlpoolTile above the nybble ladder +-- and answers PLAYERMOVEMENT_FORCE_TURN (engine/overworld/player_movement.asm +-- :117-123), which is Script_ForcedMovement: step_dig 16, turn_in , +-- step_dig 16, turn_head , step_end (engine/events/forced_movement.asm +-- :25-51). turn_in `jp TurningStep`, i.e. InitStep under OBJECT_ACTION_SPIN -- +-- a spinning one-cell step, not a facing change. +-- +-- No POKEPORT_SPEED here on purpose: the spin, the step and the settle are what +-- is being watched, and fast-forward scales only the logic clock. +local U = require("tests.drivers.util") + +local FieldMoves = require("src.world.gen2.FieldMoves") +local Movement = require("src.script.gen2.Movement") +local Mon = require("src.battle.gen2.Mon") +local Permissions = require("src.world.gen2.Permissions") + +-- data/generated/maps.lua, ROUTE_41: four whirlpools, the first at (22,12) with +-- plain water above and below it. Verified live below, and re-derived by +-- scanning the map if a re-import ever moves it. +local MAP = "ROUTE_41" +local WHIRL = { x = 22, y = 12 } + +local DELTA = { + up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 }, +} + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-whirlpool" + local fails, lines = 0, {} + + local function claim(ok, text) + if not ok then fails = fails + 1 end + lines[#lines + 1] = (ok and "PASS " or "FAIL ") .. text + return ok + end + + U.wait(45) + local world = game.world + if not (world and world.map) then + U.log("FAIL the gold world never booted, nothing to look at") + while true do coroutine.yield() end + end + + -- ---- the things a human's eyes cannot check ------------------------------ + -- + -- All of these fail silently and look exactly like "the fix did nothing". + claim(Movement.decodeByte(0x24).kind == "step", + "$24 turn_in decodes as a step, not a facing change") + claim(Movement.decodeByte(0x24).spin == true, + "and it carries OBJECT_ACTION_SPIN") + claim(Movement.STEP_DIG == 0x4f and Movement.STEP_DIG_FRAMES == 16, + "$4f step_dig is modelled, 16 frames") + claim(type(world.runForcedMovement) == "function", + "World:runForcedMovement exists for .CheckTile to call") + claim(type(world.player.scriptSpin) == "function", + "Player:scriptSpin exists for step_dig to drive") + + -- SURF but deliberately NOT WHIRLPOOL, and no GLACIERBADGE: TryWhirlpoolOW + -- must never get a look in, or what we would be watching is the prompt. + local badges = game.save.player.badges or {} + game.save.player.badges = badges + for _, badge in pairs(FieldMoves.BADGE) do badges[badge] = nil end + badges[FieldMoves.BADGE.SURF] = true + local swimmer = Mon.new(game.data, "LAPRAS", 30, + { moves = { { id = "SURF" } } }) + claim(swimmer ~= nil, "a LAPRAS that knows SURF and nothing else") + game.save.party = { swimmer } + claim(not FieldMoves.hasBadge(game.save, FieldMoves.BADGE.WHIRLPOOL), + "no GLACIERBADGE, so no whirlpool prompt can fire") + + -- ---- find the whirlpool -------------------------------------------------- + world:applyPlayerState(FieldMoves.PLAYER_SURF) + world:setMap(MAP, WHIRL.x, WHIRL.y - 1, "down") + U.wait(10) + world.noWildEncounters = true + local map = world.map + + local function isWhirl(x, y) + return map:inBounds(x, y) and Permissions.isWhirlpool(map:cellCollision(x, y)) + end + local function isOpenWater(x, y) + return map:inBounds(x, y) + and Permissions.isWater(map:cellCollision(x, y)) + and not Permissions.isWhirlpool(map:cellCollision(x, y)) + end + + local target, approach = nil, nil + if isWhirl(WHIRL.x, WHIRL.y) then target = { x = WHIRL.x, y = WHIRL.y } end + if not target then + -- A re-import moved it; take any whirlpool on the map instead of parking + -- the player on open sea with nothing to look at. + local def = world.maps[MAP] + for y = 0, (def.height or 0) * 2 - 1 do + for x = 0, (def.width or 0) * 2 - 1 do + if not target and isWhirl(x, y) then target = { x = x, y = y } end + end + end + end + if target then + for _, dir in ipairs({ "down", "up", "right", "left" }) do + local d = DELTA[dir] + if not approach and isOpenWater(target.x - d[1], target.y - d[2]) then + approach = { x = target.x - d[1], y = target.y - d[2], dir = dir } + end + end + end + claim(target ~= nil, ("%s still has a whirlpool tile on it"):format(MAP)) + claim(approach ~= nil, "with open water beside it to swim in from") + if not (target and approach) then + for _, line in ipairs(lines) do U.log(line) end + U.log("nothing to drive; stopping here rather than faking the moment") + while true do coroutine.yield() end + end + if target.x ~= WHIRL.x or target.y ~= WHIRL.y then + U.log(("note: using the whirlpool at (%d,%d), not the (%d,%d) in the header") + :format(target.x, target.y, WHIRL.x, WHIRL.y)) + end + + -- ---- swim into it -------------------------------------------------------- + world:setMap(MAP, approach.x, approach.y, approach.dir) + world:applyPlayerState(FieldMoves.PLAYER_SURF) + world.noWildEncounters = true + U.wait(20) + claim(FieldMoves.isSurfing(world.playerState), "on the water, on the Lapras") + U.shot(game, out .. "/01-before.png") + + local p = world.player + local d = DELTA[approach.dir] + local beyond = { x = target.x + d[1], y = target.y + d[2] } + local seen = { onWhirl = false, spun = false, busy = false, past = false } + local shot = {} + + local function press(dir) + table.insert(game.input.pressQueue, dir) + game.input.state[dir] = true + coroutine.yield() + end + + for _ = 1, 300 do + press(approach.dir) + if p.cellX == target.x and p.cellY == target.y then + seen.onWhirl = true + end + if p.spinFrames then seen.spun = true end + if world:busy() then seen.busy = true end + if p.cellX == beyond.x and p.cellY == beyond.y then seen.past = true end + if not shot.spin and seen.onWhirl and p.spinFrames then + shot.spin = true + game.input.state[approach.dir] = false + U.shot(game, out .. "/02-whirling.png") + end + if shot.spin and not shot.back + and (p.cellX ~= target.x or p.cellY ~= target.y) then + shot.back = true + game.input.state[approach.dir] = false + U.shot(game, out .. "/03-thrown-back.png") + end + end + game.input.state[approach.dir] = false + -- Let whatever is still running settle before reading the resting facing. + for _ = 1, 180 do + if not world:busy() and not p.moving then break end + coroutine.yield() + end + U.shot(game, out .. "/04-settled.png") + + local BACK = { up = "down", down = "up", left = "right", right = "left" } + claim(seen.onWhirl, + ("the step onto the whirlpool at (%d,%d) happened -- the cart allows it") + :format(target.x, target.y)) + claim(seen.busy, "the forced movement took the world off the d-pad") + claim(seen.spun, "step_dig put the player under OBJECT_ACTION_SPIN") + claim(not seen.past, + ("300 frames of %s never reached (%d,%d) on the far side") + :format(approach.dir:upper(), beyond.x, beyond.y)) + claim(p.cellX == approach.x and p.cellY == approach.y, + ("the player was dragged back to (%d,%d), and is at (%d,%d)") + :format(approach.x, approach.y, p.cellX, p.cellY)) + claim(p.facing == BACK[approach.dir], + ("turn_head settled the facing to %s, and it is %s") + :format(BACK[approach.dir], tostring(p.facing))) + + for _, line in ipairs(lines) do U.log(line) end + U.log(("%d checks, %d failed"):format(#lines, fails)) + if fails > 0 then + U.log("something above is FAIL, so do not spend time watching the replay") + end + + -- ---- the replay, live ---------------------------------------------------- + U.log("shots are in " .. out .. "; 02 is the whirl, 03 the drag back out.") + U.log("the replay starts in three seconds and runs on its own.") + world:setMap(MAP, approach.x, approach.y, approach.dir) + world:applyPlayerState(FieldMoves.PLAYER_SURF) + world.noWildEncounters = true + U.wait(180) + for _ = 1, 200 do press(approach.dir) end + game.input.state[approach.dir] = false + + U.log("the Lapras swims one cell into the whirlpool, whirls on the spot,") + U.log("is dragged back out still whirling, and stops facing away from it.") + U.log("a bounce with no whirl means scriptSpin never fired; a whirl that") + U.log("never leaves the cell means $24 is still decoding as a turn.") + U.log("the controls are yours -- the d-pad cannot cross that cell.") + while true do coroutine.yield() end +end diff --git a/tests/drivers/gold_whirlpool_sound_bug1717_test.lua b/tests/drivers/gold_whirlpool_sound_bug1717_test.lua new file mode 100644 index 00000000..a6f78b50 --- /dev/null +++ b/tests/drivers/gold_whirlpool_sound_bug1717_test.lua @@ -0,0 +1,240 @@ +-- #1717: the surf wash PlayWhirlpoolSound rings as a whirlpool drains. +-- +-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/gold_whirlpool_sound_bug1717_test.lua \ +-- POKEPORT_SHOT_DIR=/tmp/gold-whirlpool-sfx \ +-- perl -e 'alarm 240; exec @ARGV' \ +-- python3 -c "import pty; pty.spawn(['love','.'])" +-- +-- PlayWhirlpoolSound is WaitSFX / PlaySFX SFX_SURF / WaitSFX +-- (engine/events/field_moves.asm:5-10), never a bare PlaySFX. The LEADING wait +-- is what makes it audible: SFX_SURF is $53 and PlaySFX drops any id above the +-- sound still on ch5-ch8 (home/audio.asm), so the beep that dismissed the text +-- box swallowed it. The TRAILING wait is why the water is still washing when +-- DisappearWhirlpool hands back, above `closetext`. +-- +-- No POKEPORT_SPEED here on purpose: audio runs on its own real-time +-- accumulator, so fast-forward slides the sound off the moment it belongs to, +-- and the ordering is the whole thing being judged. +local U = require("tests.drivers.util") + +local FieldMoves = require("src.world.gen2.FieldMoves") +local Mon = require("src.battle.gen2.Mon") +local Permissions = require("src.world.gen2.Permissions") +local Sound = require("src.core.Sound") + +-- data/generated/maps.lua, ROUTE_41: whirlpools at (22,12), (42,24), (6,30) and +-- (28,48). Stand one cell north of the first and face it. +local MAP = "ROUTE_41" +local WHIRL = { x = 22, y = 12 } + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-whirlpool-sfx" + local fails, lines = 0, {} + + local function claim(ok, text) + if not ok then fails = fails + 1 end + lines[#lines + 1] = (ok and "PASS " or "FAIL ") .. text + return ok + end + + U.wait(45) + local world = game.world + if not (world and world.map) then + U.log("FAIL the gold world never booted, nothing to listen to") + while true do coroutine.yield() end + end + + -- ---- everything that fails as silence ------------------------------------ + local audio = game.data.audio or {} + local hasLabel = false + for _, name in ipairs(audio.sfxOrder or {}) do + if name == "Sfx_Surf" then hasLabel = true end + end + claim(hasLabel, "the cache knows the sfx label Sfx_Surf") + claim(audio.sfx and audio.sfx.Sfx_Surf ~= nil, + "and it has a loadable definition for it") + claim(type(world.playWhirlpoolSound) == "function", + "World:playWhirlpoolSound exists for DisappearWhirlpool to call") + local vol = game.save.options and game.save.options.sfxVol + claim(vol ~= 0, ("SFX VOL is %s"):format(tostring(vol))) + if vol == 0 then + U.log("SFX VOL is ZERO -- a muted run sounds exactly like the bug.") + U.log("turn it up in OPTION before trusting anything you hear here.") + end + + -- SURF to get there and WHIRLPOOL to use, with the badges TryWhirlpoolOW + -- gates on: the gate has its own checks in tests/gen2_world_test.lua, and + -- what is under test here is the sound, not the refusal. + local badges = game.save.player.badges or {} + game.save.player.badges = badges + for _, badge in pairs(FieldMoves.BADGE) do badges[badge] = true end + local swimmer = Mon.new(game.data, "LAPRAS", 30, + { moves = { { id = "SURF" }, { id = "WHIRLPOOL" } } }) + claim(swimmer ~= nil, "a LAPRAS that knows SURF and WHIRLPOOL") + game.save.party = { swimmer } + + -- ---- stand on the water facing the whirlpool ----------------------------- + world:applyPlayerState(FieldMoves.PLAYER_SURF) + world:setMap(MAP, WHIRL.x, WHIRL.y - 1, "down") + U.wait(15) + world.noWildEncounters = true + + local ctx = world:fieldContext() + local facingWhirl = Permissions.isWhirlpool(ctx.facingColl) + if not facingWhirl then + -- A re-import moved it: take any whirlpool with open water above it rather + -- than ringing a sound at a patch of empty sea. + local def = world.maps[MAP] + for y = 1, (def.height or 0) * 2 - 1 do + for x = 0, (def.width or 0) * 2 - 1 do + if not facingWhirl + and Permissions.isWhirlpool(world.map:cellCollision(x, y)) + and Permissions.isWater(world.map:cellCollision(x, y - 1)) then + WHIRL.x, WHIRL.y = x, y + world:setMap(MAP, x, y - 1, "down") + world:applyPlayerState(FieldMoves.PLAYER_SURF) + U.wait(10) + ctx = world:fieldContext() + facingWhirl = Permissions.isWhirlpool(ctx.facingColl) + U.log(("note: using the whirlpool at (%d,%d)"):format(x, y)) + end + end + end + end + claim(facingWhirl, + ("facing the whirlpool at (%d,%d) on %s"):format(WHIRL.x, WHIRL.y, MAP)) + local blocks = world.maps[MAP] and world.maps[MAP].blocks + local index = ctx.facingBlockIndex + local before = blocks and index and blocks[index] + claim(before ~= nil, "and the block behind it is readable") + if not (facingWhirl and before) then + for _, line in ipairs(lines) do U.log(line) end + U.log("nothing to drive; stopping here rather than faking the moment") + while true do coroutine.yield() end + end + U.shot(game, out .. "/01-facing.png") + + -- ---- record every sfx the drain rings ------------------------------------ + -- + -- Sound.play returns nil when the priority gate DROPS the request, which is + -- the bug itself: the sound neither sounds nor ducks the music. + local calls, frame = {}, 0 + local realPlay = Sound.play + Sound.play = function(data, name) + local src = realPlay(data, name) + calls[#calls + 1] = { + name = name, frame = frame, started = src ~= nil, + phase = world.fieldMove and world.fieldMove.phase, + } + return src + end + + local function tap(button, gap) + table.insert(game.input.pressQueue, button) + game.input.state[button] = true + frame = frame + 1 + coroutine.yield() + game.input.state[button] = false + for _ = 1, (gap or 8) do frame = frame + 1 coroutine.yield() end + end + + -- Press A into the whirlpool, then answer the ask box. YES is the default + -- cursor, so A all the way down is what a player does. + local drainedAt, closedAt = nil, nil + for _ = 1, 24 do + if world.fieldMove and world.fieldMove.phase == "whirlpoolsfx" then break end + if blocks[index] ~= before and not closedAt then closedAt = frame end + tap("a") + end + if blocks[index] ~= before and not closedAt then closedAt = frame end + drainedAt = frame + + -- From here on nothing is pressed: the trailing WaitSFX is supposed to hold + -- the world by itself. + local busyAfter, phaseFrames = 0, 0 + for _ = 1, 300 do + if world.fieldMove and world.fieldMove.phase == "whirlpoolsfx" then + phaseFrames = phaseFrames + 1 + end + if world:busy() then busyAfter = busyAfter + 1 else break end + frame = frame + 1 + coroutine.yield() + end + Sound.play = realPlay + U.shot(game, out .. "/02-cleared.png") + + -- ---- what was rung ------------------------------------------------------- + local surf, surfStarted, surfInPhase = nil, false, false + local heard = {} + for _, c in ipairs(calls) do + heard[#heard + 1] = ("%s@%d%s"):format(c.name, c.frame, + c.started and "" or "(dropped)") + if c.name == "Sfx_Surf" then + surf = c + surfStarted = surfStarted or c.started + surfInPhase = surfInPhase or c.phase == "whirlpoolsfx" + end + end + + claim(blocks[index] ~= before, + "the whirlpool block was replaced, so the drain really ran") + claim(surf ~= nil, "Sfx_Surf was asked for at all") + claim(surfStarted, + "and the priority gate let it start instead of dropping it") + claim(surfInPhase, + "it was rung from PlayWhirlpoolSound, not from somewhere else") + claim(closedAt == nil or (surf and surf.frame >= closedAt), + "it came after the text box took its button, not before") + claim(busyAfter >= 1, + ("the world stayed shut for %d frames after the box closed"):format( + busyAfter)) + -- The phase counts down from 180 whatever happens, so 180 flat is the cap + -- firing rather than the sound ending. + claim(phaseFrames > 0 and phaseFrames < 180, + ("the wash ended on its own after %d frames, inside the 180-frame cap") + :format(phaseFrames)) + if phaseFrames >= 180 then + U.log("the 180-frame cap is doing the work: Sound.sfxBusy never cleared,") + U.log("so check curSfx bookkeeping rather than raising the cap.") + elseif phaseFrames > 165 then + U.log(("note: only %d frames of margin under the cap"):format( + 180 - phaseFrames)) + end + + for _, line in ipairs(lines) do U.log(line) end + U.log("sfx rung across the drain: " .. table.concat(heard, " ")) + U.log(("%d checks, %d failed"):format(#lines, fails)) + if fails > 0 then + U.log("something above is FAIL, so do not spend time listening") + end + + -- ---- the replay, live ---------------------------------------------------- + -- + -- LoadMapAttributes refills the block buffer from ROM, so the whirlpool is + -- back on the next map load and the whole thing can be run again by ear. + world:setMap("NEW_BARK_TOWN", 13, 6, "down") + U.wait(10) + world:applyPlayerState(FieldMoves.PLAYER_SURF) + world:setMap(MAP, WHIRL.x, WHIRL.y - 1, "down") + world.noWildEncounters = true + U.wait(30) + U.log("the whirlpool is back; the replay starts in three seconds.") + U.wait(180) + for _ = 1, 24 do + if world.fieldMove and world.fieldMove.phase == "whirlpoolsfx" then break end + tap("a", 14) + end + for _ = 1, 240 do + if not world:busy() then break end + coroutine.yield() + end + + U.log("what right sounds like: the box takes its A-press beep, then the same") + U.log("wave wash SURF plays when you get on the water, and control only comes") + U.log("back once the wash has finished.") + U.log("silence with the block gone is the drop; a wash that starts under the") + U.log("beep, or control back in the same frame, is the wrong half fixed.") + U.log("the controls are yours -- the other whirlpools are further south.") + while true do coroutine.yield() end +end diff --git a/tests/drivers/museum_back_entrance_bug1690_test.lua b/tests/drivers/museum_back_entrance_bug1690_test.lua new file mode 100644 index 00000000..c22c1ede --- /dev/null +++ b/tests/drivers/museum_back_entrance_bug1690_test.lua @@ -0,0 +1,211 @@ +-- Manual check of the Pewter museum clerk's back-entrance branches (#1690). +-- scripts/Museum1F.asm:45 reads wYCoord/wXCoord before the ticket flag, so +-- (13,4) and (12,3) behind the counter get the AMBER question and row 4 in +-- front of it gets the ¥50 ask. This walks all three by itself. +-- Do not add POKEPORT_SPEED: fast-forward scales only the logic clock, and +-- what is being judged here is page order and the choice box timing. +-- POKEPORT_DRIVER=tests/drivers/museum_back_entrance_bug1690_test.lua POKEPORT_TOUCH=0 POKEPORT_VERSION=red POKEPORT_IDENTITY=bug1690 SHOT_DIR=/tmp/museum1690 love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local TextBox = require("src.render.TextBox") + local ChoiceBox = require("src.ui.ChoiceBox") + local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + local MAP = "MUSEUM_1F" + local CLERK_TEXT = "TEXT_MUSEUM1F_SCIENTIST1" + -- pokered data/maps/objects/Museum1F.asm: MUSEUM1F_SCIENTIST1 at (12,4), + -- counter column x==11, back door warps in at (16,7)/(17,7) + local BEHIND_EAST = { x = 13, y = 4, facing = "left" } + local BEHIND_NORTH = { x = 12, y = 3, facing = "down" } + local IN_FRONT = { x = 10, y = 4, facing = "right" } + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + -- every one of these failing looks exactly like the bug on screen: a + -- missing string, a renamed key or an unwalkable cell all end in silence + local t = game.data.text or {} + for _, key in ipairs({ + "_Museum1FScientist1DoYouKnowWhatAmberIsText", + "_Museum1FScientist1TheresALabSomewhereText", + "_Museum1FScientist1AmberIsFossilizedTreeSapText", + "_Museum1FScientist1GoToOtherSideText", + "_Museum1FScientist1WouldYouLikeToComeInText", + }) do + check(key .. " is in the text catalog", + type(t[key]) == "string" and t[key] ~= "") + end + + local function clerkIn(ow) + for _, n in ipairs(ow and ow.npcs or {}) do + if n.def and n.def.text == CLERK_TEXT then return n end + end + return nil + end + + local function boxText(box) + local shown = {} + for _, page in ipairs(box.pages or {}) do + for _, line in ipairs(page) do shown[#shown + 1] = line end + end + return table.concat(shown, " / ") + end + + -- talking works either straight at the clerk or across the counter tile + local function facingTheClerk() + local ow = game.overworld + local clerk = clerkIn(ow) + if not clerk then return false end + local fx, fy = ow.player:facingCell() + if ow:npcAtCell(fx, fy) == clerk then return true end + if ow.map:isCounterCell(fx, fy) then + local Collision = require("src.world.Collision") + local bx, by = Collision.target(fx, fy, ow.player.facing) + return ow:npcAtCell(bx, by) == clerk + end + return false + end + + -- teleport to `spot`, and if a map edit moved the counter fall back to any + -- free walkable neighbour of the clerk so this degrades instead of parking + -- the player at a wall + local function standAt(spot, label) + U.teleport(game, MAP, spot.x, spot.y, spot.facing) + U.wait(10) + if facingTheClerk() then return check(label .. ": in position", true) end + local ow = game.overworld + local clerk = clerkIn(ow) + if clerk then + local sides = { + { 0, 1, "up" }, { 0, -1, "down" }, { 1, 0, "left" }, { -1, 0, "right" }, + } + for _, s in ipairs(sides) do + local cx, cy = clerk.cellX + s[1], clerk.cellY + s[2] + if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then + U.log(("(%d,%d) does not reach the clerk, standing on") + :format(spot.x, spot.y), cx, cy, "facing", s[3]) + U.teleport(game, MAP, cx, cy, s[3]) + U.wait(10) + break + end + end + end + return check(label .. ": in position", facingTheClerk()) + end + + -- press A and wait for the dialogue box to actually mount + local function talk() + U.tap(game, "a") + for _ = 1, 60 do + local top = game.stack:top() + if getmetatable(top) == TextBox then return top end + U.wait(1) + end + return nil + end + + -- page through until the YES/NO box pops (the clerk's question is two + -- pages, so one A press sits between the box opening and the choice) + local function toChoice() + for _ = 1, 20 do + if getmetatable(game.stack:top()) == ChoiceBox then + return game.stack:top() + end + U.tap(game, "a") + U.wait(20) + end + return getmetatable(game.stack:top()) == ChoiceBox and game.stack:top() or nil + end + + -- ChoiceBox starts on YES; NO is one step down + local function answer(yes) + if not yes then U.tap(game, "down") U.wait(10) end + U.tap(game, "a") + for _ = 1, 60 do + local top = game.stack:top() + if getmetatable(top) == TextBox then return top end + U.wait(1) + end + return nil + end + + game.save.flags = game.save.flags or {} + game.save.flags.EVENT_BOUGHT_MUSEUM_TICKET = nil + standAt(BEHIND_EAST, "east of the clerk (13,4)") + + local box = talk() + if check("A behind the counter opens a box", box ~= nil) then + U.log("box reads:", boxText(box)) + check("it is the can't-sneak-in-the-back-way line", + boxText(box):find("sneak", 1, true) ~= nil) + check("no money window rides along with it", box.money == nil) + U.shot(game, SHOT_DIR .. "/bug1690_a_sneak.png") + end + + local choice = toChoice() + if check("the AMBER question offers YES/NO", choice ~= nil) then + U.shot(game, SHOT_DIR .. "/bug1690_b_amber_choice.png") + local answered = answer(true) + if check("YES opens a follow-up box", answered ~= nil) then + U.log("YES reads:", boxText(answered)) + check("YES is the resurrection lab line", + boxText(answered):find("lab", 1, true) ~= nil) + U.shot(game, SHOT_DIR .. "/bug1690_c_lab.png") + end + end + + -- with the ticket already bought the coordinate check still wins: a + -- ticket holder round the back is told off, not thanked (asm:45 runs + -- before the CheckEvent at asm:59) + game.save.flags.EVENT_BOUGHT_MUSEUM_TICKET = true + standAt(BEHIND_NORTH, "north of the clerk (12,3), ticket in hand") + box = talk() + if check("A north of the clerk opens a box", box ~= nil) then + U.log("box reads:", boxText(box)) + check("a ticket holder round the back still gets told off", + boxText(box):find("sneak", 1, true) ~= nil) + U.shot(game, SHOT_DIR .. "/bug1690_d_ticket_holder_sneak.png") + end + choice = toChoice() + if check("it still offers YES/NO", choice ~= nil) then + local answered = answer(false) + if check("NO opens a follow-up box", answered ~= nil) then + U.log("NO reads:", boxText(answered)) + check("NO is the fossilized tree sap line", + boxText(answered):find("tree sap", 1, true) ~= nil) + U.shot(game, SHOT_DIR .. "/bug1690_e_tree_sap.png") + end + end + + -- the control case: the public side of the counter is untouched + game.save.flags.EVENT_BOUGHT_MUSEUM_TICKET = nil + game.save.money = 3000 + standAt(IN_FRONT, "in front of the counter (10,4)") + box = talk() + if check("A across the counter opens a box", box ~= nil) then + U.log("box reads:", boxText(box)) + check("the front of the counter is still the ¥50 ticket ask", + boxText(box):find("50", 1, true) ~= nil) + check("and it still raises the money window", box.money ~= nil) + U.shot(game, SHOT_DIR .. "/bug1690_f_ticket_ask.png") + end + + U.log("shots are in " .. SHOT_DIR .. ", in the order they were taken.") + U.log("behind the counter the clerk should say \"You can't sneak in the back") + U.log("way!\", then \"Oh, whatever! Do you know what AMBER is?\" with YES/NO,") + U.log("and never a money window. YES talks about a lab resurrecting ancient") + U.log("POKeMON, NO says AMBER is fossilized tree sap. The box left on screen") + U.log("now is the last case: standing at (10,4) with no ticket, which must") + U.log("still be the ¥50 ask with the money window in the corner.") + U.log("the near misses to watch for: a money window sitting behind the AMBER") + U.log("question, or \"Take your time, and enjoy it all!\" at (12,3) once the") + U.log("ticket flag is set, which would mean the ticket check ran first.") + U.log("Yellow shares this script, so POKEPORT_VERSION=yellow should read") + U.log("exactly the same at all three spots.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/pet_cries_bug1687_1649_test.lua b/tests/drivers/pet_cries_bug1687_1649_test.lua new file mode 100644 index 00000000..bdcd9a8f --- /dev/null +++ b/tests/drivers/pet_cries_bug1687_1649_test.lua @@ -0,0 +1,303 @@ +-- Talks to all six pet Pokemon for you and lets each one cry: #1687 +-- (S.S. Anne WIGGLYTUFF + MACHOKE) and #1649 (Vermilion PIDGEY, Vermilion +-- City MACHOP, Fan Club PIKACHU + SEEL). pokered/scripts/*.asm, e.g. +-- VermilionPidgeyHouse.asm:15 text_far then PlayCry at :19. Never add +-- POKEPORT_SPEED: only the logic clock scales, so the cry lands in the +-- wrong place relative to the typing this run exists to judge. +-- POKEPORT_DRIVER=tests/drivers/pet_cries_bug1687_1649_test.lua POKEPORT_TOUCH=0 POKEPORT_VERSION=red POKEPORT_IDENTITY=petcries SHOT_DIR=/tmp/petcries love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local TextBox = require("src.render.TextBox") + local Runtime = require("src.mods.Runtime") + local GameVersion = require("src.core.GameVersion") + local init = require("data.scripts.init") + + local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local yellow = GameVersion.isYellow() + local failures = 0 + + local function check(label, ok) + if not ok then failures = failures + 1 end + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + -- own tick counter: the ordering claim is "cry after the last character", + -- so the cry and the end of typing have to be stamped on the same clock + local ticks = 0 + local function step() ticks = ticks + 1; coroutine.yield() end + local function waitTicks(n) for _ = 1, n do step() end end + local function tap(btn) U.tap(game, btn); ticks = ticks + 1 end + + -- sound.played is the engine's own cue feed (src/core/Sound.lua played()), + -- which is the only way a script can tell "no cry" from "a cry nobody + -- could hear"; both sound identical on a quiet machine. + local cries = {} + local heard = false + if type(Runtime.events.on) == "function" then + Runtime.events:on("sound.played", function(p) + if p and p.kind == "cry" then + heard = true + cries[#cries + 1] = { name = p.name, species = p.species, tick = ticks } + end + end, 0, "driver") + end + + -- pokered/data/maps/objects, all cell coordinates: VermilionPidgeyHouse + -- PIDGEY (3,5), VermilionCity MACHOP (29,9), PokemonFanClub PIKACHU (6,4) + -- and SEEL (1,4), SSAnne1FRooms WIGGLYTUFF (3,11), SSAnneB1FRooms MACHOKE + -- (11,12). The two S.S. Anne rooms maps slide their objects between + -- cabins at load (src/world/SsAnneLayout.lua), so every position below is + -- read back off the live map instead of trusted from the list. + local PETS = { + { map = "VERMILION_PIDGEY_HOUSE", object = "VERMILIONPIDGEYHOUSE_PIDGEY", + const = "TEXT_VERMILIONPIDGEYHOUSE_PIDGEY", species = "PIDGEY", + label = "_VermilionPidgeyHousePidgeyText", + control = "VERMILIONPIDGEYHOUSE_YOUNGSTER" }, + { map = "SS_ANNE_1F_ROOMS", object = "SSANNE1FROOMS_WIGGLYTUFF", + const = "TEXT_SSANNE1FROOMS_WIGGLYTUFF", species = "WIGGLYTUFF", + label = "_SSAnne1FRoomsWigglytuffText", + control = "SSANNE1FROOMS_LITTLE_GIRL" }, + { map = "SS_ANNE_B1F_ROOMS", object = "SSANNEB1FROOMS_MACHOKE", + const = "TEXT_SSANNEB1FROOMS_MACHOKE", species = "MACHOKE", + label = "_SSAnneB1FRoomsMachokeText" }, + { map = "POKEMON_FAN_CLUB", object = "POKEMONFANCLUB_SEEL", + const = "TEXT_POKEMONFANCLUB_SEEL", species = "SEEL", + label = "_PokemonFanClubSeelText" }, + -- two boxes, cry on the first (VermilionCity.asm:224, PlayCry at :228) + { map = "VERMILION_CITY", object = "VERMILIONCITY_MACHOP", + const = "TEXT_VERMILIONCITY_MACHOP", species = "MACHOP", + label = "_VermilionCityMachopText", extraBoxes = 1 }, + } + if yellow then + -- pokeyellow/data/maps/objects/PokemonFanClub.asm: the pet is a CLEFAIRY + PETS[#PETS + 1] = { map = "POKEMON_FAN_CLUB", + object = "POKEMONFANCLUB_CLEFAIRY", + const = "TEXT_POKEMONFANCLUB_CLEFAIRY", species = "CLEFAIRY", + label = "_PokemonFanClubClefairyText" } + else + PETS[#PETS + 1] = { map = "POKEMON_FAN_CLUB", + object = "POKEMONFANCLUB_PIKACHU", + const = "TEXT_POKEMONFANCLUB_PIKACHU", species = "PIKACHU", + label = "_PokemonFanClubPikachuText" } + end + + -- ---------------------------------------------------------------- checks + -- Everything below produces the same silence the bug did, so it is worth + -- knowing before anyone bothers listening. + local vol = game.save and game.save.options and game.save.options.sfxVol + U.log("sfx volume is", tostring(vol), "of 7") + if not vol or vol == 0 then + check("sfx volume is above zero (a muted run sounds exactly like the bug)", + false) + end + if yellow and game.save.options.pikaVol == 0 then + U.log("pikaVol is 0: Yellow's Pikachu clips are muted independently") + end + + for _, pet in ipairs(PETS) do + local tag = pet.species .. " on " .. pet.map + local script = init.talkScript(pet.map, pet.const) + if check(tag .. " has a ported talk script", type(script) == "table") then + local cryAt + for i, row in ipairs(script) do + if type(row) == "table" and row[1] == "play_cry" then cryAt = i end + end + if check(tag .. " has a play_cry row", cryAt ~= nil) then + check(tag .. " cries " .. pet.species, script[cryAt][2] == pet.species) + check(tag .. " uses the waitForButton form", script[cryAt][3] == true) + local next_ = script[cryAt + 1] + check(tag .. " arms " .. pet.label .. " on the very next row", + type(next_) == "table" and next_[1] == "show_text" + and next_[2] == pet.label) + end + end + check(tag .. " has a cry program in this cache", + game.data.audio.cries and game.data.audio.cries[pet.species] ~= nil) + check(pet.label .. " resolves to text", + type(game.data.text[pet.label]) == "string") + end + + -- Sound.playCry sends PIKACHU to the PCM clips whenever the cache carries + -- them; that voiced "Pikachuuu" is Yellow's, and hearing it in Red is the + -- failure #1649 warned about up front. + if not yellow then + check("this cache has no Pikachu voice clips (Red must get the chip cry)", + game.data.audio.pikaCries == nil) + end + + if failures > 0 then + U.log(failures, "checks failed above, so do not bother listening yet;") + U.log("a missing row or an unresolved label sounds exactly like the bug.") + end + + -- ---------------------------------------------------------------- walking + local function findNpc(ow, name) + for _, n in ipairs(ow.npcs or {}) do + if n.def and n.def.name == name then return n end + end + return nil + end + + -- {dx, dy, facing}: the offset from the pet to the cell to stand on plus + -- the direction that looks back at it, so +1 on y means stand below. + local SIDES = { + { 0, 1, "up" }, { 0, -1, "down" }, { 1, 0, "left" }, { -1, 0, "right" }, + } + + local function approach(ow, npc) + for _, s in ipairs(SIDES) do + local cx, cy = npc.cellX + s[1], npc.cellY + s[2] + if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then + return cx, cy, s[3] + end + end + return nil + end + + -- PIDGEY and MACHOP are WALK objects and drift off their spawn cell, so + -- pin them once the final teleport has rebuilt the npc list. + local function pin(npc) + npc.wanders = false + npc:resetToSpawn() + npc.wanders = false + end + + local function standAt(mapId, objName) + local def + for _, o in ipairs((game.data.maps[mapId] or {}).objects or {}) do + if o.name == objName then def = o end + end + if not def then return nil end + -- first hop just mounts the map so the live cells can be read + U.teleport(game, mapId, def.x, math.max(0, def.y + 1), "up") + waitTicks(6) + local ow = game.overworld + local npc = ow and findNpc(ow, objName) + if not npc then return nil end + pin(npc) + local cx, cy, facing = approach(ow, npc) + if not cx then return nil end + U.teleport(game, mapId, cx, cy, facing) + waitTicks(6) + ow = game.overworld + npc = findNpc(ow, objName) + if not npc then return nil end + pin(npc) + local fx, fy = ow.player:facingCell() + if ow:npcAtCell(fx, fy) ~= npc then return nil end + return npc + end + + -- Opens the box, holds still while it types and cries, then reports where + -- the cry landed against the last typed character. + local function talk(name) + local before = #cries + tap("a") + local box, armed, doneTick + for _ = 1, 300 do + local top = game.stack:top() + if getmetatable(top) == TextBox then + box = top + -- box.auto is cleared the moment the cry finishes, so snapshot it + if armed == nil then armed = top.auto or false end + if top.done and not doneTick then doneTick = ticks end + end + if doneTick and #cries > before then break end + step() + end + if not check(name .. ": pressing A opened a text box", box ~= nil) then + return nil + end + local lines = {} + for _, page in ipairs(box.pages or {}) do + for _, line in ipairs(page) do lines[#lines + 1] = line end + end + U.log("box reads:", table.concat(lines, " / ")) + return box, armed, doneTick, before + end + + U.log("about to walk to each pet and talk to it; turn the volume up now.") + waitTicks(180) + + for _, pet in ipairs(PETS) do + local tag = pet.species .. " on " .. pet.map + U.log("walking to the", pet.species) + local npc = standAt(pet.map, pet.object) + if not check(tag .. ": standing face to face with it", npc ~= nil) then + U.log("skipping", pet.species, "-- could not reach it") + else + local box, armed, doneTick, before = talk(tag) + if box then + -- the dropped `true` shows up here: without it the box pops itself + -- when the cry ends and never blinks its arrow + check(tag .. ": the box was armed with a cry", + type(armed) == "table" and type(armed.sound) == "function") + check(tag .. ": the cry keeps the A/B wait (waitForButton)", + type(armed) == "table" and armed.wait == true) + local fired = cries[before + 1] + if check(tag .. ": a cry actually played", fired ~= nil) then + check(tag .. ": the cry was " .. pet.species, + fired.species == pet.species) + -- near miss: a cry that fires as the line starts typing + check(tag .. ": it played after the last character, not before", + doneTick ~= nil and fired.tick >= doneTick) + if pet.species == "PIKACHU" and not yellow then + check("PIKACHU used the chip cry, not Yellow's voice clip", + fired.name == "PIKACHU") + end + check(tag .. ": exactly one cry for this box", + #cries == before + 1) + end + waitTicks(90) + U.shot(game, SHOT_DIR .. "/pet_cry_" .. pet.species:lower() .. ".png") + check(tag .. ": the box is still up waiting for A", + getmetatable(game.stack:top()) == TextBox) + for _ = 1, (pet.extraBoxes or 0) + 1 do + tap("a") + waitTicks(40) + end + if pet.extraBoxes then + U.log("the MACHOP's second box is the stomping line; it has no cry") + end + waitTicks(30) + end + end + + -- a neighbour that is meant to stay silent, so a run with no audio + -- device at all cannot pass itself off as a working cry + if pet.control then + local silent = standAt(pet.map, pet.control) + if silent then + local mark = #cries + talk(pet.control .. " (control)") + check(pet.control .. ": the silent neighbour played no cry", + #cries == mark) + tap("a") + waitTicks(30) + tap("a") + waitTicks(20) + end + end + end + + -- ---------------------------------------------------------------- verdict + U.log("talked to all", #PETS, "pets;", #cries, "cries fired.") + if not heard then + U.log("no cry reached the mixer at all, which is the #1687 / #1649 bug") + end + U.log(failures == 0 and "all checks passed" or (failures .. " checks FAILED")) + U.log("what you should have heard, once per pet: the box types the line,") + U.log("then a short cry, and only then the down arrow starts blinking.") + U.log("a cry that starts while the line is still typing is wrong, and so") + U.log("is a box that closes itself when the cry ends instead of waiting.") + U.log("in Red the Fan Club PIKACHU is a short chip cry; the long voiced") + U.log("\"Pikachuuu\" belongs to Yellow, where the pet is a CLEFAIRY.") + U.log("re-run with POKEPORT_VERSION=blue and POKEPORT_VERSION=yellow.") + U.log("screenshots are in", SHOT_DIR) + + -- parked in front of the last pet, so another A press repeats its cry + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/ss_anne_departure_bug360_test.lua b/tests/drivers/ss_anne_departure_bug360_test.lua index efa4aefe..11a1b49d 100644 --- a/tests/drivers/ss_anne_departure_bug360_test.lua +++ b/tests/drivers/ss_anne_departure_bug360_test.lua @@ -90,11 +90,11 @@ return function(game) if r[2] == "SS_Anne_Horn" then horns = horns + 1 end end check("the departure blows the horn twice", horns == 2) - local slide = 0 - for _, w in ipairs(rowsOfKind(dockRows, "wait")) do - if w[2] == 20 then slide = slide + 1 end - end - check("she sails in eight beats, not one frame", slide == 8) + check("she sails as one blocking slide, not a block shuffle", + #rowsOfKind(dockRows, "ss_anne_departs") == 1 + and #rowsOfKind(dockRows, "replace_block") == 0) + check("he keeps facing down until he walks out", + (rowsOfKind(dockRows, "face_player_dir")[1] or {})[2] == "down") check("no keepMusic override into the city", (function() for _, r in ipairs(rowsOfKind(dockRows, "play_music")) do if r[3] and r[3].keep then return false end @@ -130,10 +130,12 @@ return function(game) and game.save.flags.EVENT_SS_ANNE_LEFT == nil and game.save.flags.EVENT_BEAT_SS_ANNE_RIVAL == nil) - U.log("Watch for: she idles a couple of seconds with smoke off the funnel,") - U.log("one horn, then travels WEST a block at a time with the water closing") - U.log("in behind her, a second horn once she is gone, and the surf loop") - U.log("gives way to the Vermilion theme the moment you cross into town.") + U.log("Watch for: he turns to face DOWN and stays that way, she idles two") + U.log("seconds, one horn, then she slides WEST smoothly for about seventeen") + U.log("seconds with white smoke puffing off the front funnel and drifting") + U.log("east, the dock row he stands on never moving; a second horn once she") + U.log("is gone, the gangway stub still under his feet, then he walks up and") + U.log("the surf loop gives way to the Vermilion theme as you cross in.") U.teleport(game, DOCK, DOCK_CELL.x, DOCK_CELL.y, "up") local ow = game.overworld diff --git a/tests/drivers/wrap_anim_bug1653_test.lua b/tests/drivers/wrap_anim_bug1653_test.lua new file mode 100644 index 00000000..032025d8 --- /dev/null +++ b/tests/drivers/wrap_anim_bug1653_test.lua @@ -0,0 +1,404 @@ +-- Driver: Wrap's animation length and the blank tile-load beat that opens every +-- subanimation row (#1653). PlayAnimation calls LoadMoveAnimationTiles once per +-- row (engine/battle/animations.asm:252) and CopyVideoData blocks c/8 + 1 frames +-- (home/copy2.asm:62), so WrapAnim's three rows (data/moves/animations.asm:401) +-- cost 3 x 10 blank frames on top of the 30 the port already played. +-- No POKEPORT_SPEED: it scales only the logic clock while audio runs real-time, +-- which desyncs the sfx-against-pulse ordering this driver exists to judge. +-- POKEPORT_DRIVER=tests/drivers/wrap_anim_bug1653_test.lua POKEPORT_VERSION=red POKEPORT_TOUCH=0 POKEPORT_IDENTITY=bug1653 SHOT_DIR=/tmp/wrap love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local Pokemon = require("src.pokemon.Pokemon") + local BattleState = require("src.battle.BattleState") + local AnimPlayer = require("src.battle.AnimPlayer") + + local pass, fail = 0, 0 + local function check(label, ok) + if ok then pass = pass + 1 else fail = fail + 1 end + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + -- ---- what the eye and ear cannot check --------------------------------- + U.log("#1653 Wrap animation length: machine checks") + + local ba = game.data.battle_anims + check("battle_anims is in the cache", ba ~= nil) + local anims = (ba or {}).moveAnims or {} + check("WRAP is in the move table", game.data.moves.WRAP ~= nil) + check("WRAP has an animation", anims.WRAP ~= nil) + + -- anim_tileset 79 / 79 / 64 (engine/battle/animations.asm:383): 79 tiles is + -- nine 8-tile chunks plus the tail frame, so tileset 0 blocks 10 frames + local sheet0 = (ba or {}).tilesheets and ba.tilesheets[0] + check(("tileset 0 declares %s tiles (anim_tileset 79)") + :format(tostring(sheet0 and sheet0.tiles)), + sheet0 ~= nil and sheet0.tiles == 79) + + -- WrapAnim is three identical rows: SUBANIM_0_BIND on tileset 0, delay 4 + local seq = (anims.WRAP or {}).seq or {} + check(("WRAP is %d subanimation rows (want 3)"):format(#seq), #seq == 3) + local rowsOk = #seq == 3 + for _, row in ipairs(seq) do + rowsOk = rowsOk and row.effect == nil and row.tileset == 0 + and row.delay == 4 and row.sound == "WRAP" + end + check("every WRAP row is a tileset-0 subanimation with sound WRAP", rowsOk) + + -- The move id in the row resolves to a real sfx program through moves.lua + local sfxName = (game.data.moves.WRAP or {}).anim + and game.data.moves.WRAP.anim.sound + check("WRAP's row sound resolves to a program (" .. tostring(sfxName) .. ")", + sfxName ~= nil and ((game.data.audio or {}).sfx or {})[sfxName] ~= nil) + + -- ---- the compiled timeline --------------------------------------------- + local function compile(move) + if not (ba and anims[move]) then return nil end + local p = AnimPlayer.new(ba) + local ok = pcall(p.start, p, move, false) + if not ok then return nil end + return p + end + + local function total(p) + local n = 0 + for _, s in ipairs(p.steps) do n = n + s.dur end + return n + end + + local function shapeOf(p) + local out = {} + for _, s in ipairs(p.steps) do + out[#out + 1] = ("%d/%d"):format(s.dur, #s.sprites) + end + return table.concat(out, " ") + end + + -- which step is on screen at `elapsed`, mirroring AnimPlayer:update + local function stepAt(p, elapsed) + local acc = 0 + for _, s in ipairs(p.steps) do + acc = acc + s.dur + if elapsed < acc then return s end + end + return nil + end + + local wrap = compile("WRAP") + check("WRAP compiles into a timeline", wrap ~= nil) + + if wrap then + U.log(" WRAP steps: " .. shapeOf(wrap)) + check(("WRAP is %d frames long (want 60, half-length bug gives 30)") + :format(total(wrap)), total(wrap) == 60) + check(("WRAP compiles to %d steps (want 9: three rows of load + 2 blocks)") + :format(#wrap.steps), #wrap.steps == 9) + + local shapeOk = #wrap.steps == 9 + for i = 1, 9 do + local s = wrap.steps[i] + if not s then shapeOk = false break end + if i % 3 == 1 then + shapeOk = shapeOk and s.dur == 10 and #s.sprites == 0 + else + shapeOk = shapeOk and s.dur == 5 and #s.sprites == 8 + end + end + check("steps 1, 4 and 7 are 10 blank frames and the other six are 5 frames " + .. "of 8 sprites", shapeOk) + + -- Near-miss guard. Paying the 10 frames AFTER a row's blocks also totals + -- 60, but it opens on sprites and ends on a blank tail, and every sound + -- lands 10 frames early. + local first, last = wrap.steps[1], wrap.steps[#wrap.steps] + check("the blank beat OPENS the animation (step 1 is blank, 10 frames)", + first ~= nil and #first.sprites == 0 and first.dur == 10) + check("...and does not close it (the last step draws sprites)", + last ~= nil and #last.sprites > 0) + + local sounds = {} + for _, ev in ipairs(wrap.events) do + if ev.sound then sounds[#sounds + 1] = ev end + end + check(("WRAP queues %d row sounds (want 3)"):format(#sounds), #sounds == 3) + local want = { 10, 30, 50 } + for i = 1, 3 do + local ev = sounds[i] + check(("sound %d fires at frame %s (want %d, after that row's tile load)") + :format(i, ev and tostring(ev.frame) or "nil", want[i]), + ev ~= nil and ev.frame == want[i] and ev.sound == "WRAP") + if ev then + local st = stepAt(wrap, ev.frame) + check(("sound %d lands on a frame with sprites on screen, not on the " + .. "blank beat"):format(i), + st ~= nil and #st.sprites > 0) + end + end + end + + -- ---- the same load, elsewhere: this was never Wrap-specific ------------ + -- POUND is one tileset-0 row, so it gains the same 10 frames. + local pound = compile("POUND") + if pound then + local s = pound.steps[1] + check(("POUND also opens on a 10-frame blank load (%s)") + :format(s and ("%d/%d"):format(s.dur, #s.sprites) or "nil"), + s ~= nil and s.dur == 10 and #s.sprites == 0) + end + + -- Tileset 2 declares 64 tiles, which is eight chunks plus the tail: 9 frames. + local ball = compile("TRADE_BALL_DROP_ANIM") + if ball then + local s = ball.steps[1] + check(("a tileset-2 animation opens on 9 blank frames instead of 10 (%s)") + :format(s and ("%d/%d"):format(s.dur, #s.sprites) or "nil"), + s ~= nil and s.dur == 9 and #s.sprites == 0) + end + + -- Control: TACKLE's animation is two special-effect rows and no subanimation, + -- so PlayAnimation never reaches LoadMoveAnimationTiles and nothing changes. + local tackle = compile("TACKLE") + if tackle then + check(("TACKLE, which has no subanimation row, is still %d frames " + .. "(the load is not charged blindly)"):format(total(tackle)), + total(tackle) == 6) + end + + -- ---- options that would fake a failure --------------------------------- + local opts = game.save.options or {} + check("battle animations are on in OPTION", opts.animations ~= false) + if opts.animations == false then + U.log(" ANIMATION is off, which skips the whole queued row: turn it on") + U.log(" in OPTION before judging anything below") + end + local vol = opts.sfxVol + if vol == 0 then + U.log(" sfxVol is 0: all three WRAP sfx will be SILENT, which sounds") + U.log(" exactly like the bug. Raise SFX in OPTION before listening") + else + U.log(" sfxVol " .. tostring(vol) .. ", so the three squeezes should be audible") + end + + U.log(("machine checks: %d passed, %d failed"):format(pass, fail)) + if fail > 0 then + U.log("something above says FAIL, so do not spend time watching the screen") + end + if not game.data.moves.WRAP then + U.log("no WRAP in the move table, so there is nothing to put on screen") + while true do coroutine.yield() end + end + + -- ---- put a Wrap on screen ---------------------------------------------- + -- data/generated/maps.lua ROUTE_1 is 10x18 blocks of open route, and pokered + -- data/maps/objects/Route1.asm parks its two youngsters at (5, 24) and + -- (15, 13), so the north end is empty. (5, 5) sits in the fence line in the + -- current cache, so the neighbour fallback below is the one that runs; the + -- battle is pushed straight in either way. + local MAP, SX, SY = "ROUTE_1", 5, 5 + + local ekans = Pokemon.new(game.data, "EKANS", 30) + ekans.moves = { { id = "WRAP", pp = game.data.moves.WRAP.pp } } + game.save.party = { ekans } + game.save.player.name = "RED" + + U.teleport(game, MAP, SX, SY, "down") + U.wait(12) + local ow = game.overworld + if ow and not ow.map:isWalkableCell(SX, SY) then + -- a map edit blocked the cell: take the nearest free neighbour instead + for _, d in ipairs({ { 0, 1 }, { 0, -1 }, { 1, 0 }, { -1, 0 } }) do + local cx, cy = SX + d[1], SY + d[2] + if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then + U.log(("(%d, %d) is blocked, standing on %d, %d"):format(SX, SY, cx, cy)) + U.teleport(game, MAP, cx, cy, "down") + U.wait(12) + ow = game.overworld + break + end + end + end + check("the overworld is up on " .. MAP, ow ~= nil and ow.map.id == MAP) + + local battle = BattleState.newWild(game, "PIDGEY", 20) + battle.onFinish = function() end + ow:pushBattle(battle) + for _ = 1, 400 do + if game.stack:top() == battle and (battle.introSlide or 0) == 0 then break end + U.wait(1) + end + check("the battle reached the screen", game.stack:top() == battle) + + -- a foe that survives the wrap chain, so the animation can repeat + battle.enemy.mon.stats.hp = 500 + battle.enemy.mon.hp = 500 + battle.enemy.shownHP = 500 + -- a wild PIDGEY that knows WHIRLWIND ends the fight the moment it picks it + for i = #battle.enemy.mon.moves, 1, -1 do + local id = battle.enemy.mon.moves[i].id + if id == "WHIRLWIND" or id == "ROAR" or id == "TELEPORT" then + table.remove(battle.enemy.mon.moves, i) + end + end + + -- ---- watch the live playback ------------------------------------------- + -- every frame the player is playing, record whether sprites are on screen, + -- so the blank load beats show up as runs in the trace + local runs, cur = {}, nil + if battle.animPlayer then + local realStart = battle.animPlayer.start + battle.animPlayer.start = function(self, moveId, isPlayer, o) + local r = realStart(self, moveId, isPlayer, o) + cur = { move = moveId, marks = {} } + runs[#runs + 1] = cur + return r + end + end + + -- U.shot spins frames of its own, which would punch a hole in the trace, so + -- the measuring turn runs with `shooting` off and a later turn takes the shot + local shooting, shot = false, false + local function sample() + if not (battle.animPlaying and cur and battle.animPlayer) then return end + local ap = battle.animPlayer + local st = ap.steps[ap.stepIndex] + if not st then return end + local lit = #st.sprites > 0 + cur.marks[#cur.marks + 1] = lit and 1 or 0 + if shooting and not shot and lit and cur.move == "WRAP" then + shot = true + U.shot(game, DIR .. "/bug1653_wrap_squeeze.png") + end + end + + local function runLengths(marks) + local out, run, val = {}, 0, nil + for _, m in ipairs(marks) do + if m ~= val then + if val ~= nil then + out[#out + 1] = ("%s %d"):format(val == 1 and "sprites" or "blank", run) + end + val, run = m, 0 + end + run = run + 1 + end + if val ~= nil then + out[#out + 1] = ("%s %d"):format(val == 1 and "sprites" or "blank", run) + end + return table.concat(out, ", ") + end + + -- step n frames, sampling each one, pressing A only while a box is up: an A + -- on the FIGHT menu opens the move list and fires a move behind our back + local function pump(n, mash, stop) + for i = 1, n do + if mash and i % mash == 0 and battle.phase == "messages" then + table.insert(game.input.pressQueue, "a") + end + U.wait(1) + game.input.state.a = false + sample() + if stop and stop() then return end + end + end + + local function toMenu() + pump(1200, 6, function() return battle.phase == "menu" and #battle.queue == 0 end) + return battle.phase == "menu" + end + + -- FIGHT is menuIndex 1 of the 2x2 grid, and WRAP is the only move on the list + local function useWrap() + for _ = 1, 80 do + if battle.phase == "moveSelect" then break end + if battle.phase == "menu" then + if battle.menuIndex ~= 1 then + U.tap(game, battle.menuIndex > 2 and "up" or "left") + else + U.tap(game, "a") + end + else + U.tap(game, "a") + end + for _ = 1, 3 do U.wait(1) sample() end + end + if battle.phase ~= "moveSelect" then return false end + for _ = 1, 20 do + if battle.phase ~= "moveSelect" then return true end + U.tap(game, "a") + for _ = 1, 3 do U.wait(1) sample() end + end + return false + end + + -- the foe animates too, so pick the player's WRAP row out of the list + local function wrapRun() + for i = #runs, 1, -1 do + if runs[i].move == "WRAP" and #runs[i].marks > 0 then return runs[i] end + end + return nil + end + + check("the battle reached its FIGHT menu", toMenu()) + + local sent = false + for _ = 1, 6 do + -- WRAP is 85% accurate and the foe leans on SAND-ATTACK, so hand the + -- accuracy back each try rather than reading a whiff as a regression + battle.player.stages.accuracy = 0 + sent = useWrap() + if not sent then break end + pump(900, 8, function() + return wrapRun() ~= nil and not battle.animPlaying + end) + if wrapRun() then break end + U.log(" WRAP did not connect that turn; using it again") + toMenu() + end + check("chose WRAP from the move menu", sent) + + local played = wrapRun() + check("WRAP's animation actually played on screen", played ~= nil) + if played then + U.log((" live playback: %d frames, %s") + :format(#played.marks, runLengths(played.marks))) + -- the sampler runs once per fixed step and can slip a frame against the + -- battle's own update, so the compiled timeline above is the exact + -- authority; this only has to rule out the 30-frame version + check(("live playback is around 60 frames, not 30 (measured %d)") + :format(#played.marks), #played.marks >= 50) + end + -- WRAP traps the foe, so the next turn replays it: measurement is done, so + -- now let the sampler stop and catch a squeeze mid-coil for the record + shooting = true + for _ = 1, 4 do + if shot then break end + if battle.phase == "menu" then + battle.player.stages.accuracy = 0 + if not useWrap() then break end + end + pump(600, 8, function() return shot end) + end + if not shot then U.log(" no lit WRAP frame to capture") end + U.shot(game, DIR .. "/bug1653_wrap_after.png") + + -- ---- hand off ---------------------------------------------------------- + U.log("the pad is yours in a battle where the EKANS knows only WRAP (#1653),") + U.log("so pick FIGHT then WRAP and watch. the coils should squeeze the PIDGEY") + U.log("three separate times, each squeeze about a sixth of a second, with a") + U.log("clear blank beat of the same length before each one -- roughly a second") + U.log("end to end. the old bug ran the three squeezes together as one half-") + U.log("second smear with no gaps.") + U.log("listen as well as look: each of the three WRAP sfx should land as the") + U.log("coils appear. an sfx that fires while the screen is still blank, with") + U.log("the pause moved to the end of the animation, is the near miss that") + U.log("still measures 60 frames.") + U.log("wrap traps the foe, so the animation repeats over the next few turns") + U.log("and you can watch it more than once. any other move works the same") + U.log("way: every animation in the game now opens on that blank beat.") + U.log("screenshots: " .. DIR .. "/bug1653_*.png") + + while true do + coroutine.yield() + end +end diff --git a/tests/engine/android_shortcuts_payload_test.lua b/tests/engine/android_shortcuts_payload_test.lua index 9af54c09..d7ea27d5 100644 --- a/tests/engine/android_shortcuts_payload_test.lua +++ b/tests/engine/android_shortcuts_payload_test.lua @@ -41,7 +41,7 @@ end -- Mock isReady RomImporter.isReady = function(v) return v == "red" or v == "gold" or v == "blue" or v == "yellow" - or v == "silver" + or v == "silver" or v == "crystal" end local ok = RomImporter.syncAndroidShortcuts("gold") @@ -57,6 +57,14 @@ RomImporter.syncAndroidShortcuts("silver") check(#capturedShortcuts == 4, "a fifth ready game does not widen the payload") check(capturedShortcuts[1] == "silver", "activeVersion 'silver' is placed first") +capturedShortcuts = nil +RomImporter.syncAndroidShortcuts("crystal") +check(#capturedShortcuts == 4, "nor does a sixth") +check(capturedShortcuts[1] == "crystal", "activeVersion 'crystal' is placed first") +check(capturedShortcuts[2] == "red" and capturedShortcuts[3] == "blue" + and capturedShortcuts[4] == "yellow", + "and the rest still follow GameVersion.ORDER until the cap") + -- Test with subset of ready games (e.g. only Red and Gold) RomImporter.isReady = function(v) return v == "red" or v == "gold" diff --git a/tests/engine/bag_bicycle_no_options_bug1705.lua b/tests/engine/bag_bicycle_no_options_bug1705.lua new file mode 100644 index 00000000..772144aa --- /dev/null +++ b/tests/engine/bag_bicycle_no_options_bug1705.lua @@ -0,0 +1,244 @@ +-- The BICYCLE is used straight off the item list, with no USE/TOSS box +-- (#1705). +-- +-- StartMenu_Item's .choseItem reads wCurItem and, before it ever loads +-- USE_TOSS_MENU_TEMPLATE into wTextBoxID, does `cp BICYCLE / jp z, +-- .useOrTossItem` (engine/menus/start_sub_menus.asm:340-342). So the bike +-- mounts, dismounts or refuses on one A press, and -- having no TOSS row to +-- reach -- can never be thrown away from the bag at all. Every other item, +-- key items included, still gets the option box. +-- +-- The port pushed the USE/TOSS Menu for every field item, so the bike took +-- two presses and offered a TOSS the cart does not. +-- luajit tests/engine/bag_bicycle_no_options_bug1705.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +package.loaded["src.core.Sound"] = { play = function() end, playCry = function() end } +local music = {} +package.loaded["src.core.Music"] = { + playMap = function(_, mapId, biking) music[#music + 1] = { mapId, biking } end, +} +package.loaded["src.render.TextBox"] = { + new = function(_, text, done) return { textBox = true, text = text, done = done } end, + soundOpts = function() return nil end, +} +package.loaded["src.ui.BagMenu"] = nil +local BagMenu = require("src.ui.BagMenu") +local Menu = require("src.ui.Menu") +require("src.ui.Screens").invalidate() + +local Fixtures = require("tests.modkit.fixtures") +local Bag = require("src.inventory.Bag") + +local Data = Fixtures.fresh() +-- the fixture item table carries neither, and BagMenu reads only +-- name/keyItem/machine off a def; the branches under test key on the id +Data.items.BICYCLE = { id = "BICYCLE", index = 6, name = "BICYCLE", price = 0, + keyItem = true } +Data.items.TOWN_MAP = { id = "TOWN_MAP", index = 5, name = "TOWN MAP", + price = 0, keyItem = true } +Data.items.FIX_POTION = Data.items.FIX_POTION + or { id = "FIX_POTION", index = 20, name = "FIX POTION", price = 300 } + +local BIKE_MAP = { id = "FIX_TOWN", def = { tileset = "OVERWORLD" } } + +local function freshGame(extras) + local game = { + data = Data, + save = { + party = {}, + player = { name = "RED", id = 1 }, + inventory = {}, + options = {}, + flags = {}, + money = 0, + }, + } + game.stack = { + states = {}, + push = function(self, s) table.insert(self.states, s) end, + pop = function(self) return table.remove(self.states) end, + top = function(self) return self.states[#self.states] end, + } + game.input = { pressed = nil } + function game.input:wasPressed(b) return self.pressed == b end + function game.input:isDown() return false end + -- IsBikeRidingAllowed reads the tileset off the loaded map, and the + -- mount/dismount lines read the player's name off the save + game.overworld = { map = BIKE_MAP, player = { surfing = false } } + Bag.add(game.save, "FIX_POTION", 3) + Bag.add(game.save, "BICYCLE", 1) + Bag.add(game.save, "TOWN_MAP", 1) + for k, v in pairs(extras or {}) do game.save[k] = v end + return game +end + +local function rowFor(list, id) + for i, r in ipairs(list.items) do + if r.value == id then return i end + end + return nil +end + +local function isMenu(s) return getmetatable(s) == Menu end +local function isBox(s) return type(s) == "table" and s.textBox == true end + +local function inStack(game, pred) + for _, s in ipairs(game.stack.states) do + if pred(s) then return true end + end + return false +end + +-- Open the bag, put the cursor on `id` and press A once. Returns the list +-- and every state the press left on the stack above it, so a USE/TOSS box +-- that appears and is then replaced is still caught. +local function chooseOnce(game, id) + local list = BagMenu.new(game, {}) + game.stack:push(list) + local row = rowFor(list, id) + if not row then return nil, nil, "no " .. id .. " row in the bag" end + list.index = row + local seen = {} + local realPush = game.stack.push + game.stack.push = function(self, s) + seen[#seen + 1] = s + return realPush(self, s) + end + game.input.pressed = "a" + list:update(1 / 60) + game.input.pressed = nil + game.stack.push = realPush + return list, seen +end + +local function sawMenu(seen) + for _, s in ipairs(seen or {}) do + if isMenu(s) then return true end + end + return false +end + +local function boxText(game) + local top = game.stack:top() + return isBox(top) and top.text or nil +end + +-- on foot, somewhere cycling is allowed: one A press mounts +do + music = {} + local game = freshGame() + local list, seen, why = chooseOnce(game, "BICYCLE") + if check(list ~= nil, "the bag opened on the BICYCLE row: " .. tostring(why)) then + check(not sawMenu(seen), + "no USE/TOSS Menu was pushed, not even for a frame (:340-342)") + eq(game.save.onBike, true, "the single press mounted the bike") + local said = boxText(game) + if check(said ~= nil, "and printed a line") then + check(said:find("got on", 1, true) ~= nil, + "which is the mount text: " .. tostring(said)) + end + eq(game.save.inventory.BICYCLE, 1, "the bike is still in the bag") + eq(#music, 1, "the bike theme was cued once") + check(music[1] and music[1][2] == true, "as the riding track") + end +end + +-- already riding: the same single press dismounts +do + music = {} + local game = freshGame({ onBike = true }) + local list, seen = chooseOnce(game, "BICYCLE") + if check(list ~= nil, "the bag opened while riding") then + check(not sawMenu(seen), "still no option box on the way off the bike") + eq(game.save.onBike, false, "one press dismounted") + local said = boxText(game) + if check(said ~= nil, "and printed a line") then + check(said:find("got off", 1, true) ~= nil, + "which is the dismount text: " .. tostring(said)) + end + check(music[1] and music[1][2] == false, "and the map theme came back") + end +end + +-- Cycling Road: the refusal is reached on the same single press, and the +-- item list stays open behind it (`jp ItemMenuLoop`, #513) +do + local game = freshGame({ onBike = true, forcedBike = true }) + local list, seen = chooseOnce(game, "BICYCLE") + if check(list ~= nil, "the bag opened on the Cycling Road") then + check(not sawMenu(seen), "the refusal is not behind an option box either") + eq(game.save.onBike, true, "and the player is still riding") + local said = boxText(game) + if check(said ~= nil, "the refusal printed") then + check(said:find("get off", 1, true) ~= nil, + "with _CannotGetOffHereText: " .. tostring(said)) + end + check(inStack(game, function(s) return s == list end), + "the bag list is still on the stack under it (#513)") + end +end + +-- somewhere cycling is not allowed: still one press, still no box +do + local game = freshGame() + game.overworld.map = { id = "FIX_INDOORS", def = { tileset = "FIX_HOUSE" } } + local list, seen = chooseOnce(game, "BICYCLE") + if check(list ~= nil, "the bag opened indoors") then + check(not sawMenu(seen), "the no-cycling refusal skips the box too") + eq(game.save.onBike, nil, "and nothing was mounted") + local said = boxText(game) + check(said ~= nil and said:find("cycling", 1, true) ~= nil, + "NoCyclingAllowedHere printed: " .. tostring(said)) + end +end + +-- the control cases: everything else still gets USE/TOSS, key items included +do + local game = freshGame() + local list, seen = chooseOnce(game, "FIX_POTION") + if check(list ~= nil, "the bag opened on a plain item") then + check(sawMenu(seen), "an ordinary item still opens the option box") + check(isMenu(game.stack:top()), "and it is what the A press left on top") + end +end + +do + local game = freshGame() + local list, seen = chooseOnce(game, "TOWN_MAP") + if check(list ~= nil, "the bag opened on the TOWN MAP") then + check(sawMenu(seen), + "another key item still gets the box: pokered special-cases the " + .. "BICYCLE by id, not key items as a class") + end +end + +-- the box the bike no longer opens is the only route to TOSS, so the bike +-- cannot be thrown away from the bag at all +do + local game = freshGame() + local list, seen = chooseOnce(game, "BICYCLE") + if check(list ~= nil, "the bag opened on the BICYCLE row") then + local rows = {} + for _, s in ipairs(seen or {}) do + for _, it in ipairs((isMenu(s) and s.items) or {}) do + rows[#rows + 1] = tostring(it.label) + end + end + eq(#rows, 0, "the press offered no menu rows at all, TOSS included") + eq(game.save.inventory.BICYCLE, 1, "and the bike survives the press") + end +end + +package.loaded["src.core.Sound"] = nil +package.loaded["src.core.Music"] = nil +package.loaded["src.render.TextBox"] = nil +package.loaded["src.ui.BagMenu"] = nil +require("src.ui.Screens").invalidate() + +T.finish() diff --git a/tests/engine/bag_cancel_row_bug1685.lua b/tests/engine/bag_cancel_row_bug1685.lua new file mode 100644 index 00000000..ad33646f --- /dev/null +++ b/tests/engine/bag_cancel_row_bug1685.lua @@ -0,0 +1,253 @@ +-- The bag list ends on the terminator's CANCEL row (#1685). +-- +-- pokered's item lists are $ff-terminated. PrintListMenuEntries walks four +-- entries and, on the terminator, `jp z, .printCancelMenuItem` -- which is +-- `ld de, ListMenuCancelText / jp PlaceString`, a TAIL jump that RETURNS +-- from PrintListMenuEntries (home/list_menu.asm:371-372, 523-528). The '▼' +-- at :518-522 is only reached by the fall-through, so any page showing +-- CANCEL shows no down arrow. DisplayListMenuIDLoop compares the selection +-- against wListCount and takes ExitListMenu when it is the row past the last +-- item (:105-110), which is the same exit B takes. +-- +-- The port emitted one row per bag entry and stopped, so the list had no +-- CANCEL to walk onto and printed an invented "Nothing here." when the bag +-- was empty. +-- luajit tests/engine/bag_cancel_row_bug1685.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +-- Font wants a real atlas and this suite is about what lands on which row, +-- so it records the calls instead (the shape tests/engine/ +-- bag_item_box_bug1521.lua uses). ListMenu and Theme bind Font at require +-- time, and BagMenu binds ListMenu and TextBox at require time. +local calls = {} +package.loaded["src.render.Font"] = { + BORDER = { tl = 1, tr = 2, bl = 3, br = 4, h = 5, v = 6 }, + draw = function(text, x, y) calls[#calls + 1] = { "draw", text, x, y } end, + drawCode = function(code, x, y) calls[#calls + 1] = { "code", code, x, y } end, + drawBox = function(tx, ty, tw, th) calls[#calls + 1] = { "box", tx, ty, tw, th } end, + width = function(text) return #tostring(text) * 8 end, + -- Menu.new sizes its box off split(); reaching it at all is the failure + -- this suite reports, so the stub answers instead of erroring + split = function(text) + local spans = {} + for i = 1, #tostring(text) do spans[i] = { from = i, to = i, code = 0 } end + return spans + end, +} +package.loaded["src.core.Sound"] = { play = function() end, playCry = function() end } +package.loaded["src.render.TextBox"] = { + new = function(_, text, done) return { textBox = true, text = text, done = done } end, + soundOpts = function() return nil end, +} +package.loaded["src.ui.ListMenu"] = nil +package.loaded["src.ui.Theme"] = nil +package.loaded["src.ui.BagMenu"] = nil +local ListMenu = require("src.ui.ListMenu") +local Theme = require("src.ui.Theme") +local BagMenu = require("src.ui.BagMenu") +require("src.ui.Screens").invalidate() + +local Fixtures = require("tests.modkit.fixtures") +local Bag = require("src.inventory.Bag") +local Strings = require("src.core.Strings") + +local CANCEL = Strings("CANCEL") + +local Data = Fixtures.fresh() +for i = 1, 6 do + local id = "FIXITEM_" .. i + Data.items[id] = { id = id, index = 100 + i, name = "FIX ITEM " .. i, + price = 100, tossable = true } +end + +local function freshGame(count) + local game = { + data = Data, + save = { + party = {}, + player = { name = "RED", id = 1 }, + inventory = {}, + options = {}, + flags = {}, + money = 0, + }, + } + game.stack = { + states = {}, + push = function(self, s) table.insert(self.states, s) end, + pop = function(self) return table.remove(self.states) end, + top = function(self) return self.states[#self.states] end, + } + -- one button edge per update, the way Input reports a fixed step + game.input = { pressed = nil } + function game.input:wasPressed(b) return self.pressed == b end + function game.input:isDown() return false end + for i = 1, count do Bag.add(game.save, "FIXITEM_" .. i, i) end + return game +end + +local function openBag(game, opts) + local list = BagMenu.new(game, opts) + game.stack:push(list) + return list +end + +local function found(kind, pred) + for _, c in ipairs(calls) do + if c[1] == kind and pred(c) then return c end + end + return nil +end + +local function drawnAt(text, x, y) + return found("draw", function(c) + return c[2] == text and c[3] == x and (y == nil or c[4] == y) + end) +end + +local function press(list, btn) + list.game.input.pressed = btn + list:update(1 / 60) + list.game.input.pressed = nil +end + +-- the row itself: one CANCEL after the last bag entry, carrying no id and no +-- quantity (home/list_menu.asm:523-528) +do + local game = freshGame(6) + local list = openBag(game) + eq(#list.items, 7, "six bag entries plus the terminator's CANCEL row") + local last = list.items[#list.items] + eq(last.label, CANCEL, "the last row is CANCEL") + eq(last.cancel, true, "flagged as the terminator row, not an item") + eq(last.value, nil, "with no item id behind it") + eq(last.right, nil, "and no 'xN' count (PrintListMenuEntries never gets " + .. "that far for the terminator)") + eq(CANCEL, "CANCEL", "ListMenuCancelText reads CANCEL") + for i = 1, 6 do + eq(list.items[i].value, "FIXITEM_" .. i, "row " .. i .. " is still its item") + end +end + +-- page one of a long list: four real names, the '▼', no CANCEL yet +do + local game = freshGame(6) + local list = openBag(game) + calls = {} + list:draw() + for row = 1, 4 do + local y = 32 + (row - 1) * 16 + check(drawnAt("FIX ITEM " .. row, 48, y) ~= nil, + "name " .. row .. " sits at (48, " .. y .. ")") + end + check(found("draw", function(c) return c[2] == CANCEL end) == nil, + "CANCEL is below the fold on page one, not printed on it") + check(found("code", function(c) + return c[2] == Theme.moreArrow and c[3] == 144 and c[4] == 88 + end) ~= nil, "four real rows fill the page, so the '▼' prints (:518-522)") +end + +-- walking down: the cursor reaches CANCEL and stops there, and the page it +-- lands on drops the '▼' +do + local game = freshGame(6) + local list = openBag(game) + for _ = 1, 6 do press(list, "down") end + eq(list.index, 7, "six downs put the cursor on the CANCEL row") + eq(list.scroll, 4, "with the scroll capped at wListCount - 2") + press(list, "down") + eq(list.index, 7, "and a seventh down goes nowhere: CANCEL is the last row") + eq(list.scroll, 4, "the list does not scroll past it either") + + calls = {} + list:draw() + -- rows 5, 6, CANCEL, then the terminator's return + check(drawnAt("FIX ITEM 5", 48, 32) ~= nil, "FIX ITEM 5 leads the last page") + check(drawnAt(CANCEL, 48, 64) ~= nil, + "CANCEL sits one row below the last item, at the item names' x") + check(found("code", function(c) + return c[2] == Theme.cursor and c[3] == 40 and c[4] == 64 + end) ~= nil, "the cursor is on it, in wTopMenuItemX's column") + check(found("code", function(c) return c[2] == Theme.moreArrow end) == nil, + "and the '▼' is gone: .printCancelMenuItem returns before it") +end + +-- the subtle one: three items plus CANCEL fills all four printed rows, so +-- `shown == rows` alone would still paint the arrow next to CANCEL +do + local game = freshGame(3) + local list = openBag(game) + eq(#list.items, 4, "three items and CANCEL exactly fill the four rows") + calls = {} + list:draw() + check(drawnAt(CANCEL, 48, 80) ~= nil, "CANCEL is the fourth printed row") + check(found("code", function(c) return c[2] == Theme.moreArrow end) == nil, + "a full page whose last row is CANCEL still prints no '▼' (:371-372)") +end + +-- an empty bag is a box with CANCEL alone, which is what the cart shows +do + local game = freshGame(0) + local list = openBag(game) + eq(#list.items, 1, "no items: the terminator is the whole list") + eq(list.items[1] and list.items[1].cancel, true, "and that row is CANCEL") + calls = {} + list:draw() + check(drawnAt(CANCEL, 48, 32) ~= nil, "printed on the first row") + check(found("draw", function(c) + return tostring(c[2]):find("Nothing here", 1, true) ~= nil + end) == nil, "the port's invented \"Nothing here.\" line is gone") + check(found("code", function(c) return c[2] == Theme.moreArrow end) == nil, + "one row, no '▼'") +end + +-- A on CANCEL is ExitListMenu: the same exit B takes (:105-110) +do + local game = freshGame(6) + local cancels = 0 + local list = openBag(game, { onCancel = function() cancels = cancels + 1 end }) + for _ = 1, 6 do press(list, "down") end + eq(list.index, 7, "cursor on CANCEL") + press(list, "a") + check(game.stack:top() ~= list, "A on CANCEL leaves the item list") + eq(#game.stack.states, 0, "with nothing pushed over it: no USE/TOSS box, " + .. "no message") + eq(cancels, 1, "and the caller's onCancel ran, exactly as for B") +end + +do + local game = freshGame(6) + local cancels = 0 + local list = openBag(game, { onCancel = function() cancels = cancels + 1 end }) + press(list, "b") + check(game.stack:top() ~= list, "B still exits the list") + eq(cancels, 1, "through the same callback") +end + +-- SELECT swaps two bag entries; the terminator is not one +-- (engine/menus/swap_items.asm:19-22) +do + local game = freshGame(6) + local list = openBag(game) + for _ = 1, 6 do press(list, "down") end + press(list, "select") + eq(list.swapIndex, nil, "SELECT on the CANCEL row starts no swap") + press(list, "up") + press(list, "select") + eq(list.swapIndex, 6, "while SELECT on a real row still does") +end + +package.loaded["src.render.Font"] = nil +package.loaded["src.core.Sound"] = nil +package.loaded["src.render.TextBox"] = nil +package.loaded["src.ui.ListMenu"] = nil +package.loaded["src.ui.Theme"] = nil +package.loaded["src.ui.BagMenu"] = nil +require("src.ui.Screens").invalidate() + +T.finish() diff --git a/tests/engine/cart_launcher.lua b/tests/engine/cart_launcher.lua new file mode 100644 index 00000000..40c0adad --- /dev/null +++ b/tests/engine/cart_launcher.lua @@ -0,0 +1,1456 @@ +-- Custom carts in the launcher. +-- luajit tests/engine/cart_launcher.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +love.graphics.setLineJoin = love.graphics.setLineJoin or function() end +love.graphics.newShader = love.graphics.newShader or function() return {} end +love.graphics.polygon = love.graphics.polygon or function() end + +local Kit = require("src.ui.kit.Kit") +local SaveData = require("src.core.SaveData") +local CartManifest = require("src.carts.CartManifest") +local CartStore = require("src.carts.CartStore") +local RomImporter = require("src.import.RomImporter") +local LauncherView = require("src.import.LauncherView") + +local SHA = ("a1b2c3d4"):rep(8) + +local function window(w, h) + love.graphics.getDimensions = function() return w, h end + love.graphics.getPixelDimensions = function() return w, h end +end + +local function freshLauncher(onComplete) + return RomImporter.new(onComplete or function() end, { launcher = true }) +end + +local realPrint = love.graphics.print +local function drawAndCapture(imp) + local seen = {} + love.graphics.print = function(str, ...) + seen[#seen + 1] = tostring(str) + return realPrint(str, ...) + end + local ok, err = pcall(LauncherView.draw, imp) + love.graphics.print = realPrint + check(ok, "the frame draws: " .. tostring(err)) + return table.concat(seen, "\n") +end + +local realSetColor = love.graphics.setColor +local function drawColors(imp) + local seen = {} + love.graphics.setColor = function(r, g, b, a) + if type(r) == "number" and type(g) == "number" and type(b) == "number" then + seen[("%d,%d,%d"):format(math.floor(r * 255 + 0.5), + math.floor(g * 255 + 0.5), math.floor(b * 255 + 0.5))] = true + end + return realSetColor(r, g, b, a) + end + local ok, err = pcall(LauncherView.draw, imp) + love.graphics.setColor = realSetColor + check(ok, "the frame draws: " .. tostring(err)) + return seen +end + +local function cartTable(over) + local tbl = { + id = "kanto_plus", title = "Kanto Plus", version = "1.2.0", + author = "Ren", shell = "#3fa9f5", base = "red", seal = "sealed", + mods = { { id = "rare_soda", source = "github", repo = "ren/rare-soda", + version = "0.4.1", sha256 = SHA } }, + } + for key, value in pairs(over or {}) do tbl[key] = value end + return tbl +end + +local function install(over) + over = over or {} + local cart, parseErr = CartManifest.parse(cartTable(over)) + check(cart ~= nil, "fixture parses: " .. tostring(parseErr)) + cart.labelArt = over.labelArt + local ok, err = CartStore.install(CartManifest.encode(cart)) + check(ok ~= nil, "fixture installs: " .. tostring(err)) + return cart +end + +install() +install({ id = "zeta_open", title = "Zeta Open", version = "0.9.0", + seal = "open", shell = "#112233" }) +install({ id = "johto_lite", title = "Johto Lite", base = "blue", + shell = "#7a5c2e" }) + +window(1280, 720) + +local imp = freshLauncher() +local red = imp:_ensureCarts("red") +eq(#red, 2, "red lists exactly the carts based on red") +eq(red[1].id, "kanto_plus", "the list is sorted by title") +eq(red[2].id, "zeta_open", "the list is sorted by title") +eq(#imp:_ensureCarts("blue"), 1, "blue lists only its own cart") +eq(imp:_ensureCarts("blue")[1].id, "johto_lite", "and that one is Johto Lite") +eq(#imp:_ensureCarts("yellow"), 0, "a game with no carts lists none") + +-- A .g1rcart dropped into the carts folder by hand, with no registry entry: +-- the launcher must adopt it, not read past it. _refreshCarts used to call +-- CartStore.index (registry only) and never saw one. +do + local stray = CartManifest.parse(cartTable({ + id = "dropped_in", title = "Dropped In", version = "2.0.0" })) + check(stray ~= nil, "the stray fixture parses") + local opts = SaveData.loadOptions() + opts[CartStore.OPTIONS_KEY] = nil + SaveData.saveOptions(opts) + love.filesystem.createDirectory(CartStore.DIR) + love.filesystem.write(CartStore.fileFor("dropped_in"), + CartManifest.encode(stray)) + + local fresh = freshLauncher() + local found + for _, row in ipairs(fresh:_ensureCarts("red")) do + if row.id == "dropped_in" then found = row end + end + check(found ~= nil, "the launcher lists a cart dropped into the folder") + if found then + eq(found.title, "Dropped In", "and reads its title from the file") + eq(found.base, "red", "and its base game") + end + eq(CartStore.index()[1] ~= nil, true, + "listing healed the registry so the cheap index sees it too") + + -- Put the fixture set back: later blocks assert on the picker's own layout, + -- and a third red cart changes its height. + CartStore.uninstall("dropped_in") +end + +imp.tab = "red" +imp.ready.red = true +imp._cartPopup = "red" +local picker = drawAndCapture(imp) +check(picker:find("Pokemon Red", 1, true) ~= nil, + "the picker offers the base game as the first row") +check(picker:find("Kanto Plus", 1, true) ~= nil, "the picker lists Kanto Plus") +check(picker:find("Zeta Open", 1, true) ~= nil, "the picker lists Zeta Open") +check(picker:find("Johto Lite", 1, true) == nil, + "the picker does NOT list a cart based on another game") +check(picker:find("v1.2.0", 1, true) ~= nil, "a cart row carries its version") +check(picker:find("sealed", 1, true) ~= nil, "a cart row carries its seal state") +check(picker:find("open", 1, true) ~= nil, "including an open one") +check(picker:find("Import a cart", 1, true) ~= nil + or picker:find("Get more carts", 1, true) ~= nil, + "the last row imports a cart, by picker or by folder") + +imp._cartPopup = nil +local vanillaColors = drawColors(imp) +check(vanillaColors["63,169,245"] == nil, + "a vanilla page never paints the cart's shell colour") + +imp:_selectCart("red", "kanto_plus") +eq(imp.activeCart.red, "kanto_plus", "the pick lands on activeCart") +eq(imp._cartPopup, nil, "picking closes the picker") + +local titled = drawAndCapture(imp) +check(titled:find("Kanto Plus", 1, true) ~= nil, + "the panel title becomes the cart's title") + +local cartColors = drawColors(imp) +eq(cartColors["63,169,245"], true, + "the cartridge takes the cart's shell colour") + +eq(imp.tab, "red", "a cart id never reaches imp.tab") +eq(imp.panelVersion, "red", "a cart id never reaches imp.panelVersion") + +check(imp._cartridge["cart:kanto_plus"] ~= nil, + "the cart spins on its own cartridge state") +check(imp._cartridge["cart:kanto_plus"] ~= imp._cartridge.red, + "which is not the base game's") +eq(imp._cartridgeLabels["cart:kanto_plus"], false, + "a cart carrying no label art falls through to bare plastic") + +local scope = imp:slotScope("red") +eq(scope, "cart_kanto_plus", "an active cart scopes the panel's save slots") +imp:_newSlot(scope) +imp:_newSlot(scope) +eq(#SaveData.listCartSlots("kanto_plus"), 2, "both slots land in the cart") +eq(#SaveData.listSlots("red"), 0, "and none of them in the base game") +imp:_ensureSlots(scope) +eq(#imp.slots[scope], 2, "the panel reads the cart's slots back") + +imp:_beginRename(scope, "slot1") +imp._rename.text = "Nuzlocke" +imp:_commitRename() +eq(SaveData.listCartSlots("kanto_plus")[1].label, "Nuzlocke", + "a rename writes into the cart's registry") + +imp:_selectSlot(scope, "slot2") +eq(SaveData.activeCartSlot("kanto_plus"), "slot2", + "selecting a row moves the cart's active slot") +imp:_deleteSlot(scope, "slot2") +eq(#SaveData.listCartSlots("kanto_plus"), 1, "a delete removes the cart's slot") + +local withCart = drawAndCapture(imp) +check(withCart:find("Nuzlocke", 1, true) ~= nil, + "the slot card shows the cart's slots while the cart is active") + +imp:_selectCart("red", nil) +eq(imp.activeCart.red, nil, "choosing the base game clears the active cart") +eq(imp:slotScope("red"), "red", "and the slots go back to the version's own") +imp:_newSlot("red") +eq(#SaveData.listSlots("red"), 1, "a vanilla slot lands in the version") +eq(#SaveData.listCartSlots("kanto_plus"), 1, "and not in the cart") + +local backHome = drawAndCapture(imp) +check(backHome:find("Nuzlocke", 1, true) == nil, + "the slot card no longer shows the cart's slots") +check(backHome:find("Kanto Plus", 1, true) == nil, + "and the panel title is the base game's again") +local homeColors = drawColors(imp) +check(homeColors["63,169,245"] == nil, + "the cartridge is back to the base game's shell") + +local handed = {} +local player = freshLauncher(function(version, cartId) + handed.version, handed.cart = version, cartId +end) +player.ready.red = true +player:_selectCart("red", "kanto_plus") +player:play("red") +eq(handed.version, "red", "Play still boots the base game") +eq(handed.cart, "kanto_plus", "and names the cart it is running") + +local opts = SaveData.loadOptions() +eq(opts.lastVersion, "red", "play still remembers the version") +eq(type(opts.activeCart) == "table" and opts.activeCart.red or nil, "kanto_plus", + "play persists the active cart beside it") + +local restored = freshLauncher() +eq(restored.activeCart.red, "kanto_plus", + "a fresh launcher restores the cart its page was on") +eq(restored.tab, "red", "and a cart id still never reaches the tab") + +local uninstalled = freshLauncher() +uninstalled.activeCart.red = nil +uninstalled:_restoreActiveCarts({ activeCart = { red = "gone_forever" } }) +eq(uninstalled.activeCart.red, nil, + "a remembered cart that is no longer installed is dropped") + +local Base64 = require("src.core.Base64") +local PNG = CartManifest.PNG_SIGNATURE .. ("labelart"):rep(4) +local function artOf() + return { encoding = "base64", bytes = #PNG, data = Base64.encode(PNG) } +end + +install({ id = "art_cart", title = "Art Cart", base = "yellow", + shell = "#204060", labelArt = artOf() }) +install({ id = "bare_cart", title = "Bare Cart", base = "yellow", + shell = "#405060" }) +install({ id = "bad_cart", title = "Bad Cart", base = "yellow", + shell = "#605040", labelArt = artOf() }) + +check(CartStore.labelArt("art_cart") == PNG, + "the store hands back the cart's own PNG bytes") +check(CartStore.labelArt("bare_cart") == nil, + "and nothing for a cart that carries none") + +window(1280, 720) +local realNewImage = love.graphics.newImage +local madeImages = 0 +love.graphics.newImage = function(...) + madeImages = madeImages + 1 + return realNewImage(...) +end + +local art = freshLauncher() +art.tab = "yellow" +art.ready.yellow = true +art:_selectCart("yellow", "art_cart") +drawAndCapture(art) +local artLabel = art._cartridgeLabels["cart:art_cart"] +check(type(artLabel) == "table" and artLabel.image ~= nil, + "a cart's own label art becomes a cached cartridge image") +local afterFirst = madeImages +drawAndCapture(art) +eq(madeImages, afterFirst, "the decode happens once, not every frame") + +art:_selectCart("yellow", "bare_cart") +drawAndCapture(art) +eq(art._cartridgeLabels["cart:bare_cart"], false, + "a cart with no art renders as bare plastic") +check(art._cartridgeLabels["cart:art_cart"] ~= art._cartridgeLabels["cart:bare_cart"], + "and the two carts never share one label cache entry") + +love.graphics.newImage = function(a, ...) + if type(a) == "table" and a._fileData then error("not a PNG this engine reads") end + return realNewImage(a, ...) +end +local bad = freshLauncher() +bad.tab = "yellow" +bad.ready.yellow = true +bad:_selectCart("yellow", "bad_cart") +drawAndCapture(bad) +love.graphics.newImage = realNewImage +eq(bad._cartridgeLabels["cart:bad_cart"], false, + "art that will not decode leaves the cart bare instead of throwing") +drawAndCapture(bad) +eq(bad._cartridgeLabels["cart:bad_cart"], false, + "and it is not retried on the next frame") + +local LauncherMods = require("src.mods.LauncherMods") +local realModList = LauncherMods.list + +local function fakeRow(over) + local row = { id = "x", name = "X", version = "1.0.0", badge = "MOD", + description = "", enabled = true, status = "ok", + statusDetail = "", experimental = false, targetsHere = true, + targets = nil, safeMode = false, requiredImports = {}, + imports = {}, missingRequiredImports = 0, + missingOptionalImports = 0, + enabledByVersion = { red = true, blue = true, yellow = true, + gold = true, silver = true } } + for key, value in pairs(over) do row[key] = value end + row.manifest = row.manifest or { id = row.id, name = row.name, + version = row.version } + return row +end + +local FAKE_MODS = { + fakeRow({ id = "rare_soda", name = "Rare Soda", version = "0.4.1", + github = "ren/rare-soda", sha256 = SHA }), + fakeRow({ id = "wide_gym", name = "Wide Gym", version = "dev" }), + fakeRow({ id = "off_mod", name = "Off Mod", enabled = false, + enabledByVersion = { red = false } }), +} + +local modsView = freshLauncher() +modsView.tab = "mods" +local modsText = drawAndCapture(modsView) +check(modsText:find("Save as cart", 1, true) ~= nil, + "the mods tab carries the Save as cart control") + +LauncherMods.list = function() return FAKE_MODS end + +local maker = freshLauncher() +maker.tab = "red" +maker.ready.red = true +maker:_setModScope("red") +eq(maker:_cartCaptureCount("red"), 3, + "the control counts every mod the capture would pin, on or off") + +maker:_beginCartSave("red") +check(maker._cartSave ~= nil, "Save as cart opens a form") +eq(maker._cartSave.count, 3, "the form reports the captured mod count") +eq(maker._cartSave.version, "red", "scoped to the game the panel is showing") +eq(#maker._cartSave.unresolved, 2, "capture reports the pins it could not resolve") +eq(maker._cartSave.unresolved[1].id, "wide_gym", "naming the mod it belongs to") +check(tostring(maker._cartSave.unresolved[1].reason):find("semantic", 1, true) ~= nil, + "and why it could only be pinned locally") +eq(maker._cartSave.publishable, false, "a local pin makes the cart unpublishable") + +local form = drawAndCapture(maker) +check(form:find("Save as cart", 1, true) ~= nil, "the form is titled") +check(form:find("Wide Gym", 1, true) ~= nil, + "the unresolved pin is named BEFORE the player confirms") +check(form:find("could only be pinned to this install", 1, true) ~= nil, + "under a heading that says what a local pin means") +check(form:find("cannot be shared", 1, true) ~= nil, + "and the form says plainly that the result cannot be shared") + +maker._cartSave.text = "Kanto Plus" +maker:_commitCartSave() +check(maker._cartSave ~= nil, "a title that collides with an installed cart refuses") +check(tostring(maker._cartSave.error):find("kanto_plus", 1, true) ~= nil, + "and names the id that is already taken") +eq(CartStore.get("kanto_plus").title, "Kanto Plus", + "the installed cart is untouched") + +maker._cartSave.text = "Soda Run" +maker:_commitCartSave() +eq(maker._cartSave, nil, "a free title saves the cart and closes the form") +eq(maker._cartPopup, "red", "and drops the player straight into the picker") +local made = CartStore.get("soda_run") +check(made ~= nil, "the cart is installed under the id derived from the title") +eq(made.base, "red", "based on the game the panel was showing") +eq(made.version, "1.0.0", "at the default cart version") +eq(made.seal, "sealed", "sealed by default") +eq(made.shell, "#ff3c48", "wearing the base game's rail colour") +eq(#made.mods, 3, "pinning every installed mod, on or off") +local madeOff +for _, entry in ipairs(made.mods) do + if entry.id == "off_mod" then madeOff = entry end +end +check(madeOff ~= nil, "including the one the player has switched off") +eq(madeOff.enabled, false, "which is pinned switched off rather than dropped") + +local listedNow = false +for _, row in ipairs(maker:_ensureCarts("red")) do + if row.id == "soda_run" then listedNow = true end +end +check(listedNow, "and the picker lists it immediately") +local picked = drawAndCapture(maker) +check(picked:find("Soda Run", 1, true) ~= nil, "including on screen") + +LauncherMods.list = function() return { FAKE_MODS[1] } end +local pure = freshLauncher() +pure.tab = "red" +pure.ready.red = true +pure:_beginCartSave("red") +eq(#pure._cartSave.unresolved, 0, "a fully pinned capture has no local pins") +eq(pure._cartSave.publishable, true, "and it is publishable") +local pureForm = drawAndCapture(pure) +check(pureForm:find("can be shared", 1, true) ~= nil, + "which the form says before the player confirms") +check(pureForm:find("cannot be shared", 1, true) == nil, + "instead of the local-pin warning") +pure._cartSave.text = "Pure Soda" +pure:_commitCartSave() +eq(pure._cartSave, nil, "a fully pinned capture saves too") +local pureOk, pureWhy = CartManifest.publishable(CartStore.get("pure_soda")) +eq(pureOk, true, "and the saved cart really is publishable") +eq(pureWhy, nil, "with nothing holding it back") +LauncherMods.list = function() return FAKE_MODS end + +local blank = freshLauncher() +blank:_beginCartSave("red") +blank:_commitCartSave() +check(blank._cartSave ~= nil, "an empty title does not save") +check(tostring(blank._cartSave.error):find("title", 1, true) ~= nil, + "and asks for one") +blank:_cancelCartSave() +eq(blank._cartSave, nil, "Cancel closes the form") + +LauncherMods.list = realModList + +local exporter = freshLauncher() +exporter:exportCart("kanto_plus") +check(tostring(exporter._cartNotice):find("Exported", 1, true) ~= nil, + "Export reports where the cart file went: " .. tostring(exporter._cartNotice)) +local wroteBytes = love.filesystem.read("exports/carts/kanto_plus" .. CartStore.EXT) +check(type(wroteBytes) == "string" and wroteBytes ~= "", + "and the bytes land in the same exports tree a save export uses") +eq(wroteBytes, (CartStore.export("kanto_plus")), + "byte for byte what CartStore.export returned") +local roundTrip = CartManifest.decode(wroteBytes) +check(roundTrip ~= nil and roundTrip.id == "kanto_plus", + "and the file decodes back into the cart") + +exporter:exportCart("no_such_cart") +check(tostring(exporter._cartNotice):find("not installed", 1, true) ~= nil, + "exporting a cart that is not installed says so") + +window(1920, 1080) + +local FULL_MODS = { fakeRow({ id = "rare_soda", name = "Rare Soda", + version = "0.4.1", github = "ren/rare-soda", + sha256 = SHA }) } + +LauncherMods.list = function() return FULL_MODS end +local okCart = freshLauncher() +okCart.tab = "red" +okCart.ready.red = true +okCart:_selectCart("red", "kanto_plus") +local okPlan = okCart:cartPlan("red") +eq(okPlan.refused, false, "a cart whose pin is installed is playable") +eq(okPlan.sealed, true, "and is still sealed") +local okText = drawAndCapture(okCart) +check(okText:find("Sealed", 1, true) ~= nil, + "the verdict is on the page before the player commits") +check(okText:find("Break the seal", 1, true) ~= nil, + "and a sealed cart's page offers the escape hatch") + +LauncherMods.list = function() return {} end +local gapCart = freshLauncher() +gapCart.tab = "red" +gapCart.ready.red = true +gapCart:_selectCart("red", "kanto_plus") +local gapPlan = gapCart:cartPlan("red") +eq(gapPlan.refused, true, "a cart with an uninstalled pin refuses") +eq(gapPlan.missing[1].id, "rare_soda", "naming the pin it cannot find") +local gapText = drawAndCapture(gapCart) +check(gapText:find("will not start", 1, true) ~= nil, + "which the page says without the player booting into an error") +check(gapText:find("rare_soda", 1, true) ~= nil, "and names the pin") +check(gapText:find("Break the seal", 1, true) ~= nil, + "with the escape hatch beside the refusal") + +local sealScope = gapCart:slotScope("red") +gapCart:_ensureSlots(sealScope) +local sealSlot = gapCart.activeSlot[sealScope] +check(type(sealSlot) == "string", "the cart page has a loaded save slot") +eq(gapCart:pressBreakSeal("red"), false, "the first press only arms the confirm") +eq(SaveData.slotSealBroken("kanto_plus", sealSlot), false, + "so one press breaks nothing") +local armedText = drawAndCapture(gapCart) +check(armedText:find("Kanto Plus", 1, true) ~= nil, + "the armed confirm names the cart") +check(armedText:find("save slot", 1, true) ~= nil, "and the save slot") +check(armedText:find("cannot be undone", 1, true) ~= nil, + "says it is permanent") +check(armedText:find("marked modified", 1, true) ~= nil, + "says the file is marked modified from then on") +check(armedText:find("pinned mods first", 1, true) ~= nil, + "and that the cart's own list still loads first") +eq(gapCart:pressBreakSeal("red"), true, "a second press breaks the seal") +eq(SaveData.slotSealBroken("kanto_plus", sealSlot), true, + "which lands on that slot, durably") +eq(gapCart:cartPlan("red").refused, false, "and the cart is no longer refused") +local brokenText = drawAndCapture(gapCart) +check(brokenText:find("Seal broken", 1, true) ~= nil, + "the cart page shows the broken state afterwards") +check(brokenText:find("seal broken", 1, true) ~= nil, + "and so does the cart's slot row") + +gapCart:_newSlot(sealScope) +local freshSlot = gapCart.activeSlot[sealScope] +check(freshSlot ~= sealSlot, "a new save slot under the same cart") +eq(SaveData.slotSealBroken("kanto_plus", freshSlot), false, + "starts sealed again") +eq(gapCart:cartPlan("red").refused, true, "so the cart refuses that one") + +install({ id = "plus_cart", title = "Plus Cart", seal = "sealed+", + shell = "#445566" }) +LauncherMods.list = function() return FULL_MODS end +local plusCart = freshLauncher() +plusCart.tab = "red" +plusCart.ready.red = true +plusCart._cartPopup = "red" +local plusPicker = drawAndCapture(plusCart) +check(plusPicker:find("sealed+", 1, true) ~= nil, + "the picker spells a sealed+ cart's seal out") +plusCart._cartPopup = nil +plusCart:_selectCart("red", "plus_cart") +local plusPlan = plusCart:cartPlan("red") +eq(plusPlan.seal, "sealed+", "the plan carries the sealed+ seal") +eq(plusPlan.sealed, true, "sealed+ is a sealed cart") +eq(plusPlan.enforced, true, "and enforces its mod set") +local plusText = drawAndCapture(plusCart) +check(plusText:find("Sealed", 1, true) ~= nil, "the page says it is sealed") +check(plusText:find("switch any of them on or off", 1, true) ~= nil, + "and that the player may switch the mods it pins") +check(plusText:find("Break the seal", 1, true) ~= nil, + "while still offering the escape hatch") +eq(SaveData.slotSealBroken("plus_cart", + plusCart.activeSlot[plusCart:slotScope("red")] or "slot1"), false, + "and reading the page breaks nothing") + +-- ------- installing the mods a cart pins, instead of defeating its seal + +local ModUpdate = require("src.mods.ModUpdate") +local realFetchBegin, realFetchPump = + ModUpdate.beginFetchReleases, ModUpdate.pumpFetchReleases +local realDlBegin, realDlPump = + ModUpdate.beginDownloadZip, ModUpdate.pumpDownloadZip +local realInstallDownloaded = LauncherMods.installDownloadedZip + +love.data = love.data or {} +local savedData = { hash = love.data.hash, encode = love.data.encode } +-- The archive's digest IS its bytes here, so a fixture writes the hash it +-- wants the downloaded zip to have. +love.data.hash = function(_, data) return tostring(data) end +love.data.encode = function(_, _, digest) return tostring(digest) end + +local SHA_ONE = ("0123abcd"):rep(8) +local SHA_TWO = ("dead9876"):rep(8) + +local net = { versions = {}, bytes = {}, err = nil, repos = {} } +ModUpdate.beginFetchReleases = function(repo, modId) + net.repos[modId] = repo + return { modId = modId, repo = repo } +end +ModUpdate.pumpFetchReleases = function(h) + if net.err then return true, nil, net.err end + local out = {} + for _, v in ipairs(net.versions[h.modId] or {}) do + out[#out + 1] = { version = v, tag = "v" .. v, + zip = { url = h.modId .. "@" .. v, name = h.modId .. ".zip" } } + end + return true, out +end +ModUpdate.beginDownloadZip = function(url, destName) + love.filesystem.write(destName, net.bytes[url] or "not-the-pinned-archive") + return { path = destName } +end +ModUpdate.pumpDownloadZip = function(h) return true, h.path end + +local haveMods = {} +LauncherMods.installDownloadedZip = function(modId, localPath, version) + haveMods[modId] = version or "?" + pcall(love.filesystem.remove, localPath) + return true, haveMods[modId] +end +LauncherMods.list = function() + local rows = {} + for id, v in pairs(haveMods) do + rows[#rows + 1] = fakeRow({ id = id, name = id, version = v }) + end + return rows +end + +install({ id = "fill_cart", title = "Fill Cart", shell = "#227744", + mods = { + { id = "fill_one", source = "github", repo = "ren/fill-one", + version = "1.0.0", sha256 = SHA_ONE }, + { id = "fill_two", source = "github", repo = "ren/fill-two", + version = "2.1.0", sha256 = SHA_TWO }, + }, + load_order = { "fill_one", "fill_two" } }) +install({ id = "odd_cart", title = "Odd Cart", shell = "#772244", + mods = { + { id = "banana_mod", source = "gamebanana", mod = 4821, file = 99123, + md5 = ("ab"):rep(16) }, + { id = "here_mod", source = "local", version = "0.0.0" }, + }, + load_order = { "banana_mod", "here_mod" } }) + +local function fillPage(cartId) + local page = freshLauncher() + page.tab = "red" + page.ready.red = true + page:_selectCart("red", cartId) + return page +end + +local function runFill(page) + page:pressInstallCartMods("red") + local guard = 0 + while page._cartFill and guard < 500 do + page:_pumpModInstall() + page:_pumpCartFill() + guard = guard + 1 + end + check(page._cartFill == nil, "the install run finishes") + return page.cartFillNotice or {} +end + +local function failureText(notice) + return table.concat(notice.failures or {}, " | ") +end + +net.versions.fill_one = { "1.0.0", "0.9.0" } +net.versions.fill_two = { "2.1.0" } +net.bytes["fill_one@1.0.0"] = SHA_ONE +net.bytes["fill_two@2.1.0"] = SHA_TWO + +-- A cart whose pins are all installed offers nothing to install. +haveMods = { fill_one = "1.0.0", fill_two = "2.1.0" } +local readyFill = fillPage("fill_cart") +eq(readyFill:cartPlan("red").refused, false, "every pin installed, so it plays") +eq(#readyFill:cartFillRows("red"), 0, "with nothing left to install") +local readyText = drawAndCapture(readyFill) +check(readyText:find("Install required mods", 1, true) == nil + and readyText:find("required mods", 1, true) == nil, + "so a ready cart never offers the install button") +check(readyText:find("Break the seal", 1, true) ~= nil, + "while the seal control is where it always was") + +-- Nothing installed: the button appears, named for the count, beside the seal. +haveMods = {} +local gapFill = fillPage("fill_cart") +eq(#gapFill:cartFillRows("red"), 2, "both uninstalled pins queue up") +local gapFillText = drawAndCapture(gapFill) +check(gapFillText:find("Install 2 required mods", 1, true) ~= nil, + "a refused cart offers to install what it pins, counted") +check(gapFillText:find("the way its author built it", 1, true) ~= nil, + "saying plainly that this is the way to play it as built") +check(gapFillText:find("Break the seal", 1, true) ~= nil, + "with breaking the seal still there as the fallback") + +-- A pin installed at the WRONG version is a gap too: the cart wants its own. +haveMods = { fill_one = "0.9.0", fill_two = "2.1.0" } +local skewFill = fillPage("fill_cart") +eq(skewFill:cartPlan("red").mismatched[1].id, "fill_one", + "a pin at another version is a mismatch") +eq(#skewFill:cartFillRows("red"), 1, "which queues for install like a gap") +eq(skewFill:cartFillRows("red")[1].version, "1.0.0", + "at the version the cart pins, not the one on disk") +local skewText = drawAndCapture(skewFill) +check(skewText:find("Install required mods", 1, true) ~= nil, + "a single gap drops the count from the label") + +-- THE HASH GATE. An archive that is not the one the cart recorded installs +-- nothing, however well the id and version line up. +haveMods = {} +net.bytes["fill_one@1.0.0"] = "some other build entirely" +local badHash = fillPage("fill_cart") +local badNotice = runFill(badHash) +eq(haveMods.fill_one, nil, "a hash mismatch installs nothing at all") +eq(badNotice.ok, false, "and the run reports as failed") +check(failureText(badNotice):find("fill_one", 1, true) ~= nil, + "naming the mod whose archive did not match") +check(failureText(badNotice):find("sha256", 1, true) ~= nil, + "and saying it was the hash: " .. failureText(badNotice)) +eq(badHash:cartPlan("red").refused, true, "so the cart still refuses") +net.bytes["fill_one@1.0.0"] = SHA_ONE + +-- Partial: one archive verifies, the other does not. +haveMods = {} +net.bytes["fill_two@2.1.0"] = "wrong bytes for the second mod" +local partial = fillPage("fill_cart") +local partialNotice = runFill(partial) +eq(haveMods.fill_one, "1.0.0", "the pin that verified is installed") +eq(haveMods.fill_two, nil, "the pin that did not is not") +eq(partialNotice.ok, false, "a partial run reads as a failure") +check(tostring(partialNotice.text):find("1 of 2", 1, true) ~= nil, + "counting what landed: " .. tostring(partialNotice.text)) +check(failureText(partialNotice):find("fill_two", 1, true) ~= nil, + "and naming only the mod that failed") +check(failureText(partialNotice):find("fill_one", 1, true) == nil, + "not the one that worked") +local partialText = drawAndCapture(partial) +check(partialText:find("fill_two", 1, true) ~= nil, + "the card reports the failure where the player pressed the button") +net.bytes["fill_two@2.1.0"] = SHA_TWO + +-- The whole run, verified, with the seal untouched at the end of it. +haveMods = {} +local good = fillPage("fill_cart") +local goodScope = good:slotScope("red") +good:_ensureSlots(goodScope) +local goodSlot = good.activeSlot[goodScope] +eq(good:cartPlan("red").refused, true, "the cart refuses before the run") +local goodNotice = runFill(good) +eq(goodNotice.ok, true, "the run reports success: " .. tostring(goodNotice.text)) +eq(haveMods.fill_one, "1.0.0", "the first pin lands at its pinned version") +eq(haveMods.fill_two, "2.1.0", "and the second at its own") +eq(net.repos.fill_one, "ren/fill-one", "each pin was looked up in its own repo") +eq(net.repos.fill_two, "ren/fill-two", "including the second") +eq(good:cartPlan("red").refused, false, + "and the cart is ready to play with no further presses") +eq(SaveData.slotSealBroken("fill_cart", goodSlot or "slot1"), false, + "installing never breaks the cart's seal") +eq(SaveData.isSealBroken(), false, "nor the session's") +local goodText = drawAndCapture(good) +check(goodText:find("Sealed", 1, true) ~= nil, + "the card flips to ready without another click") +check(goodText:find("required mods", 1, true) == nil, + "and stops offering the install button") + +-- No release carrying that version. +haveMods = {} +net.versions.fill_one = { "0.9.0" } +local noRel = fillPage("fill_cart") +local noRelNotice = runFill(noRel) +eq(haveMods.fill_one, nil, "a version the repo does not publish installs nothing") +check(failureText(noRelNotice):find("v1.0.0", 1, true) ~= nil, + "and the failure names the version it wanted: " .. failureText(noRelNotice)) +net.versions.fill_one = { "1.0.0", "0.9.0" } + +-- No .zip on the matching release. +haveMods = {} +local savedPump = ModUpdate.pumpFetchReleases +ModUpdate.pumpFetchReleases = function(h) + return true, { { version = "1.0.0", tag = "v1.0.0" }, + { version = "2.1.0", tag = "v2.1.0" } } +end +local noZip = fillPage("fill_cart") +local noZipNotice = runFill(noZip) +eq(haveMods.fill_one, nil, "a release with no archive installs nothing") +check(failureText(noZipNotice):find(".zip", 1, true) ~= nil, + "and says so: " .. failureText(noZipNotice)) +ModUpdate.pumpFetchReleases = savedPump + +-- Network down. +haveMods = {} +net.err = "no network transport on this platform" +local offline = fillPage("fill_cart") +local offlineNotice = runFill(offline) +eq(haveMods.fill_one, nil, "an offline run installs nothing") +eq(offlineNotice.ok, false, "and reports as failed") +check(failureText(offlineNotice):find("no network transport", 1, true) ~= nil, + "carrying the transport's own words: " .. failureText(offlineNotice)) +net.err = nil + +-- Sources the launcher cannot fetch are refused per mod, not as one silence. +haveMods = {} +local odd = fillPage("odd_cart") +eq(#odd:cartFillRows("red"), 2, "both odd pins are gaps") +local oddText = drawAndCapture(odd) +check(oddText:find("Install 2 required mods", 1, true) ~= nil, + "the button is offered even when a pin may not be fetchable") +local oddNotice = runFill(odd) +eq(oddNotice.ok, false, "neither can be installed") +eq(#(oddNotice.failures or {}), 2, "and each is reported on its own line") +check(failureText(oddNotice):find("banana_mod", 1, true) ~= nil + and failureText(oddNotice):find("GameBanana", 1, true) ~= nil, + "the gamebanana pin says what it is pinned to: " .. failureText(oddNotice)) +check(failureText(oddNotice):find("here_mod", 1, true) ~= nil + and failureText(oddNotice):find("nothing to download", 1, true) ~= nil, + "and the local pin says there is nothing to fetch") +eq(next(haveMods), nil, "with nothing installed either way") +local oddAfter = drawAndCapture(odd) +check(oddAfter:find("Break the seal", 1, true) ~= nil, + "and the seal is still the fallback it was") + +-- Breaking the seal still works exactly as it did, install button or not. +local sealStill = fillPage("odd_cart") +local oddScope = sealStill:slotScope("red") +sealStill:_newSlot(oddScope) +local oddSlot = sealStill.activeSlot[oddScope] +check(type(oddSlot) == "string", "the cart page has a loaded save slot") +eq(sealStill:pressBreakSeal("red"), false, "the first press still only arms") +eq(SaveData.slotSealBroken("odd_cart", oddSlot), false, "breaking nothing") +eq(sealStill:pressBreakSeal("red"), true, "and the second still breaks it") +eq(SaveData.slotSealBroken("odd_cart", oddSlot), true, "on that slot") +eq(sealStill:cartPlan("red").refused, false, "so the cart plays") + +love.data.hash, love.data.encode = savedData.hash, savedData.encode +ModUpdate.beginFetchReleases, ModUpdate.pumpFetchReleases = + realFetchBegin, realFetchPump +ModUpdate.beginDownloadZip, ModUpdate.pumpDownloadZip = + realDlBegin, realDlPump +LauncherMods.installDownloadedZip = realInstallDownloaded +-- Later blocks audit the picker's layout, which counts the carts on red. +CartStore.uninstall("fill_cart") +CartStore.uninstall("odd_cart") + +LauncherMods.list = realModList +window(1280, 720) + +-- ------- the MODS tab under an active cart + +-- the rows the base game's own list would hand back, so the panel with no +-- cart active has something real to disagree with the carts about +local PIN_MODS = { + fakeRow({ id = "pin_on", name = "Pin On", version = "1.0.0" }), + fakeRow({ id = "pin_off", name = "Pin Off", version = "1.0.0", + enabled = false, + enabledByVersion = { red = false, blue = false, yellow = false, + gold = false, silver = false } }), +} + +local function localPin(id, off) + local entry = { id = id, source = "local", version = "1.0.0" } + if off then entry.enabled = false end + return entry +end + +local PIN_SET = { localPin("pin_on"), localPin("pin_off", true) } +local PIN_ORDER = { "pin_on", "pin_off" } + +install({ id = "plus_mods", title = "Plus Mods", seal = "sealed+", + shell = "#2b8a3e", mods = PIN_SET, load_order = PIN_ORDER }) +install({ id = "hard_mods", title = "Hard Mods", seal = "sealed", + shell = "#8a2b3e", mods = PIN_SET, load_order = PIN_ORDER }) +install({ id = "gap_mods", title = "Gap Mods", seal = "sealed+", + shell = "#3e2b8a", + mods = { localPin("pin_on"), localPin("ghost_mod") }, + load_order = { "pin_on", "ghost_mod" } }) + +local realSetEnabled, realSetAllEnabled = LauncherMods.setEnabled, LauncherMods.setAllEnabled +local perGameWrites = 0 +LauncherMods.setEnabled = function(...) + perGameWrites = perGameWrites + 1 + return realSetEnabled(...) +end +LauncherMods.setAllEnabled = function(...) + perGameWrites = perGameWrites + 1 + return realSetAllEnabled(...) +end +LauncherMods.list = function() return PIN_MODS end + +-- the base game's own answer for the same mod, which nothing below may move +local seeded = SaveData.loadOptions() +SaveData.setModEnabled(seeded, "pin_off", false, "red") +SaveData.setModEnabled(seeded, "pin_on", true, "red") +SaveData.saveOptions(seeded) + +local function rowsById(imp) + local byId = {} + for _, row in ipairs(imp.mods or {}) do byId[row.id] = row end + return byId +end + +local plain = freshLauncher() +plain.tab = "mods" +plain.ready.red = true +plain:_selectCart("red", nil) +plain:_setModScope("red") +eq(#plain.mods, 2, "with no cart the panel lists the installed mods") +eq(plain.mods[1].cartPin, nil, "which are not marked as any cart's") +check(type(plain.mods[1].enabledByVersion) == "table", + "and still carry their per-game answers") +perGameWrites = 0 +plain:_toggleMod("pin_on", nil, "red") +eq(perGameWrites, 1, "a toggle with no cart still writes the per-game flag") +eq(SaveData.modEnabled(SaveData.loadOptions(), "pin_on", "red"), false, + "which is what changed") +eq(SaveData.cartModEnabled(SaveData.loadOptions(), "plus_mods", "pin_on"), nil, + "and no cart scope was touched") +SaveData.setModEnabled(seeded, "pin_on", true, "red") +SaveData.saveOptions(seeded) + +local pins = freshLauncher() +pins.tab = "mods" +pins.ready.red = true +pins:_selectCart("red", "plus_mods") +pins:_setModScope("red") +eq(#pins.mods, 2, "an active cart makes the panel list its pins") +local pinRows = rowsById(pins) +eq(pinRows.pin_on.cartPin, true, "each row is marked as the cart's") +eq(pinRows.pin_on.cartId, "plus_mods", "naming the cart it came from") +eq(pinRows.pin_on.enabled, true, "a pin shipped switched on shows on") +eq(pinRows.pin_off.enabled, false, "a pin shipped switched off shows off") +eq(pinRows.pin_off.cartTogglable, true, "sealed+ hands every pin's switch over") +eq(pinRows.pin_on.enabledByVersion, nil, + "and a cart row carries no per-game answer, because a cart is one game") + +local pinText = drawAndCapture(pins) +check(pinText:find("PINNED", 1, true) ~= nil, + "the list says on every row that these are the cart's mods") +check(pinText:find("Plus Mods", 1, true) ~= nil, "and names the cart above them") +check(pinText:find("In this cart:", 1, true) ~= nil, + "with a switch that answers the cart, not the game") + +perGameWrites = 0 +pins:_toggleMod("pin_off", nil, "red") +local afterOn = SaveData.loadOptions() +eq(SaveData.cartModEnabled(afterOn, "plus_mods", "pin_off"), true, + "a sealed+ toggle writes the player's answer into the cart's scope") +eq(perGameWrites, 0, "and never through the per-game path") +eq(SaveData.modEnabled(afterOn, "pin_off", "red"), false, + "so the base game's flag for that mod is untouched") +eq(SaveData.modEnabled(afterOn, "pin_on", "red"), true, "as is every other") +eq(rowsById(pins).pin_off.enabled, true, "the row follows the new answer") + +local pinScope = pins:slotScope("red") +pins:_ensureSlots(pinScope) +local pinSlot = pins.activeSlot[pinScope] +eq(SaveData.slotSealBroken("plus_mods", pinSlot or "slot1"), false, + "switching a sealed+ pin does not break the cart's seal") +eq(SaveData.isSealBroken(), false, "nor the session's") +eq(pins:cartPlan("red").broken, false, "and the plan still reads it as intact") + +pins:_toggleMod("pin_off", nil, "red") +eq(SaveData.cartModEnabled(SaveData.loadOptions(), "plus_mods", "pin_off"), false, + "pressing again switches it back off, still in the cart's scope") + +perGameWrites = 0 +pins:_setAllMods(true) +eq(perGameWrites, 0, "Enable all cannot reach a cart's mod set") +eq(SaveData.cartModEnabled(SaveData.loadOptions(), "plus_mods", "pin_off"), false, + "so no pin moved") +check(tostring(pins.modNotice.text):find("Plus Mods", 1, true) ~= nil, + "and the panel says which cart is deciding") +eq(pins.modNotice.ok, false, "as a refusal") +pins:_setAllMods(false) +eq(perGameWrites, 0, "Disable all cannot either") +eq(SaveData.modEnabled(SaveData.loadOptions(), "pin_on", "red"), true, + "and the base game's list is where it was") + +pins:_toggleMod("wide_gym", nil, "red") +check(tostring(pins.modNotice.text):find("cannot be added to", 1, true) ~= nil, + "a mod the cart does not pin cannot be switched on from here") +eq(SaveData.cartModEnabled(SaveData.loadOptions(), "plus_mods", "wide_gym"), nil, + "and nothing is written for it") + +pins.safeMode = true +pins:_toggleMod("pin_off", nil, "red") +check(tostring(pins.modNotice.text):find("Safe mode", 1, true) ~= nil, + "safe mode still refuses a cart toggle first") +eq(SaveData.cartModEnabled(SaveData.loadOptions(), "plus_mods", "pin_off"), false, + "and writes nothing") +pins.safeMode = false + +local sealedPins = freshLauncher() +sealedPins.tab = "mods" +sealedPins.ready.red = true +sealedPins:_selectCart("red", "hard_mods") +sealedPins:_setModScope("red") +local hardRows = rowsById(sealedPins) +eq(hardRows.pin_off.enabled, false, "a sealed cart's off pin shows off") +eq(hardRows.pin_off.cartTogglable, false, "and hands no switch over") +eq(hardRows.pin_on.cartTogglable, false, "nor does the pin it ships on") +perGameWrites = 0 +sealedPins:_toggleMod("pin_off", nil, "red") +eq(perGameWrites, 0, "a sealed pin never reaches the per-game path either") +eq(SaveData.cartModEnabled(SaveData.loadOptions(), "hard_mods", "pin_off"), nil, + "and a press under a sealed cart writes nothing") +eq(sealedPins.modNotice.ok, false, "the press is refused") +check(tostring(sealedPins.modNotice.text):find("sealed", 1, true) ~= nil, + "with the panel saying why rather than doing nothing") +check(tostring(sealedPins.modNotice.text):find("Break the seal", 1, true) ~= nil, + "and pointing at the one way to change it") +local hardScope = sealedPins:slotScope("red") +sealedPins:_ensureSlots(hardScope) +eq(SaveData.slotSealBroken("hard_mods", + sealedPins.activeSlot[hardScope] or "slot1"), false, + "a refused press breaks no seal of its own") +local hardText = drawAndCapture(sealedPins) +check(hardText:find("Pinned, sealed:", 1, true) ~= nil, + "and the row itself reads as locked") + +local gapPins = freshLauncher() +gapPins.tab = "mods" +gapPins.ready.red = true +gapPins:_selectCart("red", "gap_mods") +gapPins:_setModScope("red") +local gapRows = rowsById(gapPins) +eq(#gapPins.mods, 2, "a pin that is not installed is still listed") +eq(gapRows.ghost_mod.status, "missing", "as missing") +eq(gapRows.ghost_mod.enabled, false, "and switched off") +gapPins:_toggleMod("ghost_mod", nil, "red") +check(tostring(gapPins.modNotice.text):find("not installed", 1, true) ~= nil, + "switching it says so instead of writing an answer for a mod that is absent") +eq(SaveData.cartModEnabled(SaveData.loadOptions(), "gap_mods", "ghost_mod"), nil, + "and writes nothing") + +pins:_selectCart("red", nil) +pins:_setModScope("red") +local backRows = rowsById(pins) +eq(#pins.mods, 2, "choosing the base game lists the player's own mods again") +eq(backRows.pin_on.cartPin, nil, "with no cart marks left on them") +check(type(backRows.pin_on.enabledByVersion) == "table", + "and their per-game answers back") +eq(backRows.pin_off.enabled, false, + "reading exactly what the base game's flags say") +perGameWrites = 0 +pins:_toggleMod("pin_off", nil, "red") +eq(perGameWrites, 1, "and a toggle is a per-game write once more") +eq(SaveData.modEnabled(SaveData.loadOptions(), "pin_off", "red"), true, + "which lands in the game's own flags") +eq(SaveData.cartModEnabled(SaveData.loadOptions(), "plus_mods", "pin_off"), false, + "leaving the cart's answer where the player left it") + +LauncherMods.setEnabled, LauncherMods.setAllEnabled = realSetEnabled, realSetAllEnabled +LauncherMods.list = realModList + +local function clipped(r) + local x1, y1, x2, y2 = r.x, r.y, r.x + r.w, r.y + r.h + if r.clip then + x1 = math.max(x1, r.clip.x); y1 = math.max(y1, r.clip.y) + x2 = math.min(x2, r.clip.x + r.clip.w); y2 = math.min(y2, r.clip.y + r.clip.h) + end + if x2 - x1 <= 1 or y2 - y1 <= 1 then return nil end + return x1, y1, x2, y2 +end + +local function overlap(a, b) + local ax1, ay1, ax2, ay2 = clipped(a) + if not ax1 then return false end + local bx1, by1, bx2, by2 = clipped(b) + if not bx1 then return false end + return math.min(ax2, bx2) - math.max(ax1, bx1) > 1 + and math.min(ay2, by2) - math.max(ay1, by1) > 1 +end + +local function auditFrame(label, want) + local controls, found = {}, false + for _, r in ipairs(Kit.audit or {}) do + if r.class == "control" then + controls[#controls + 1] = r + if want and tostring(r.label):find(want, 1, true) then found = true end + end + end + check(#controls > 0, label .. ": the frame dispatched controls at all") + local collisions = 0 + for i = 1, #controls do + for j = i + 1, #controls do + if overlap(controls[i], controls[j]) then + collisions = collisions + 1 + print((" overlap: '%s' vs '%s' at (%.0f,%.0f) / (%.0f,%.0f)") + :format(tostring(controls[i].label), tostring(controls[j].label), + controls[i].x, controls[i].y, controls[j].x, controls[j].y)) + end + end + end + check(collisions == 0, label .. ": no two controls overlap") + if want then check(found, label .. ": drew " .. want) end +end + +local SIZES = { + { 360, 780 }, { 412, 915 }, { 480, 900 }, { 720, 1280 }, + { 1280, 720 }, { 1024, 768 }, { 900, 700 }, { 1920, 1080 }, +} + +for _, size in ipairs(SIZES) do + local W, H = size[1], size[2] + window(W, H) + for _, cart in ipairs({ false, true }) do + local page = freshLauncher() + page.tab = "red" + page.ready.red = true + page:_selectCart("red", cart and "kanto_plus" or nil) + LauncherView.draw(page) + Kit.audit = {} + local ok, err = pcall(LauncherView.draw, page) + Kit.audit = ok and Kit.audit or nil + check(ok, ("%dx%d %s draws: %s") + :format(W, H, cart and "cart" or "vanilla", tostring(err))) + if ok then + auditFrame(("%dx%d %s"):format(W, H, cart and "cart" or "vanilla"), + "Custom Carts") + -- The refused card carries two chips now, so both have to stay on it. + if cart then + local sawFill = false + for _, r in ipairs(Kit.audit or {}) do + local label = tostring(r.label) + if label == "Install required mods" or label == "Break the seal" then + sawFill = sawFill or label == "Install required mods" + check(r.x >= -0.5 and r.x + r.w <= W + 0.5 + and r.y >= -0.5 and r.y + r.h <= H + 0.5, + ("%dx%d cart card: %q stays inside the window") + :format(W, H, label)) + end + end + check(sawFill, + ("%dx%d cart card: drew Install required mods"):format(W, H)) + end + end + Kit.audit = nil + + page._cartPopup = "red" + if cart then + page._cartNotice = "Browsing for carts arrives in a later update." + end + LauncherView.draw(page) + Kit.audit = {} + ok, err = pcall(LauncherView.draw, page) + Kit.audit = ok and Kit.audit or nil + check(ok, ("%dx%d picker draws: %s"):format(W, H, tostring(err))) + if ok then + auditFrame(("%dx%d picker"):format(W, H), "Kanto Plus") + for _, r in ipairs(Kit.audit or {}) do + local label = tostring(r.label) + if label == "Get more carts" or label == "Close" then + check(r.y >= -0.5 and r.y + r.h <= H + 0.5, + ("%dx%d picker: %q stays inside the window"):format(W, H, label)) + end + end + end + Kit.audit = nil + end +end + +LauncherMods.list = function() return FAKE_MODS end +for _, size in ipairs(SIZES) do + local W, H = size[1], size[2] + window(W, H) + local form = freshLauncher() + form.tab = "mods" + form.ready.red = true + form:_setModScope("red") + form:_beginCartSave("red") + form._cartSave.text = "Soda Run" + form:_commitCartSave() + check(form._cartSave ~= nil, + ("%dx%d save form stays open on a colliding title"):format(W, H)) + LauncherView.draw(form) + Kit.audit = {} + local ok, err = pcall(LauncherView.draw, form) + Kit.audit = ok and Kit.audit or nil + check(ok, ("%dx%d save form draws: %s"):format(W, H, tostring(err))) + if ok then + auditFrame(("%dx%d save form"):format(W, H), "Save as cart") + for _, r in ipairs(Kit.audit or {}) do + if tostring(r.label) == "Cancel" then + check(r.y >= -0.5 and r.y + r.h <= H + 0.5, + ("%dx%d save form: Cancel stays inside the window"):format(W, H)) + end + end + end + Kit.audit = nil +end +LauncherMods.list = function() return PIN_MODS end +for _, size in ipairs(SIZES) do + local W, H = size[1], size[2] + window(W, H) + for _, cart in ipairs({ "plus_mods", "hard_mods" }) do + local panel = freshLauncher() + panel.tab = "mods" + panel.ready.red = true + panel:_selectCart("red", cart) + panel:_setModScope("red") + LauncherView.draw(panel) + Kit.audit = {} + local ok, err = pcall(LauncherView.draw, panel) + Kit.audit = ok and Kit.audit or nil + check(ok, ("%dx%d %s mods draws: %s"):format(W, H, cart, tostring(err))) + if ok then auditFrame(("%dx%d %s mods"):format(W, H, cart)) end + Kit.audit = nil + end +end +LauncherMods.list = realModList + +-- ------- FIND tab: browsing the index's carts instead of its mods +-- +-- The feed carries carts beside mods at the same schema_version, so the panel +-- has a Mods / Carts switch. Installing a cart from a listing goes through +-- CartStore.install with the downloaded bytes, never the mod installer. + +local ModIndex = require("src.mods.ModIndex") +local ModUpdate = require("src.mods.ModUpdate") +local Json = require("src.link.Json") + +local INDEX_CART = { + id = "indexed_cart", title = "Indexed Cart", author = "Ren", + version = "1.4.0", base = "silver", seal = "sealed", + repo = "https://github.com/ren/indexed-cart", + github = "ren/indexed-cart", + mods = { { id = "rare_soda", source = "github", repo = "ren/rare-soda", + version = "0.4.1", sha256 = SHA } }, + update_check = "ok", + latest = { version = "1.4.0", tag = "v1.4.0", + zip = { name = "indexed_cart-1.4.0.zip", + url = "https://example.test/indexed_cart-1.4.0.zip" } }, +} + +local INDEX_FEED = ModIndex.parse(Json.encode({ + schema_version = 1, + categories = { "GAMEPLAY" }, + base_games = { "red", "blue", "yellow", "gold", "silver" }, + mods = { + { id = "rare_soda", title = "Rare Soda", author = "Ren", version = "0.4.1", + categories = { "GAMEPLAY" }, update_check = "off" }, + { id = "true_colour", title = "True Colour", author = "Sam", + version = "1.0.0", categories = { "ART" }, update_check = "off" }, + }, + carts = { + INDEX_CART, + { id = "gold_rush", title = "Gold Rush", author = "Sam", version = "3.0.0", + base = "gold", seal = "open", repo = "https://github.com/sam/gold-rush", + mods = { { id = "steps", source = "github", repo = "sam/steps", + version = "1.0.0", sha256 = SHA } }, + update_check = "off" }, + }, +})) +check(INDEX_FEED ~= nil, "the find-tab fixture feed parses") + +window(1280, 720) + +local find = freshLauncher() +eq(find.findKind, "mods", "the Find tab browses mods by default") +check(find.findBase == nil, "with no base-game filter armed") +find.tab = "find" +find.modScope = nil +find.findLoaded = true +find.findSources = { { feed = "https://example.test/data/index.json", + base = "https://example.test/", + label = "example/index" } } +find.findIndex = { mods = INDEX_FEED.mods, carts = INDEX_FEED.carts, + categories = { "GAMEPLAY", "ART" }, + baseGames = ModIndex.baseGamesIn(INDEX_FEED) } + +eq(#find:_findRows(), 2, "the default rows are the feed's mods") +eq(find:_findRows()[1].id, "rare_soda", "and they are the mod entries") + +local modsFind = drawAndCapture(find) +check(modsFind:find("Mods (2)", 1, true) ~= nil, + "the switch says how many mods the feed lists") +check(modsFind:find("Carts (2)", 1, true) ~= nil, "and how many carts") +check(modsFind:find("Rare Soda", 1, true) ~= nil, "mods are what is listed") + +find:_setFindKind("carts") +eq(find.findKind, "carts", "the switch flips to carts") +eq(#find:_findRows(), 2, "and the rows become the feed's carts") +check(ModIndex.isCart(find:_findRows()[1]), "which are cart entries") + +local cartsFind = drawAndCapture(find) +check(cartsFind:find("Indexed Cart", 1, true) ~= nil, + "a cart listing is drawn on the Carts half") +check(cartsFind:find("Rare Soda", 1, true) == nil, + "and the mod listings are gone") +check(cartsFind:find("Search carts", 1, true) ~= nil, + "the search field asks for carts") + +-- search spans the same fields it does for a mod +find.findQuery = "gold" +eq(#find:_findRows(), 1, "searching a cart title narrows the list") +eq(find:_findRows()[1].id, "gold_rush", "to that cart") +find.findQuery = "Ren" +eq(find:_findRows()[1].id, "indexed_cart", "searching by author works too") +find.findQuery = "" + +-- the cart-side filter is by base game, and the popup offers only the base +-- games the feed's carts actually play as +find.findBase = "gold" +eq(#find:_findRows(), 1, "filtering by base game keeps that game's carts") +eq(find:_findRows()[1].id, "gold_rush", "and only those") +local filterText +do + find._filterPopup = true + filterText = drawAndCapture(find) + find._filterPopup = nil +end +check(filterText:find("Filter by base game", 1, true) ~= nil, + "the filter popup filters carts by base game") +check(filterText:find("Filter by category", 1, true) == nil, + "not by a category no cart has") +find.findBase = nil + +-- MODS-tab scope still applies: a cart plays as exactly one game +find.modScope = "silver" +eq(#find:_findRows(), 1, "a scoped launcher lists only that game's carts") +eq(find:_findRows()[1].id, "indexed_cart", "the one based on silver") +find.modScope = nil + +find:_setFindKind("mods") +eq(#find:_findRows(), 2, "switching back restores the mod rows") +find:_setFindKind("carts") + +-- ------- installing a cart from a listing + +local INDEX_CART_BYTES = CartManifest.encode(CartManifest.parse(cartTable({ + id = "indexed_cart", title = "Indexed Cart", version = "1.4.0", + base = "silver", shell = "#d8c24a" }))) + +eq(#find:_ensureCarts("silver"), 0, "silver has no carts before the install") + +local entry +for _, row in ipairs(find:_findRows()) do + if row.id == "indexed_cart" then entry = row end +end +check(entry ~= nil, "the cart listing is on screen to install") + +local realBegin, realPump = ModUpdate.beginDownloadZip, ModUpdate.pumpDownloadZip +local asked = {} +ModUpdate.beginDownloadZip = function(url, name) + asked[#asked + 1] = { url = url, name = name } + return { name = name } +end +ModUpdate.pumpDownloadZip = function(h) + love.filesystem.write(h.name, INDEX_CART_BYTES) + return true, h.name +end + +find:_findConfirmInstall(entry) +check(find._modConfirm ~= nil, "installing a cart arms a confirm first") +eq(find._modConfirm.title, "Install cart", "and it says it is a cart") +eq(find._modConfirm.indexEntry, entry, "carrying the listing it will install") +do + local lines = table.concat(find._modConfirm.lines or {}, "\n") + check(lines:find("Pins 1 mod", 1, true) ~= nil, + "the confirm says how many mods the cart pins") + check(lines:find("installed separately", 1, true) ~= nil, + "and that installing the cart does not install them") +end +find._modConfirm = nil + +find:_findInstall(entry) +check(find._cartInstall ~= nil, "the cart download is in flight") +check(find._modInstall == nil, "and it is not a mod install") +eq(asked[1].url, "https://example.test/indexed_cart-1.4.0.zip", + "the release the listing resolves is what gets downloaded") +check(asked[1].name:find(".g1rcart", 1, true) ~= nil, + "into a cart file, not a mod zip") + +-- single in flight: a mod install cannot start on top of a cart download +find:_beginModInstall({ modId = "rare_soda", name = "Rare Soda", notice = "find", + release = { version = "0.4.1", + zip = { url = "https://example.test/rare_soda.zip" } } }) +check(find._modInstall == nil, "a mod install cannot race a cart install") +eq(#asked, 1, "and nothing else was downloaded") + +find:_pumpCartInstall() +check(find._cartInstall == nil, "the cart install completes") +check(find.findNotice ~= nil and find.findNotice.ok, + "and reports success on the Find tab") + +local installedCart = CartStore.get("indexed_cart") +check(installedCart ~= nil, "the cart is installed through CartStore") +if installedCart then + eq(installedCart.title, "Indexed Cart", "with its own title") + eq(installedCart.base, "silver", "and the game it plays as") +end + +-- the per-version cache is the whole point: without invalidating it the new +-- cart would not show in Custom Carts until a relaunch +eq(#find.carts["silver"], 1, + "the cached cart list for that game is refreshed in place") +eq(find.carts["silver"][1].id, "indexed_cart", "with the new cart in it") +eq(find:_findInstalledCarts()["indexed_cart"], "1.4.0", + "and the Find rows now read it as installed") + +-- the Custom Carts picker draws the new cart in the same session, with no +-- refresh call from here and no relaunch +do + local picker = freshLauncher() + picker.tab = "silver" + picker.ready.silver = true + picker._cartPopup = "silver" + local pickerText = drawAndCapture(picker) + check(pickerText:find("Indexed Cart", 1, true) ~= nil, + "the Custom Carts picker lists the freshly installed cart") +end +do + local same = drawAndCapture(find) + check(same ~= nil, "the Find tab still draws after an install") +end + +-- ------- the pins prompt +-- +-- Installing a cart never installs its mods behind the player's back, but a +-- cart whose pins are missing will not start, so it asks once and routes a +-- yes at the existing hash-verified fill queue. + +check(find._modConfirm ~= nil, "a cart with missing pins prompts after install") +eq(find._modConfirm.kind, "cartPins", "through the launcher's confirm modal") +eq(find._modConfirm.version, "silver", "for the game the cart plays as") +eq(find._modConfirm.id, "indexed_cart", "naming the cart just installed") +do + local lines = table.concat(find._modConfirm.lines or {}, "\n") + check(lines:find("Indexed Cart pins 1 mod", 1, true) ~= nil, + "the prompt names the cart and how many pins are missing") +end +check(find.activeCart["silver"] == nil, + "and asking does not select the cart on its own") + +-- yes routes at pressInstallCartMods, which owns the hash check +do + local realFetchRel = ModUpdate.beginFetchReleases + local realPumpRel = ModUpdate.pumpFetchReleases + local asked_repos = {} + ModUpdate.beginFetchReleases = function(repo) + asked_repos[#asked_repos + 1] = repo + return { repo = repo } + end + ModUpdate.pumpFetchReleases = function() return true, nil, "no releases" end + find._modConfirm = nil + find:_installCartPins("silver", "indexed_cart") + eq(find.activeCart["silver"], "indexed_cart", + "saying yes selects the cart the pins belong to") + for _ = 1, 8 do find:_pumpCartFill() end + eq(asked_repos[1], "ren/rare-soda", + "and the fill queue resolves the cart's own pin") + check(find.cartFillNotice ~= nil and not find.cartFillNotice.ok, + "reporting per-mod failures through the existing notice") + find:_selectCart("silver", nil) + find.cartFillNotice = nil + ModUpdate.beginFetchReleases = realFetchRel + ModUpdate.pumpFetchReleases = realPumpRel +end + +-- a cart whose pins are all installed must not prompt at all +do + local realList = LauncherMods.list + LauncherMods.list = function() + return { { id = "rare_soda", version = "0.4.1", + manifest = { id = "rare_soda", version = "0.4.1" } } } + end + local satisfied = freshLauncher() + eq(#satisfied:_cartPinsMissing("silver", "indexed_cart"), 0, + "a cart with every pin installed is missing none") + satisfied:_offerCartPins({ id = "indexed_cart", title = "Indexed Cart", + base = "silver" }) + check(satisfied._modConfirm == nil, "so it does not prompt") + LauncherMods.list = realList +end + +-- a download that is not a cart at all fails loudly instead of installing +ModUpdate.pumpDownloadZip = function(h) + love.filesystem.write(h.name, "not a cart at all") + return true, h.name +end +find.findNotice = nil +find:_findInstall(entry) +find:_pumpCartInstall() +check(find._cartInstall == nil, "a junk download ends the job") +check(find.findNotice ~= nil and not find.findNotice.ok, + "and says so rather than installing anything") + +ModUpdate.beginDownloadZip, ModUpdate.pumpDownloadZip = realBegin, realPump + +T.finish("cart launcher") diff --git a/tests/engine/cart_manifest.lua b/tests/engine/cart_manifest.lua new file mode 100644 index 00000000..177ff024 --- /dev/null +++ b/tests/engine/cart_manifest.lua @@ -0,0 +1,507 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +if not rawget(_G, "bit") and not rawget(_G, "bit32") then + local ok, bit32 = pcall(require, "bit32") + if ok then _G.bit32 = bit32 end +end + +local T = require("tests.harness") +local Base64 = require("src.core.Base64") +local CartManifest = require("src.carts.CartManifest") +local SaveSerializer = require("src.core.SaveSerializer") + +local SHA = ("a1b2c3d4"):rep(8) +local MD5 = ("0123456789abcdef"):rep(2) + +local function baseCart() + return { + id = "kanto_plus", + title = " Kanto Plus ", + version = "1.2.0", + author = "Ren", + repo = "ren/kanto-plus", + summary = "A sealed set of five", + shell = "#3FA9F5", + label = "./art/label.png", + base = "red", + engine = ">=1.4.0", + mods = { + { id = "rare_soda", source = "github", repo = "ren/rare-soda", + version = "0.4.1", sha256 = SHA, + options = { sweetness = 3, flavour = "grape", fizzy = true } }, + { id = "hard_mode", source = "gamebanana", mod = 4821, file = 99123, + md5 = MD5 }, + }, + } +end + +local function rejects(mutate, fragment, what) + local tbl = baseCart() + mutate(tbl) + local cart, err = CartManifest.parse(tbl) + T.eq(cart, nil, what .. " is rejected") + T.check(type(err) == "string" and err:find(fragment, 1, true) ~= nil, + ("%s says why (got %s)"):format(what, tostring(err))) +end + +local raw = baseCart() +local cart, err = CartManifest.parse(raw) +T.check(cart ~= nil, "a good manifest parses: " .. tostring(err)) +T.eq(cart.title, "Kanto Plus", "title is trimmed") +T.eq(cart.shell, "#3fa9f5", "shell normalises to lowercase") +T.eq(cart.label, "art/label.png", "label normalises through SafePath") +T.eq(cart.seal, "sealed", "seal defaults to sealed") +T.eq(cart.base, "red", "base survives") +T.eq(cart.engine, ">=1.4.0", "engine range is kept unevaluated") +T.eq(#cart.mods, 2, "both pins survive") +T.eq(cart.mods[1].sha256, SHA, "the github pin keeps its sha256") +T.eq(cart.mods[1].repo, "ren/rare-soda", "the github pin keeps its repo") +T.eq(cart.mods[1].version, "0.4.1", "the github pin keeps its version") +T.eq(cart.mods[1].options.flavour, "grape", "frozen option values survive") +T.eq(cart.mods[2].source, "gamebanana", "the gamebanana pin keeps its source") +T.eq(cart.mods[2].mod, 4821, "the gamebanana pin keeps its mod id") +T.eq(cart.mods[2].file, 99123, "the gamebanana pin keeps its file id") +T.eq(cart.mods[2].md5, MD5, "the gamebanana pin keeps its md5") +T.eq(cart.mods[2].sha256, nil, "a gamebanana pin carries no sha256") +T.eq(cart.load_order[1], "rare_soda", "load_order defaults to the mods order") +T.eq(cart.load_order[2], "hard_mode", "load_order defaults to the mods order") + +T.eq(raw.title, " Kanto Plus ", "parse does not trim the input in place") +T.eq(raw.shell, "#3FA9F5", "parse does not recolour the input in place") +T.eq(raw.load_order, nil, "parse does not add load_order to the input") +T.neq(cart.mods, raw.mods, "the parsed mods array is a fresh table") +T.neq(cart.mods[1].options, raw.mods[1].options, "options are copied") + +local again = CartManifest.parse(baseCart()) +T.eq(CartManifest.canonical(again), CartManifest.canonical(cart), + "two independent parses encode identically") +T.eq(CartManifest.hash(again), CartManifest.hash(cart), + "two independent parses hash identically") +T.eq(#CartManifest.hash(cart), 32, "the cart hash is an MD5 hex digest") + +local bumped = baseCart() +bumped.mods[1].version = "0.4.2" +T.neq(CartManifest.hash(CartManifest.parse(bumped)), CartManifest.hash(cart), + "a bumped mod version moves the cart hash") + +local retuned = baseCart() +retuned.mods[1].options.sweetness = 4 +T.neq(CartManifest.hash(CartManifest.parse(retuned)), CartManifest.hash(cart), + "a changed option value moves the cart hash") + +local reordered = baseCart() +reordered.load_order = { "hard_mode", "rare_soda" } +local reorderedCart = CartManifest.parse(reordered) +T.eq(reorderedCart.load_order[1], "hard_mode", "an explicit load_order is kept") +T.neq(CartManifest.hash(reorderedCart), CartManifest.hash(cart), + "a different load order moves the cart hash") + +local encoded = CartManifest.encode(cart) +local decoded, decodeErr = CartManifest.decode(encoded) +T.check(decoded ~= nil, "an encoded cart decodes: " .. tostring(decodeErr)) +T.same(decoded, cart, "the round trip is lossless") +T.eq(CartManifest.hash(decoded), CartManifest.hash(cart), + "the round trip keeps the cart hash") + +T.eq(CartManifest.decode(nil), nil, "decode refuses a non-string") +T.eq(CartManifest.decode(""), nil, "decode refuses an empty file") +T.eq(CartManifest.decode("return { }"), nil, "decode refuses an untagged file") +T.eq(CartManifest.decode('return { format = "g1rmodlist" }'), nil, + "decode refuses another format's file") +T.eq(CartManifest.decode( + ('return { format = "%s", formatVersion = 99, cart = {} }') + :format(CartManifest.FORMAT)), nil, "decode refuses an unknown schema") +T.eq(CartManifest.decode('return os.exit(1)'), nil, + "decode refuses a file that tries to call out") +T.eq(CartManifest.decode( + ('return { format = "%s", formatVersion = 1, cart = { id = "x" } }') + :format(CartManifest.FORMAT)), nil, "decode validates the cart it carries") + +rejects(function(c) c.id = nil end, "cart id", "a missing id") +rejects(function(c) c.id = "kanto plus" end, "cart id", "an id with a space") +rejects(function(c) c.id = ("k"):rep(65) end, "cart id", "a 65 character id") +rejects(function(c) c.title = nil end, "cart title", "a missing title") +rejects(function(c) c.title = " " end, "cart title", "a blank title") +rejects(function(c) c.title = ("T"):rep(49) end, "cart title", "a 49 character title") +rejects(function(c) c.version = nil end, "cart version", "a missing version") +rejects(function(c) c.version = "one" end, "cart version", "a non-semver version") +rejects(function(c) c.author = nil end, "cart author", "a missing author") +rejects(function(c) c.author = "" end, "cart author", "an empty author") +rejects(function(c) c.author = ("A"):rep(65) end, "cart author", "a 65 character author") +rejects(function(c) c.repo = "ren" end, "cart repo", "a repo with no owner") +rejects(function(c) c.repo = "ren/kanto/plus" end, "cart repo", "a three part repo") +rejects(function(c) c.summary = ("s"):rep(121) end, "cart summary", "a 121 character summary") +rejects(function(c) c.shell = nil end, "cart shell", "a missing shell colour") +rejects(function(c) c.shell = "3FA9F5" end, "cart shell", "a shell colour with no hash") +rejects(function(c) c.shell = "#3FA9F" end, "cart shell", "a five digit shell colour") +rejects(function(c) c.shell = "#gggggg" end, "cart shell", "a non-hex shell colour") +rejects(function(c) c.label = "../../etc/passwd" end, "cart label", "a climbing label path") +rejects(function(c) c.label = "/etc/passwd" end, "cart label", "an absolute label path") +rejects(function(c) c.label = ("a"):rep(129) end, "cart label", "a 129 character label path") +rejects(function(c) c.base = nil end, "cart base", "a missing base game") +rejects(function(c) c.base = "nonesuch" end, "cart base", "an unknown base game") +rejects(function(c) c.engine = "" end, "cart engine", "an empty engine range") +rejects(function(c) c.engine = 3 end, "cart engine", "a numeric engine range") +rejects(function(c) c.seal = "welded" end, "cart seal", "an unknown seal") +rejects(function(c) c.mods = nil end, "cart mods", "a cart with no mods array") +rejects(function(c) c.mods = {} end, "cart must pin", "a cart that pins nothing") +rejects(function(c) + for i = 1, 65 do + c.mods[i] = { id = "mod" .. i, source = "gamebanana", mod = i, file = i, md5 = MD5 } + end +end, "cart must pin", "a cart that pins 65 mods") + +rejects(function(c) c.mods[1] = "rare_soda" end, "must be a table", "a string mod entry") +rejects(function(c) c.mods[1].id = nil end, "id must be", "a pin with no id") +rejects(function(c) c.mods[1].id = "rare soda" end, "id must be", "a pin id with a space") +rejects(function(c) c.mods[2].id = "rare_soda" end, "pinned twice", "the same mod pinned twice") +rejects(function(c) c.mods[1].source = nil end, "source must be", "a pin with no source") +rejects(function(c) c.mods[1].source = "dropbox" end, "source must be", "a pin from an unknown source") +rejects(function(c) c.mods[1].repo = nil end, "repo must be", "a github pin with no repo") +rejects(function(c) c.mods[1].version = "latest" end, "version must be", "a github pin with no semver") +rejects(function(c) c.mods[1].sha256 = nil end, "sha256", "a github pin with no sha256") +rejects(function(c) c.mods[1].sha256 = SHA:upper() end, "sha256", "an uppercase sha256") +rejects(function(c) c.mods[1].sha256 = SHA:sub(1, 63) end, "sha256", "a short sha256") +rejects(function(c) c.mods[2].mod = nil end, "mod must be", "a gamebanana pin with no mod id") +rejects(function(c) c.mods[2].mod = 0 end, "mod must be", "a gamebanana mod id of zero") +rejects(function(c) c.mods[2].mod = 12.5 end, "mod must be", "a fractional gamebanana mod id") +rejects(function(c) c.mods[2].file = nil end, "file must be", "a gamebanana pin with no file id") +rejects(function(c) c.mods[2].file = -3 end, "file must be", "a negative gamebanana file id") +rejects(function(c) c.mods[2].md5 = nil end, "md5", "a gamebanana pin with no md5") +rejects(function(c) c.mods[2].md5 = MD5:upper() end, "md5", "an uppercase md5") +rejects(function(c) c.mods[2].md5 = MD5 .. "00" end, "md5", "an overlong md5") + +rejects(function(c) c.mods[1].options = "grape" end, "options must be a table", + "a non-table options block") +rejects(function(c) c.mods[1].options = { [("k"):rep(65)] = 1 } end, + "option keys", "a 65 character option key") +rejects(function(c) c.mods[1].options = { [1] = "grape" } end, + "option keys", "a numeric option key") +rejects(function(c) c.mods[1].options.nested = { 1, 2 } end, + "must be a string, number or boolean", "a table option value") +rejects(function(c) c.mods[1].options.flavour = ("g"):rep(257) end, + "characters or fewer", "a 257 character option value") +rejects(function(c) + local options = {} + for i = 1, 65 do options["opt" .. i] = i end + c.mods[1].options = options +end, "more than 64 options", "a pin with 65 options") + +rejects(function(c) c.load_order = "rare_soda" end, "load_order must be an array", + "a string load_order") +rejects(function(c) c.load_order = { "rare_soda" } end, "exactly once", + "a load_order that drops a mod") +rejects(function(c) c.load_order = { "rare_soda", "hard_mode", "hard_mode" } end, + "exactly once", "a load_order longer than the mods array") +rejects(function(c) c.load_order = { "rare_soda", "rare_soda" } end, "twice", + "a load_order that repeats a mod") +rejects(function(c) c.load_order = { "rare_soda", "master_ball" } end, + "does not pin", "a load_order naming an unpinned mod") + +rejects(function(c) c.mods[1].enabled = "no" end, "enabled must be", + "a string enabled flag") +rejects(function(c) c.mods[1].enabled = 0 end, "enabled must be", + "a numeric enabled flag") + +rejects(function(c) c.options = "fast" end, "cart options must be a table", + "a non-table cart options block") +rejects(function(c) c.options = { [2] = 1 } end, "cart option keys", + "a numeric cart option key") +rejects(function(c) c.options = { textSpeed = { 1 } } end, + "must be a string, number or boolean", "a table cart option value") + +T.eq(CartManifest.parse(nil), nil, "parse refuses a non-table") + +-- ------- a pin the cart ships switched off + +T.eq(cart.mods[1].enabled, nil, "a pin that says nothing carries no enabled flag") +T.eq(CartManifest.modEnabled(cart.mods[1]), true, "and defaults to enabled") +T.eq(CartManifest.modEnabled(nil), false, "modEnabled refuses a non-entry") + +local offRaw = baseCart() +offRaw.mods[1].enabled = false +local offCart = CartManifest.parse(offRaw) +T.check(offCart ~= nil, "a pin switched off parses") +T.eq(offCart.mods[1].enabled, false, "and keeps the flag") +T.eq(CartManifest.modEnabled(offCart.mods[1]), false, "which reads back as off") +T.eq(offCart.mods[1].sha256, SHA, "a switched-off pin is pinned like any other") +T.eq(offCart.mods[1].options.flavour, "grape", "with its options captured") +T.same(CartManifest.decode(CartManifest.encode(offCart)), offCart, + "a switched-off pin survives the file round trip") +T.neq(CartManifest.hash(offCart), CartManifest.hash(cart), + "and is part of the cart hash") + +local onRaw = baseCart() +onRaw.mods[1].enabled = true +T.eq(CartManifest.canonical(CartManifest.parse(onRaw)), + CartManifest.canonical(cart), + "an explicit enabled = true is the same cart as saying nothing") + +-- ------- settings the cart ships + +local optRaw = baseCart() +optRaw.options = { textSpeed = 1, animations = false, ruleset = "gen1_faithful" } +local optCart, optErr = CartManifest.parse(optRaw) +T.check(optCart ~= nil, "a cart that ships settings parses: " .. tostring(optErr)) +T.eq(optCart.options.textSpeed, 1, "the shipped number survives") +T.eq(optCart.options.animations, false, "the shipped boolean survives") +T.eq(optCart.options.ruleset, "gen1_faithful", "the shipped string survives") +T.eq(cart.options, nil, "a cart that ships none carries no options table") +T.same(CartManifest.decode(CartManifest.encode(optCart)), optCart, + "shipped settings survive the file round trip") +T.neq(CartManifest.hash(optCart), CartManifest.hash(cart), + "and are part of the cart hash") +T.neq(optCart.options, optCart.mods[1].options, + "a cart's own settings are not a mod's") + +-- ------- the sealed+ seal + +local plusRaw = baseCart() +plusRaw.seal = "sealed+" +local plusCart, plusErr = CartManifest.parse(plusRaw) +T.check(plusCart ~= nil, "a sealed+ cart parses: " .. tostring(plusErr)) +T.eq(plusCart.seal, "sealed+", "the seal survives") +T.same(CartManifest.decode(CartManifest.encode(plusCart)), plusCart, + "a sealed+ cart round trips through a file") +T.neq(CartManifest.hash(plusCart), CartManifest.hash(cart), + "and sealed+ is part of the cart hash") +T.eq(CartManifest.SEALS["sealed+"], true, "sealed+ is a known seal") + +-- ------- the launcher fields the file round trip used to drop + +local dressed = baseCart() +dressed.finish = "sparkle+holo" +dressed.speeds = { 1, 2 } +local dressedCart = CartManifest.parse(dressed) +local reread = CartManifest.decode(CartManifest.encode(dressedCart)) +T.eq(reread.finish, "sparkle+holo", "a cart's finish survives the file round trip") +T.same(reread.speeds, { 1, 2 }, "and so does its speed ladder") +T.eq(CartManifest.hash(reread), CartManifest.hash(dressedCart), + "so an installed copy hashes the same as the one that was written") + +-- ------- a cart that uses none of the above serializes exactly as it always did + +T.eq(CartManifest.canonical(cart), + "[cart].6:author$3:Ren.4:base$3:red.6:engine$7:>=1.4.0.2:id$10:kanto_plus" + .. ".5:label$13:art/label.png.4:repo$14:ren/kanto-plus.4:seal$6:sealed" + .. ".5:shell$7:#3fa9f5.7:summary$20:A sealed set of five.5:title$10:Kanto Plus" + .. ".7:version$5:1.2.0[mods]@9:rare_soda.2:id$9:rare_soda" + .. ".4:repo$13:ren/rare-soda.6:sha256$64:" .. SHA + .. ".6:source$6:github.7:version$5:0.4.1[options].5:fizzyT.7:flavour$5:grape" + .. ".9:sweetness#3@9:hard_mode.4:file#99123.2:id$9:hard_mode.3:md5$32:" .. MD5 + .. ".3:mod#4821.6:source$10:gamebanana[options][order]@9:rare_soda@9:hard_mode", + "the canonical string of a cart using no new field is byte for byte the old one") +T.eq(CartManifest.hash(cart), "2a8fbbacbd1df3f349b6a6ed387fa036", + "so its hash, and every save stamped with it, is unchanged") + +local open = baseCart() +open.seal = "open" +open.repo = nil +open.summary = nil +open.label = nil +open.engine = nil +local openCart = CartManifest.parse(open) +T.check(openCart ~= nil, "an open cart with no optional fields parses") +T.eq(openCart.seal, "open", "an open seal survives") +T.eq(openCart.label, nil, "an absent label stays absent") +T.same(CartManifest.decode(CartManifest.encode(openCart)), openCart, + "an open cart round trips") +T.neq(CartManifest.hash(openCart), CartManifest.hash(cart), + "the seal is part of the cart hash") + +T.eq(CartManifest.publishable(cart), true, + "a cart pinned entirely to github and gamebanana is publishable") +T.eq(CartManifest.publishable(nil), false, "publishable refuses a non-cart") +T.eq(CartManifest.publishable({}), false, "publishable refuses an unparsed cart") + +local localised = baseCart() +localised.mods[1] = { id = "rare_soda", source = "local", version = "0.4.1", + repo = "ren/rare-soda", sha256 = SHA, + options = { flavour = "grape" } } +local localCart, localErr = CartManifest.parse(localised) +T.check(localCart ~= nil, "a local pin parses: " .. tostring(localErr)) +T.eq(localCart.mods[1].source, "local", "the local pin keeps its source") +T.eq(localCart.mods[1].version, "0.4.1", "the local pin keeps its version") +T.eq(localCart.mods[1].repo, nil, "a local pin carries no repo") +T.eq(localCart.mods[1].sha256, nil, "a local pin carries no sha256") +T.eq(localCart.mods[1].md5, nil, "a local pin carries no md5") +T.eq(localCart.mods[1].options.flavour, "grape", "a local pin still freezes options") +T.eq(localCart.mods[2].source, "gamebanana", "the sibling pin is untouched") + +T.eq(CartManifest.canonical(CartManifest.parse(localised)), + CartManifest.canonical(localCart), "two parses of a local pin encode identically") +T.eq(CartManifest.hash(CartManifest.parse(localised)), CartManifest.hash(localCart), + "two parses of a local pin hash identically") +T.neq(CartManifest.hash(localCart), CartManifest.hash(cart), + "a local pin hashes differently from the github pin it replaced") + +local localBump = baseCart() +localBump.mods[1] = { id = "rare_soda", source = "local", version = "0.4.2", + options = { flavour = "grape" } } +T.neq(CartManifest.hash(CartManifest.parse(localBump)), CartManifest.hash(localCart), + "a bumped local pin version moves the cart hash") + +T.same(CartManifest.decode(CartManifest.encode(localCart)), localCart, + "a cart holding a local pin round trips") + +local publishableLocal, localWhy = CartManifest.publishable(localCart) +T.eq(publishableLocal, false, "a cart holding a local pin is not publishable") +T.check(type(localWhy) == "string" and localWhy:find("rare_soda", 1, true) ~= nil, + "the reason names the local pin (got " .. tostring(localWhy) .. ")") +T.check(localWhy:find("hard_mode", 1, true) == nil, + "the reason leaves the publishable pins out") + +local allLocal = baseCart() +allLocal.mods = { + { id = "rare_soda", source = "local", version = "0.4.1" }, + { id = "hard_mode", source = "local", version = "2.0.0" }, +} +local _, allWhy = CartManifest.publishable(CartManifest.parse(allLocal)) +T.check(allWhy:find("hard_mode", 1, true) ~= nil and allWhy:find("rare_soda", 1, true) ~= nil, + "the reason names every local pin (got " .. tostring(allWhy) .. ")") + +rejects(function(c) c.mods[1] = { id = "rare_soda", source = "local" } end, + "version must be", "a local pin with no version") +rejects(function(c) + c.mods[1] = { id = "rare_soda", source = "local", version = "latest" } +end, "version must be", "a local pin with a non-semver version") +rejects(function(c) c.mods[1].source = "localhost" end, "source must be", + "a source that merely starts like local") + +local VECTORS = { + { "", "" }, { "f", "Zg==" }, { "fo", "Zm8=" }, { "foo", "Zm9v" }, + { "foob", "Zm9vYg==" }, { "fooba", "Zm9vYmE=" }, { "foobar", "Zm9vYmFy" }, + { "\0\255\0", "AP8A" }, { "\255\255\255\255", "/////w==" }, +} +for _, row in ipairs(VECTORS) do + T.eq(Base64.encode(row[1]), row[2], + ("base64 encodes %q as %s"):format(row[1], row[2])) + T.eq(Base64.decode(row[2]), row[1], + ("base64 decodes %s back"):format(row[2] == "" and "an empty string" or row[2])) +end + +local seed = 7 +local function nextByte() + seed = (seed * 75 + 74) % 65537 + return seed % 256 +end +for n = 0, 24 do + local chunk = {} + for i = 1, n do chunk[i] = string.char(nextByte()) end + local raw = table.concat(chunk) + local text = Base64.encode(raw) + T.eq(#text % 4, 0, ("base64 pads %d bytes to a multiple of four"):format(n)) + T.eq(Base64.decode(text), raw, ("base64 round trips %d random bytes"):format(n)) +end + +T.eq(Base64.encode(nil), nil, "base64 encode refuses a non-string") +T.eq(Base64.decode(nil), nil, "base64 decode refuses a non-string") +T.eq(Base64.decode("TWF"), nil, "base64 refuses a length that is not a multiple of four") +T.eq(Base64.decode("TW*u"), nil, "base64 refuses a character outside the alphabet") +T.eq(Base64.decode("TWFu\n\n\n\n"), nil, "base64 refuses embedded whitespace") +T.eq(Base64.decode("TW=u"), nil, "base64 refuses padding inside a group") +T.eq(Base64.decode("=WFu"), nil, "base64 refuses a leading pad character") +T.eq(Base64.decode("TWFu===="), nil, "base64 refuses a group that is all padding") +T.eq(Base64.decode("TR=="), nil, "base64 refuses one-byte padding that carries data bits") +T.eq(Base64.decode("Zm9vYmF="), nil, "base64 refuses two-byte padding that carries data bits") +T.eq(Base64.decode("TWFu"), "Man", "base64 decodes a known vector") + +local PNG = CartManifest.PNG_SIGNATURE .. "\0\0\0\13IHDRtiny label art" +local ART_DATA = Base64.encode(PNG) + +local NONE = {} + +local function artTable(over) + local art = { name = "label.png", encoding = "base64", bytes = #PNG, + data = ART_DATA } + for key, value in pairs(over or {}) do + if value == NONE then art[key] = nil else art[key] = value end + end + return art +end + +local function bundle(body, art) + return SaveSerializer.encode({ format = CartManifest.FORMAT, + formatVersion = CartManifest.SCHEMA, cart = body, labelArt = art }) +end + +local arted = CartManifest.parse(baseCart()) +arted.labelArt = artTable() + +local artedBytes = CartManifest.encode(arted) +local artedRound, artedErr = CartManifest.decode(artedBytes) +T.check(artedRound ~= nil, "a cart with label art decodes: " .. tostring(artedErr)) +T.same(artedRound, arted, "the label art survives the encode and decode round trip") +T.eq(artedRound.labelArt.data, ART_DATA, "the base64 payload is preserved verbatim") +T.eq(artedRound.labelArt.bytes, #PNG, "the declared byte count is preserved") +T.eq(artedRound.labelArt.name, "label.png", "the art name is preserved") +T.eq(CartManifest.encode(artedRound), artedBytes, + "re-encoding a decoded cart writes the same bytes") + +local artBytes, artName = CartManifest.labelArtBytes(artedRound) +T.eq(artBytes, PNG, "labelArtBytes hands back the PNG that was packed") +T.eq(artName, "label.png", "labelArtBytes hands back the art name") +T.eq(CartManifest.labelArtBytes(cart), nil, "a cart with no art has no art bytes") + +T.eq(CartManifest.hash(arted), CartManifest.hash(cart), + "label art is not part of the cart hash") +T.check(CartManifest.canonical(arted):find(ART_DATA, 1, true) == nil, + "the canonical form leaves the art payload out") + +local repainted = CartManifest.parse(baseCart()) +local REPAINT = PNG .. "repainted" +repainted.labelArt = artTable({ data = Base64.encode(REPAINT), bytes = #REPAINT }) +T.eq(CartManifest.hash(repainted), CartManifest.hash(arted), + "changing only the art leaves the cart hash alone") +T.eq(CartManifest.labelArtBytes(CartManifest.decode(CartManifest.encode(repainted))), + REPAINT, "the repainted art round trips") + +local plain = CartManifest.decode(CartManifest.encode(cart)) +T.eq(plain.labelArt, nil, "a cart with no art still decodes without art") +T.check(CartManifest.encode(cart):find("labelArt", 1, true) == nil, + "a cart with no art writes no labelArt key") +T.same(plain, cart, "a cart with no art round trips exactly as before") + +local OVERSIZE = PNG .. string.rep("\0", CartManifest.MAX_LABEL_ART) +local OVERSIZE_DATA = Base64.encode(OVERSIZE) + +local function drops(art, what) + local decodedArt, dropErr = CartManifest.decode(bundle(cart, art)) + T.check(decodedArt ~= nil, what .. " still loads the cart: " .. tostring(dropErr)) + T.eq(decodedArt.labelArt, nil, what .. " drops the art") + T.same(decodedArt, cart, what .. " leaves the rest of the cart untouched") +end + +drops("label.png", "art that is not a table") +drops(artTable({ encoding = "hex" }), "art in an encoding we do not support") +drops(artTable({ encoding = NONE }), "art with no encoding") +drops(artTable({ data = "not base64!!" }), "art whose payload is not base64") +drops(artTable({ data = NONE }), "art with no payload") +drops(artTable({ data = "" }), "art with an empty payload") +drops(artTable({ bytes = #PNG + 1 }), "art whose byte count is too high") +drops(artTable({ bytes = #PNG - 1 }), "art whose byte count is too low") +drops(artTable({ bytes = NONE }), "art with no byte count") +drops(artTable({ bytes = tostring(#PNG) }), "art whose byte count is a string") +drops(artTable({ data = Base64.encode("GIF89a not a png at all"), + bytes = #"GIF89a not a png at all" }), "art that is not a PNG") +drops(artTable({ data = OVERSIZE_DATA, bytes = #OVERSIZE }), "art past the size cap") +drops(artTable({ name = "../../etc/passwd" }), "art whose name climbs out") +drops(artTable({ name = ("a"):rep(129) }), "art with a 129 character name") +drops(artTable({ name = 7 }), "art with a numeric name") + +local unnamed = CartManifest.decode(bundle(cart, artTable({ name = NONE }))) +T.check(unnamed ~= nil and unnamed.labelArt ~= nil, "art with no name is still kept") +T.eq(unnamed.labelArt.name, nil, "the missing art name stays missing") +T.eq(CartManifest.labelArtBytes(unnamed), PNG, "unnamed art still decodes") + +T.eq(CartManifest.parseLabelArt(nil), nil, "parseLabelArt refuses an absent payload") +local _, capErr = CartManifest.parseLabelArt( + artTable({ data = OVERSIZE_DATA, bytes = #OVERSIZE })) +T.check(type(capErr) == "string" and capErr:find("bytes or fewer", 1, true) ~= nil, + "the size cap says why (got " .. tostring(capErr) .. ")") +local _, pngErr = CartManifest.parseLabelArt( + artTable({ data = Base64.encode("nope"), bytes = 4 })) +T.check(type(pngErr) == "string" and pngErr:find("PNG", 1, true) ~= nil, + "a non-PNG payload says why (got " .. tostring(pngErr) .. ")") + +T.finish("cart_manifest") diff --git a/tests/engine/cart_saves.lua b/tests/engine/cart_saves.lua new file mode 100644 index 00000000..42b94e5b --- /dev/null +++ b/tests/engine/cart_saves.lua @@ -0,0 +1,353 @@ + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +love = love or require("tests.love_stub") + +local SaveSerializer = require("src.core.SaveSerializer") +local SaveData = require("src.core.SaveData") +local GameVersion = require("src.core.GameVersion") + +local realFS = love.filesystem + +local function memfs(files) + return { + files = files, + write = function(path, content) files[path] = content return true end, + read = function(path) return files[path] end, + remove = function(path) files[path] = nil return true end, + getInfo = function(path) + if files[path] then return { type = "file" } end + return nil + end, + } +end + +local function fresh() + local files = {} + love.filesystem = memfs(files) + SaveData.resetSlotState() + GameVersion.set("red") + return files +end + +local function options(files) + return SaveSerializer.decode(files["options.lua"] or "") or {} +end + +local function plainSave(name, hash) + return { + version = "red", + meta = hash and { cartHash = hash } or nil, + player = { name = name, map = "PALLET_TOWN", x = 1, y = 1 }, + pokedex = { seen = {}, owned = {} }, + inventory = {}, + playTime = 0, + } +end + +do + fresh() + T.eq(SaveData.getCart(), nil, "no cart is active by default") + T.eq(SaveData.setCart("nuzlocke", "abc123"), "nuzlocke", "setCart returns the id") + T.eq(SaveData.getCart(), "nuzlocke", "getCart reads the active cart back") + T.eq(SaveData.getCartHash(), "abc123", "the build hash rides along") + T.eq(SaveData.setCart(nil), nil, "setCart(nil) returns to vanilla play") + T.eq(SaveData.getCart(), nil, "and getCart says so") + T.eq(SaveData.getCartHash(), nil, "clearing the cart clears its build hash") + + T.eq(SaveData.setCart("../evil"), nil, "a path-climbing cart id is refused") + T.eq(SaveData.setCart(".."), nil, "so is a bare parent reference") + T.eq(SaveData.setCart("has spaces"), nil, "so is a non-word id") + T.eq(SaveData.setCart(42), nil, "so is a non-string id") + T.eq(SaveData.getCart(), nil, "none of them become the active cart") +end + +do + local files = fresh() + T.eq(#SaveData.listCartSlots("nuzlocke"), 0, "a cart starts with no slots") + T.eq(SaveData.activeCartSlot("nuzlocke"), nil, "and no active slot") + T.eq(SaveData.slotCartHash("nuzlocke", "slot1"), nil, "and no stamped hash") + T.eq(files["options.lua"], nil, "listing an empty cart writes no options file") + + T.eq(#SaveData.listCartSlots("../evil"), 0, "an unusable cart id lists nothing") + T.eq(SaveData.createCartSlot("../evil"), nil, "and can allocate no slot") + local ok, err = SaveData.deleteCartSlot("../evil", "slot1") + T.check(not ok, "and deleting from it fails") + T.check(tostring(err):find("unknown cart", 1, true) ~= nil, + "with a user-presentable reason") +end + +do + local files = fresh() + T.eq(SaveData.createCartSlot("nuzlocke"), "slot1", "first cart slot is slot1") + T.eq(SaveData.createCartSlot("nuzlocke"), "slot2", "ids increment as they do per version") + T.eq(SaveData.setActiveCartSlot("nuzlocke", "slot2"), "slot2", + "setActiveCartSlot returns the chosen id") + T.eq(SaveData.activeCartSlot("nuzlocke"), "slot2", "and the choice is live") + + local opts = options(files) + T.eq(opts.cartSlots.nuzlocke.active, "slot2", "the active id persists in options.lua") + T.eq(opts.cartSlots.nuzlocke.list[1], "slot1", "the slot list persists with it") + T.eq(opts.cartSlots.nuzlocke.list[2], "slot2", "in allocation order") + T.eq(opts.saveSlots, nil, "no per-version registry is created by cart work") + + T.check(SaveData.renameCartSlot("nuzlocke", "slot1", " Hardcore "), + "renameCartSlot labels a registered slot") + T.eq(options(files).cartSlots.nuzlocke.names.slot1, "Hardcore", + "the label is trimmed and persisted") + T.eq(SaveData.listCartSlots("nuzlocke")[1].label, "Hardcore", + "listCartSlots carries the label") + T.check(SaveData.renameCartSlot("nuzlocke", "slot1", ""), "an empty name clears it") + T.eq(options(files).cartSlots.nuzlocke.names, nil, + "the names table leaves the registry once empty") + + local bad, badErr = SaveData.renameCartSlot("nuzlocke", "slot99", "x") + T.check(not bad, "renaming an unregistered cart slot fails") + T.check(tostring(badErr):find("not registered", 1, true) ~= nil, + "unknown-slot rename error is user-presentable") + + T.check(SaveData.writeCartSlot("nuzlocke", "slot2", plainSave("NUZ")), + "seed the active cart slot") + T.check(files["saves/cart_nuzlocke/slot2.lua"] ~= nil, + "the bytes land in the cart's own directory") + + T.check(SaveData.deleteCartSlot("nuzlocke", "slot2"), "deleteCartSlot removes it") + T.eq(files["saves/cart_nuzlocke/slot2.lua"], nil, "the slot file is gone") + opts = options(files) + T.eq(#opts.cartSlots.nuzlocke.list, 1, "the id is dropped from the registry") + T.eq(opts.cartSlots.nuzlocke.active, "slot1", + "active falls back to the remaining slot") + T.eq(SaveData.activeCartSlot("nuzlocke"), "slot1", "and the live cache follows") + + T.check(SaveData.deleteCartSlot("nuzlocke", "slot1"), "deleting the last slot works") + T.eq(#SaveData.listCartSlots("nuzlocke"), 0, "the cart is empty again") + T.eq(options(files).cartSlots.nuzlocke.active, nil, "active clears with the list") +end + +do + local files = fresh() + T.eq(SaveData.createCartSlot("alpha"), "slot1", "alpha allocates its own slot1") + T.eq(SaveData.createCartSlot("beta"), "slot1", "beta allocates its own slot1") + T.check(SaveData.writeCartSlot("alpha", "slot1", plainSave("AAA", "aaa111")), + "seed alpha's slot") + T.check(SaveData.writeCartSlot("beta", "slot1", plainSave("BBB", "bbb222")), + "seed beta's slot") + + T.eq(#SaveData.listCartSlots("alpha"), 1, "alpha lists only its own slot") + T.eq(#SaveData.listCartSlots("beta"), 1, "beta lists only its own slot") + T.eq(SaveData.listCartSlots("alpha")[1].name, "AAA", "alpha reads its own save") + T.eq(SaveData.listCartSlots("beta")[1].name, "BBB", "beta reads its own save") + T.check(files["saves/cart_alpha/slot1.lua"] ~= files["saves/cart_beta/slot1.lua"], + "two carts with the same slot id write different files") + T.eq(SaveData.slotCartHash("alpha", "slot1"), "aaa111", "alpha's build stamp") + T.eq(SaveData.slotCartHash("beta", "slot1"), "bbb222", "beta's build stamp") + + T.check(SaveData.deleteCartSlot("alpha", "slot1"), "delete alpha's only slot") + T.eq(#SaveData.listCartSlots("alpha"), 0, "alpha is empty") + T.eq(#SaveData.listCartSlots("beta"), 1, "beta is untouched") + T.check(files["saves/cart_beta/slot1.lua"] ~= nil, "beta's file survives") + T.eq(SaveData.slotCartHash("beta", "slot1"), "bbb222", "as does its stamp") +end + +do + local files = fresh() + T.eq(SaveData.createSlot("red"), "slot1", "red allocates a version slot") + T.eq(SaveData.createCartSlot("red"), "slot1", + "a cart may even be named after a version") + T.check(SaveData.writeSlot("red", "slot1", plainSave("VANILLA")), + "seed the version slot") + T.check(SaveData.writeCartSlot("red", "slot1", plainSave("CARTRED")), + "seed the cart slot") + + T.eq(#SaveData.listSlots("red"), 1, "the version lists only its own slot") + T.eq(SaveData.listSlots("red")[1].name, "VANILLA", "with the vanilla save in it") + T.eq(#SaveData.listCartSlots("red"), 1, "the cart lists only its own slot") + T.eq(SaveData.listCartSlots("red")[1].name, "CARTRED", "with the cart save in it") + T.check(files["saves/red/slot1.lua"] ~= nil, "the version path is saves/red/") + T.check(files["saves/cart_red/slot1.lua"] ~= nil, "the cart path is saves/cart_red/") + T.eq(SaveData.listSlots("red")[1].cartHash, nil, + "a version slot carries no cart hash") + + T.check(SaveData.deleteCartSlot("red", "slot1"), "uninstall-style cart slot delete") + T.eq(#SaveData.listSlots("red"), 1, "the vanilla slot is still registered") + T.check(files["saves/red/slot1.lua"] ~= nil, "and its file is not orphaned") + T.eq(options(files).saveSlots.red.list[1], "slot1", + "the version registry is independent of the cart one") +end + +do + local files = fresh() + SaveData.setCart("nuzlocke", "abc123") + T.eq(SaveData.saveFilename("red"), "save_cart_nuzlocke.lua", + "with no cart slot yet, the flat path follows the version suffix scheme") + + SaveData.createCartSlot("nuzlocke") + SaveData.setActiveCartSlot("nuzlocke", "slot1") + T.eq(SaveData.saveFilename("red"), "saves/cart_nuzlocke/slot1.lua", + "the active cart slot owns the save path") + + local save = SaveData.newGame() + save.player.name = "NUZ" + T.check(SaveData.save(save, {}), "an in-game save under a cart writes") + T.check(files["saves/cart_nuzlocke/slot1.lua"] ~= nil, "into the cart's slot") + T.eq(files["save.lua"], nil, "never into the base game's flat file") + T.eq(files["saves/red/slot1.lua"], nil, "never into the base game's slot") + T.eq(#SaveData.listSlots("red"), 0, "and the base game still has no slots") + + local loaded = SaveData.load("red") + T.check(loaded and loaded.player.name == "NUZ", "load reads the cart's slot back") + T.eq(loaded.meta.cartHash, "abc123", "the save records the build it was made under") + T.eq(SaveData.slotCartHash("nuzlocke", "slot1"), "abc123", + "and the registry mirrors it for a listing with no save decode") + T.eq(SaveData.listCartSlots("nuzlocke")[1].cartHash, "abc123", + "listCartSlots surfaces the stamp") +end + +do + fresh() + SaveData.setCart("nuzlocke", "abc123") + SaveData.createCartSlot("nuzlocke") + SaveData.setActiveCartSlot("nuzlocke", "slot1") + T.check(SaveData.save(SaveData.newGame(), {}), "first save under build abc123") + + local loaded = SaveData.load("red") + T.check(SaveData.save(loaded, {}), "a re-save rebuilds meta") + T.eq(SaveData.load("red").meta.cartHash, "abc123", + "buildMeta carries the stamp instead of dropping it") + + SaveData.setCartHash("def456") + T.eq(SaveData.slotCartHash("nuzlocke", "slot1"), "abc123", + "installing an update does not restamp the slot") + loaded = SaveData.load("red") + T.eq(loaded.meta.cartHash, "abc123", "nor the in-progress save") + T.check(SaveData.save(loaded, {}), "save under the new build") + T.eq(SaveData.load("red").meta.cartHash, "def456", "now the save carries it") + T.eq(SaveData.slotCartHash("nuzlocke", "slot1"), "def456", "and so does the registry") + + local ok, err = SaveData.setSlotCartHash("nuzlocke", "slot9", "zzz") + T.check(not ok, "an unregistered slot cannot be stamped") + T.check(tostring(err):find("not registered", 1, true) ~= nil, + "with a user-presentable reason") +end + +do + local files = fresh() + SaveData.createSlot("red") + SaveData.setActiveSlot("red", "slot1") + local save = SaveData.newGame() + save.player.name = "VANILLA" + T.check(SaveData.save(save, {}), "a vanilla save with no cart set") + + local bytes = files["saves/red/slot1.lua"] + T.check(bytes:find("cartHash", 1, true) == nil, "records no cartHash") + T.check(files["options.lua"]:find("cartSlots", 1, true) == nil, + "and options.lua grows no cart registry") + T.eq(options(files).cartSlots, nil, "which decodes as absent, not empty") + + local loaded = SaveData.load("red") + T.check(SaveData.save(loaded), "re-save the migrated shape once") + local before = files["saves/red/slot1.lua"] + SaveData.setCart("nuzlocke", "abc123") + T.eq(SaveData.saveFilename("red"), "save_cart_nuzlocke.lua", + "the cart takes over the path while it is active") + SaveData.setCart(nil) + T.eq(SaveData.saveFilename("red"), "saves/red/slot1.lua", + "and hands it straight back") + T.check(SaveData.save(loaded), "re-save after the cart round trip") + T.eq(files["saves/red/slot1.lua"], before, "the vanilla bytes are identical") + T.check(files["options.lua"]:find("cartSlots", 1, true) == nil, + "and options.lua still carries no cart registry") + + local again = SaveData.load("red") + T.check(again and again.player.name == "VANILLA", "the vanilla save still loads") + T.eq(again.meta.cartHash, nil, "with no cart stamp on it") +end + +-- ------- cart-scoped settings + +do + local files = fresh() + local base = SaveData.loadOptions() + base.textSpeed = 5 + base.musicVol = 2 + base.speedBattle = 1 + T.check(SaveData.saveOptions(base), "the base game writes its own settings") + local plain = files["options.lua"] + + SaveData.setCart("nuzlocke", "hash1") + local inCart = SaveData.loadOptions() + T.eq(inCart.textSpeed, 5, "a cart starts from the global value") + T.eq(inCart.musicVol, 2, "for every key, scoped or not") + inCart.textSpeed = 1 + inCart.musicVol = 7 + inCart.speedBattle = 4 + T.check(SaveData.saveOptions(inCart), "change some settings inside the cart") + + local stored = options(files) + T.eq(stored.textSpeed, 5, "the global text speed is untouched") + T.eq(stored.cartOptions.nuzlocke.textSpeed, 1, "the cart keeps its own") + T.eq(stored.cartOptions.nuzlocke.speedBattle, 4, "and its own battle speed") + T.eq(stored.musicVol, 7, "an unscoped setting is written globally") + T.eq(stored.cartOptions.nuzlocke.musicVol, nil, "and never lands in the cart") + + T.eq(SaveData.loadOptions().textSpeed, 1, "the cart reads its value back") + T.eq(SaveData.loadOptions().musicVol, 7, "and the machine's shared volume") + + SaveData.setCart("marathon", "hash2") + T.eq(SaveData.loadOptions().textSpeed, 5, + "another cart does not see the first cart's setting") + local other = SaveData.loadOptions() + other.textSpeed = 3 + T.check(SaveData.saveOptions(other), "the second cart sets its own") + T.eq(options(files).cartOptions.nuzlocke.textSpeed, 1, + "which leaves the first cart's alone") + + SaveData.setCart(nil) + T.eq(SaveData.loadOptions().textSpeed, 5, "and the base game keeps the global") + T.eq(SaveData.loadOptions().speedBattle, 1, "for every scoped key") + + T.check(plain:find("cartOptions", 1, true) ~= nil, + "the options file declares the overlay") + T.check(plain:find("nuzlocke", 1, true) == nil, + "but an install that never ran a cart names no cart in it") + SaveData.setCart(nil) + local vanilla = SaveData.loadOptions() + vanilla.textSpeed = 4 + T.check(SaveData.saveOptions(vanilla), "a base-game write after playing carts") + T.eq(options(files).cartOptions.nuzlocke.textSpeed, 1, + "leaves every cart's overlay intact") + T.eq(options(files).cartOptions.marathon.textSpeed, 3, "for every cart") +end + +do + local files = fresh() + SaveData.setCart("author_cart", "hash3") + T.eq(SaveData.seedCartOptions({ textSpeed = 1, animations = false, + musicVol = 0, nonesuch = 3 }), true, "a cart seeds its shipped settings") + local seeded = SaveData.loadOptions() + T.eq(seeded.textSpeed, 1, "the author's text speed applies") + T.eq(seeded.animations, false, "and the author's battle animation choice") + T.eq(seeded.musicVol, 7, "a machine setting is never seeded") + T.eq(options(files).cartOptions.author_cart.musicVol, nil, "not even into the cart") + T.eq(options(files).cartOptions.author_cart.nonesuch, nil, + "and a key outside the cart-scoped set is ignored") + T.eq(options(files).cartOptionsSeeded.author_cart, true, "the seed is recorded") + + local player = SaveData.loadOptions() + player.textSpeed = 5 + T.check(SaveData.saveOptions(player), "the player then picks their own speed") + T.eq(SaveData.seedCartOptions({ textSpeed = 1, animations = false }), false, + "a second boot re-seeds nothing") + T.eq(SaveData.loadOptions().textSpeed, 5, "so the player's change survives") + + SaveData.setCart(nil) + T.eq(SaveData.loadOptions().textSpeed, 3, + "and the base game still has the shipped default of its own") + T.eq(SaveData.seedCartOptions({ textSpeed = 1 }), false, + "seeding with no cart active does nothing") +end + +love.filesystem = realFS + +T.finish("cart_saves") diff --git a/tests/engine/cart_seal.lua b/tests/engine/cart_seal.lua new file mode 100644 index 00000000..35866c86 --- /dev/null +++ b/tests/engine/cart_seal.lua @@ -0,0 +1,641 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +love = love or require("tests.love_stub") + +local Loader = require("src.mods.Loader") +local CartManifest = require("src.carts.CartManifest") +local SaveData = require("src.core.SaveData") +local SaveSerializer = require("src.core.SaveSerializer") +local GameVersion = require("src.core.GameVersion") + +local realFS = love.filesystem + +local function memfs(files) + local fs + fs = { + files = files, + read = function(path) return files[path] end, + write = function(path, content) files[path] = content return true end, + remove = function(path) files[path] = nil return true end, + getInfo = function(path) + if files[path] then return { type = "file" } end + local prefix = path .. "/" + for key in pairs(files) do + if key:sub(1, #prefix) == prefix then return { type = "directory" } end + end + return nil + end, + load = function(path) + if not files[path] then return nil, "no file: " .. path end + return load(files[path], path) + end, + createDirectory = function() return true end, + getDirectoryItems = function(path) + local seen, items = {}, {} + local prefix = path .. "/" + for key in pairs(files) do + if key:sub(1, #prefix) == prefix then + local child = key:sub(#prefix + 1):match("^[^/]+") + if child and not seen[child] then + seen[child] = true + items[#items + 1] = child + end + end + end + table.sort(items) + return items + end, + } + return fs +end + +local function manifestJson(id) + return ([[{"id":"%s","name":"%s","version":"1.0.0","entry":"main.lua"}]]) + :format(id, id) +end + +local function entry(record) + return ([[ +return function(mod) + mod.options:define({ { key = "tint", default = "base" } }) + mod.content.pokemon:register("%s", { name = tostring(mod.options:get("tint")) }) +end +]]):format(record) +end + +local function install() + local files = { + ["options.lua"] = SaveSerializer.encode({ + mods = { beta = false }, + modOptions = { alpha = { tint = "player" }, beta = { tint = "player" } }, + }), + } + for _, id in ipairs({ "alpha", "beta", "gamma" }) do + files["mods/" .. id .. "/manifest.json"] = manifestJson(id) + files["mods/" .. id .. "/main.lua"] = entry(id:upper()) + end + SaveData.resetSlotState() + GameVersion.set("red") + return files +end + +local function writeCart(files, tbl) + local cart, err = CartManifest.parse(tbl) + assert(cart, err) + files["carts/" .. tbl.id .. CartManifest.EXT] = CartManifest.encode(cart) + return cart +end + +local function cartTable(id, seal, mods, order) + return { id = id, title = id, version = "1.0.0", author = "tester", + shell = "#102030", base = "red", seal = seal, + mods = mods, load_order = order } +end + +local function pin(id, version, options) + return { id = id, source = "local", version = version or "1.0.0", + options = options } +end + +local function offPin(id, version, options) + local p = pin(id, version, options) + p.enabled = false + return p +end + +local function boot(files) + local data = { pokemon = {} } + local loader = Loader.new({ fs = memfs(files) }) + local ok = loader:load(data) + return loader, data, ok +end + +local function options(files) + return SaveSerializer.decode(files["options.lua"] or "") or {} +end + +local function names(list) + return table.concat(list, ",") +end + +-- ------- a sealed cart loads its pins, in its order, with its options + +do + local files = install() + writeCart(files, cartTable("sealed", "sealed", + { pin("alpha", "1.0.0", { tint = "cart" }), pin("beta") }, + { "beta", "alpha" })) + SaveData.setCart("sealed", "hash1") + + local loader, data, ok = boot(files) + T.check(ok, "a sealed cart whose pins are all installed loads cleanly") + T.eq(names(loader.order), "beta,alpha", + "the cart's load_order beats priority and the id tie-break") + T.eq(data.pokemon.ALPHA.name, "cart", + "a frozen option overrides the player's saved value") + T.eq(data.pokemon.BETA.name, "base", + "a pin that froze no options falls to the schema default, not the player's") + T.eq(data.pokemon.GAMMA, nil, "an enabled mod the cart does not pin never runs") + T.eq(loader.mods.gamma.enabled, false, "and is reported as inactive") + T.eq(loader.mods.gamma.state, "disabled", "with the disabled row state") + T.eq(loader.mods.beta.enabled, true, "a pin the player switched off still loads") + + local report = loader:cartStatus() + T.eq(report.id, "sealed", "the report names the cart") + T.eq(report.seal, "sealed", "and its seal") + T.eq(report.enforced, true, "which is enforced") + T.eq(report.refused, false, "and not refused") + T.eq(#report.missing, 0, "with no missing pins") + T.eq(#report.mismatched, 0, "and no version mismatches") + T.eq(loader:status().cart, report, "status() carries the same report") + + local opts = options(files) + T.eq(SaveData.modEnabled(opts, "beta", "red"), false, + "the player's disable flag is untouched on disk") + T.eq(SaveData.modEnabled(opts, "gamma", "red") == false, false, + "and so is the enable flag of the mod the seal left out") + T.eq(opts.modOptions.alpha.tint, "player", + "the frozen option never overwrote the player's saved value") + T.eq(opts.modOptions.beta.tint, "player", "for any pinned mod") +end + +-- ------- an open cart layers the player's mods on top + +do + local files = install() + writeCart(files, cartTable("open", "open", + { pin("beta", "1.0.0", { tint = "cart" }), pin("gamma", "1.0.0", { tint = "cart" }) }, + { "beta", "gamma" })) + SaveData.setCart("open", "hash2") + + local loader, data, ok = boot(files) + T.check(ok, "an open cart loads") + T.eq(names(loader.order), "beta,gamma,alpha", + "the cart's mods come first in its order, then the player's own") + T.eq(data.pokemon.BETA.name, "player", + "an open cart's option is a starting value the player's own setting beats") + T.eq(data.pokemon.GAMMA.name, "cart", + "and it stands where the player set nothing") + T.eq(data.pokemon.ALPHA.name, "player", "the player's extra mod loads normally") + T.eq(loader:cartStatus().enforced, false, "an open cart enforces nothing") +end + +-- ------- a pin the cart ships switched off + +local function withFlags(files, flags) + local opts = SaveSerializer.decode(files["options.lua"]) or {} + opts.mods = opts.mods or {} + for id, on in pairs(flags) do opts.mods[id] = on end + files["options.lua"] = SaveSerializer.encode(opts) + return files +end + +local function withCartFlags(files, cartId, flags) + local opts = SaveSerializer.decode(files["options.lua"]) or {} + opts.cartMods = opts.cartMods or {} + opts.cartMods[cartId] = opts.cartMods[cartId] or {} + for id, on in pairs(flags) do opts.cartMods[cartId][id] = on end + files["options.lua"] = SaveSerializer.encode(opts) + return files +end + +do + local files = install() + writeCart(files, cartTable("shipped_off", "sealed", + { pin("alpha"), offPin("gamma", "1.0.0", { tint = "cart" }) }, + { "alpha", "gamma" })) + SaveData.setCart("shipped_off", "hashA") + + local loader, data, ok = boot(files) + T.check(ok, "a sealed cart that ships a mod switched off loads") + T.eq(names(loader.order), "alpha", "and runs only the pins it ships switched on") + T.eq(data.pokemon.GAMMA, nil, "the switched-off pin does not run") + T.eq(loader.mods.gamma.enabled, false, "and is reported as inactive") + T.eq(loader.mods.gamma.state, "disabled", "with the disabled row state") + T.check(loader.mods.gamma ~= nil, "while still being installed") + T.eq(loader:cartStatus().pins.gamma.options.tint, "cart", + "carrying the settings the cart shipped it with") +end + +do + local files = withCartFlags(install(), "welded", { gamma = true }) + writeCart(files, cartTable("welded", "sealed", + { pin("alpha"), offPin("gamma") }, { "alpha", "gamma" })) + SaveData.setCart("welded", "hashB") + + local loader, data, ok = boot(files) + T.check(ok, "a sealed cart loads with the player asking for the off pin") + T.eq(data.pokemon.GAMMA, nil, "but sealed refuses the toggle") + T.eq(loader.mods.gamma.enabled, false, "the pin stays exactly as the cart shipped it") +end + +-- ------- sealed+ : the same fixed set, switchable + +do + local files = withCartFlags(install(), "plus", { gamma = true }) + writeCart(files, cartTable("plus", "sealed+", + { pin("alpha"), offPin("gamma", "1.0.0", { tint = "cart" }) }, + { "alpha", "gamma" })) + SaveData.setCart("plus", "hashC") + + local loader, data, ok = boot(files) + T.check(ok, "a sealed+ cart loads") + local report = loader:cartStatus() + T.eq(report.seal, "sealed+", "the report carries the new seal") + T.eq(report.sealed, true, "which is a sealed cart") + T.eq(report.enforced, true, "and is enforced like one") + T.eq(names(loader.order), "alpha,gamma", + "the player switched the cart's off pin on and it runs") + T.eq(data.pokemon.GAMMA.name, "cart", + "still with the cart's frozen option value, not the player's") + T.eq(data.pokemon.BETA, nil, "a mod the cart does not pin cannot be added") + T.eq(loader.mods.beta.enabled, false, "and stays switched off") +end + +do + local files = withCartFlags(install(), "plus_off", { alpha = false }) + writeCart(files, cartTable("plus_off", "sealed+", + { pin("alpha"), pin("gamma") }, { "alpha", "gamma" })) + SaveData.setCart("plus_off", "hashD") + + local loader, data, ok = boot(files) + T.check(ok, "a sealed+ cart loads with a pin the player switched off") + T.eq(names(loader.order), "gamma", "which does not run") + T.eq(data.pokemon.ALPHA, nil, "so its content is absent") + T.eq(loader.mods.alpha.enabled, false, "and the row reads as off") +end + +do + local files = install() + writeCart(files, cartTable("plus_gap", "sealed+", + { pin("alpha"), pin("delta", "2.0.0") }, { "alpha", "delta" })) + SaveData.setCart("plus_gap", "hashE") + + local loader, _, ok = boot(files) + T.check(not ok, "sealed+ still refuses a pin that is not installed") + T.eq(#loader.order, 0, "and plays no subset of itself") + T.eq(loader:cartStatus().refused, true, "the report refuses it") +end + +do + local files = install() + love.filesystem = memfs(files) + SaveData.resetSlotState() + GameVersion.set("red") + writeCart(files, cartTable("plus_seal", "sealed+", + { pin("alpha"), offPin("gamma") }, { "alpha", "gamma" })) + SaveData.setCart("plus_seal", "hashF") + local slot = SaveData.createCartSlot("plus_seal") + SaveData.setActiveCartSlot("plus_seal", slot) + + local loader = boot(files) + T.check(loader:setEnabled("gamma", true), "switch a sealed+ pin on") + T.eq(SaveData.isSealBroken(), false, "which does not break the session seal") + T.eq(SaveData.slotSealBroken("plus_seal", slot), false, + "nor mark the save slot modified") + T.eq(SaveData.adoptCartSeal("plus_seal"), false, + "so the next boot still adopts an intact seal") + + local again, data, ok = boot(files) + T.check(ok, "and the cart loads again") + T.eq(names(again.order), "alpha,gamma", "with the mod the player switched on") + T.eq(data.pokemon.BETA, nil, "and still nothing the cart does not pin") +end + +do + local files = withFlags(install(), { gamma = false }) + writeCart(files, cartTable("plus_split", "sealed+", + { pin("alpha"), offPin("gamma") }, { "alpha", "gamma" })) + SaveData.setCart("plus_split", "hashI") + + local loader = boot(files) + T.eq(loader.mods.gamma.enabled, false, "a cart's off pin starts off") + T.check(loader:setEnabled("gamma", true), "and the player switches it on") + local opts = options(files) + T.eq(opts.cartMods.plus_split.gamma, true, + "the answer is stored under the cart that asked for it") + T.eq(SaveData.modEnabled(opts, "gamma", "red"), false, + "and the base game's own flag for that mod is untouched") +end + +-- ------- an open cart hands back only the pins it ships switched off + +do + local files = withCartFlags(install(), "open_off", { gamma = true }) + writeCart(files, cartTable("open_off", "open", + { pin("beta"), offPin("gamma") }, { "beta", "gamma" })) + SaveData.setCart("open_off", "hashG") + + local loader, data, ok = boot(files) + T.check(ok, "an open cart with a switched-off pin loads") + T.check(data.pokemon.GAMMA ~= nil, "the player switched it on, so it runs") + T.eq(loader.mods.beta.enabled, true, + "while a pin the cart says nothing about is still forced on") +end + +do + local files = install() + writeCart(files, cartTable("open_quiet", "open", + { pin("beta"), offPin("gamma") }, { "beta", "gamma" })) + SaveData.setCart("open_quiet", "hashH") + + local loader, data, ok = boot(files) + T.check(ok, "an open cart whose off pin the player never touched loads") + T.eq(data.pokemon.GAMMA, nil, "with that pin off by default") + T.eq(loader.mods.gamma.enabled, false, "and reported off") +end + +-- ------- a missing pin: refusal when sealed, warning when open + +do + local files = install() + writeCart(files, cartTable("gap", "sealed", + { pin("alpha"), pin("delta", "2.0.0") }, { "alpha", "delta" })) + SaveData.setCart("gap", "hash3") + + local loader, data, ok = boot(files) + T.check(not ok, "a sealed cart with an uninstalled pin fails the load") + T.eq(#loader.order, 0, "and plays no subset of itself") + T.eq(data.pokemon.ALPHA, nil, "not even the pin that is installed") + T.eq(data.pokemon.GAMMA, nil, "and certainly not the player's own mods") + + local report = loader:cartStatus() + T.eq(report.refused, true, "the report refuses the cart") + T.eq(#report.missing, 1, "naming one missing pin") + T.eq(report.missing[1].id, "delta", "by id") + T.eq(report.missing[1].version, "2.0.0", "and by the version it pins") + T.eq(report.missing[1].source, "local", "with the source it would come from") + T.check(report.message:find("delta 2.0.0 is not installed", 1, true) ~= nil, + "and a message the launcher can show") + T.eq(loader.errors[1], report.message, "the refusal is on the boot error list") + + local opts = options(files) + T.eq(SaveData.modEnabled(opts, "beta", "red"), false, + "a refusal still leaves the player's flags alone") +end + +do + local files = install() + writeCart(files, cartTable("gap", "open", + { pin("alpha"), pin("delta", "2.0.0") }, { "alpha", "delta" })) + SaveData.setCart("gap", "hash4") + + local loader, data, ok = boot(files) + T.check(ok, "an open cart with an uninstalled pin still loads") + T.eq(names(loader.order), "alpha,gamma", "with the pins it does have, then the rest") + T.eq(data.pokemon.ALPHA.name, "player", "the surviving pin runs") + local report = loader:cartStatus() + T.eq(report.refused, false, "the missing pin is a warning, not a refusal") + T.eq(report.missing[1].id, "delta", "and is still reported by id") +end + +-- ------- a pin installed at another version + +do + local files = install() + writeCart(files, cartTable("skew", "sealed", + { pin("alpha", "2.0.0") }, { "alpha" })) + SaveData.setCart("skew", "hash5") + + local loader, _, ok = boot(files) + T.check(not ok, "a sealed cart refuses a pin installed at another version") + T.eq(#loader.order, 0, "and loads nothing") + local report = loader:cartStatus() + T.eq(report.mismatched[1].id, "alpha", "the mismatch names the mod") + T.eq(report.mismatched[1].version, "2.0.0", "the version the cart pins") + T.eq(report.mismatched[1].installed, "1.0.0", "and the version installed") + T.check(report.message:find("alpha is pinned at 2.0.0 but 1.0.0 is installed", + 1, true) ~= nil, "with a message the launcher can show") +end + +do + local files = install() + writeCart(files, cartTable("skew", "open", + { pin("alpha", "2.0.0") }, { "alpha" })) + SaveData.setCart("skew", "hash6") + + local loader, data, ok = boot(files) + T.check(ok, "an open cart warns about a version skew instead of refusing") + T.eq(data.pokemon.ALPHA.name, "player", "and loads the version that is there") + T.eq(loader:cartStatus().mismatched[1].installed, "1.0.0", "while reporting it") +end + +-- ------- an unreadable cart + +do + local files = install() + SaveData.setCart("ghost", "hash7") + local loader, data, ok = boot(files) + T.check(not ok, "a cart that is not installed cannot be played as that cart") + T.eq(data.pokemon.GAMMA, nil, "so nothing loads under its name") + T.check(loader:cartStatus().message:find("ghost", 1, true) ~= nil, + "and the report names the cart that went missing") +end + +-- ------- breaking the seal downgrades a sealed cart to the open answer + +do + local files = install() + writeCart(files, cartTable("sealed", "sealed", + { pin("beta", "1.0.0", { tint = "cart" }), pin("delta", "2.0.0") }, + { "beta", "delta" })) + SaveData.setCart("sealed", "hash8") + SaveData.breakSeal() + + local loader, data, ok = boot(files) + T.check(ok, "a broken seal no longer refuses over a missing pin") + T.eq(names(loader.order), "beta,alpha,gamma", + "the player's own mods load alongside the cart's") + T.eq(data.pokemon.BETA.name, "player", + "and the player's option values come back with them") + local report = loader:cartStatus() + T.eq(report.broken, true, "the report says the seal is broken") + T.eq(report.enforced, false, "so the seal enforces nothing") +end + +-- ------- vanilla is untouched when no cart is active + +do + local files = install() + SaveData.resetSlotState() + local loader, data, ok = boot(files) + T.check(ok, "a vanilla boot with no cart loads") + T.eq(loader:cartStatus(), nil, "and reports no cart at all") + T.eq(names(loader.order), "alpha,gamma", "with the player's enabled set, in id order") + T.eq(data.pokemon.ALPHA.name, "player", "and the player's option values") + T.eq(data.pokemon.BETA, nil, "the mod the player switched off stays off") +end + +-- ------- planCart on its own + +do + local report = Loader.planCart(nil, {}) + T.eq(report.refused, true, "planCart refuses a cart it was handed nothing for") + T.check(report.message:find("not installed", 1, true) ~= nil, + "with a presentable reason") + + local unpinned = Loader.planCart( + cartTable("c", "sealed", { pin("gamma", "0.0.0") }, { "gamma" }), + { { id = "gamma", version = "whatever" } }) + T.eq(#unpinned.mismatched, 0, + "a local pin captured with no semantic version makes no version claim") + T.eq(unpinned.refused, false, "so it cannot refuse over one") + + local skew = Loader.planCart( + cartTable("c", "sealed", { pin("gamma", "1.0.0") }, { "gamma" }), + { gamma = { manifest = { id = "gamma", version = "1.0.0-beta" } } }) + T.eq(skew.mismatched[1].installed, "1.0.0-beta", + "a prerelease is a different version to a sealed cart") + + local broken = Loader.planCart( + cartTable("c", "sealed", { pin("gamma", "1.0.0") }, { "gamma" }), {}, true) + T.eq(broken.refused, false, "a broken seal downgrades the refusal to a warning") + T.eq(broken.missing[1].id, "gamma", "while still reporting the missing pin") +end + +-- ------- the broken-seal stamp + +local function plainSave(name) + return { + version = "red", + player = { name = name, map = "PALLET_TOWN", x = 1, y = 1 }, + pokedex = { seen = {}, owned = {} }, + inventory = {}, + playTime = 0, + } +end + +do + local files = {} + love.filesystem = memfs(files) + SaveData.resetSlotState() + GameVersion.set("red") + + T.eq(SaveData.isSealBroken(), false, "a fresh session has no broken seal") + local save = plainSave("INTACT") + T.eq(SaveData.isSealBroken(save), false, "and neither does a fresh save") + T.check(SaveData.save(save, {}), "write a save under an intact seal") + T.eq(SaveData.load("red").meta.sealBroken, nil, "which carries no stamp") + + local loaded = SaveData.load("red") + T.check(SaveData.breakSeal(loaded), "breaking the seal stamps the save") + T.eq(SaveData.isSealBroken(loaded), true, "the save reads back as modified") + T.eq(SaveData.isSealBroken(), true, "and the session is armed") + T.check(SaveData.save(loaded, {}), "save the stamped file") + T.eq(SaveData.load("red").meta.sealBroken, true, + "the stamp survives a save/load round trip") + + local again = SaveData.load("red") + T.check(SaveData.save(again, {}), "re-save with a rebuilt meta stamp") + T.eq(SaveData.load("red").meta.sealBroken, true, "buildMeta carries the stamp") + + T.eq(SaveData.unbreakSeal, nil, "there is no public unset") + T.eq(SaveData.clearSeal, nil, "under any spelling") + T.eq(SaveData.setSealBroken, nil, "and no setter that takes a value") + T.check(SaveData.breakSeal(again, false), "the setter takes no argument that clears") + T.eq(SaveData.isSealBroken(again), true, "so the stamp is still there") + + SaveData.resetSlotState() + T.eq(SaveData.isSealBroken(), false, "a new session starts unarmed") + local reread = SaveData.load("red") + T.eq(reread.meta.sealBroken, true, "but the file it stamped is modified for good") + T.check(SaveData.save(reread, {}), "and re-saving it under a fresh session") + T.eq(SaveData.load("red").meta.sealBroken, true, "does not un-modify it") + + local fresh = plainSave("FRESH") + T.check(SaveData.save(fresh, {}), "a save written while the session is unarmed") + T.eq(SaveData.load("red").meta.sealBroken, nil, "carries no stamp of its own") +end + +-- ------- the durable per-slot broken mark + +do + local files = {} + love.filesystem = memfs(files) + SaveData.resetSlotState() + GameVersion.set("red") + + local first = SaveData.createCartSlot("kanto") + T.eq(first, "slot1", "a cart's first save slot") + T.eq(SaveData.slotSealBroken("kanto", first), false, "starts sealed") + T.eq(SaveData.listCartSlots("kanto")[1].sealBroken, false, + "which its launcher row reports without loading a save") + + T.check(SaveData.markSlotSealBroken("kanto", first), "break that slot's seal") + T.eq(SaveData.slotSealBroken("kanto", first), true, "the slot reads as broken") + T.eq(SaveData.listCartSlots("kanto")[1].sealBroken, true, + "and the launcher row carries it") + + SaveData.resetSlotState() + T.eq(SaveData.slotSealBroken("kanto", first), true, + "the mark survives a restart") + + local second = SaveData.createCartSlot("kanto") + T.eq(SaveData.slotSealBroken("kanto", second), false, + "a new slot under the same cart starts sealed again") + + T.eq(SaveData.clearSlotSealBroken, nil, "there is no public unset") + T.eq(SaveData.unmarkSlotSealBroken, nil, "under any spelling") + T.eq(SaveData.setSlotSealBroken, nil, "and no setter that takes a value") + T.check(SaveData.markSlotSealBroken("kanto", first, false), + "the setter takes no argument that clears") + T.eq(SaveData.slotSealBroken("kanto", first), true, "so the mark stands") + T.eq(SaveData.markSlotSealBroken("kanto", "slot9"), false, + "a slot that is not registered cannot be marked") + + SaveData.setCart("kanto", "hash9") + SaveData.setActiveCartSlot("kanto", second) + T.eq(SaveData.adoptCartSeal("kanto"), false, + "booting an unmarked slot leaves the session sealed") + T.eq(SaveData.isSealBroken(), false, "so the loader still enforces the cart") + SaveData.setActiveCartSlot("kanto", first) + T.eq(SaveData.adoptCartSeal("kanto"), true, + "booting the marked slot breaks the seal for the session") + T.eq(SaveData.isSealBroken(), true, "which is what the loader reads") + + SaveData.deleteCartSlot("kanto", first) + T.eq(SaveData.slotSealBroken("kanto", first), false, + "deleting the playthrough takes its mark with it") +end + +-- ------- a marked slot loads the cart's pins first, then the player's mods + +do + local files = install() + writeCart(files, cartTable("marked", "sealed", + { pin("beta", "1.0.0", { tint = "cart" }), pin("delta", "2.0.0") }, + { "beta", "delta" })) + love.filesystem = memfs(files) + SaveData.resetSlotState() + GameVersion.set("red") + SaveData.setCart("marked", "hash10") + local slot = SaveData.createCartSlot("marked") + SaveData.setActiveCartSlot("marked", slot) + + local intact, _, intactOk = boot(files) + T.check(not intactOk, "an unmarked slot still refuses the missing pin") + T.eq(intact:cartStatus().refused, true, "with the refusal on its report") + + SaveData.resetSlotState() + SaveData.setCart("marked", "hash10") + T.check(SaveData.markSlotSealBroken("marked", slot), "mark that slot broken") + T.check(SaveData.adoptCartSeal("marked"), "boot adopts the mark") + + local loader, data, ok = boot(files) + T.check(ok, "and the cart loads") + T.eq(names(loader.order), "beta,alpha,gamma", + "the cart's pins load first, then the player's own enabled mods") + T.eq(data.pokemon.BETA.name, "player", + "with the player's own option values back") + T.eq(loader:cartStatus().broken, true, "the report says the seal is broken") + + local stamped = plainSave("BROKEN") + T.check(SaveData.save(stamped, {}), "a save written under the adopted mark") + T.eq(SaveData.load("red").meta.sealBroken, true, + "carries the save's own permanent stamp") +end + +love.filesystem = realFS + +T.finish("cart_seal") diff --git a/tests/engine/cart_store.lua b/tests/engine/cart_store.lua new file mode 100644 index 00000000..47713dd9 --- /dev/null +++ b/tests/engine/cart_store.lua @@ -0,0 +1,433 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +if not rawget(_G, "bit") and not rawget(_G, "bit32") then + local ok, bit32 = pcall(require, "bit32") + if ok then _G.bit32 = bit32 end +end + +local T = require("tests.harness") +love = love or require("tests.love_stub") + +local Base64 = require("src.core.Base64") +local SaveData = require("src.core.SaveData") +local CartManifest = require("src.carts.CartManifest") +local CartStore = require("src.carts.CartStore") + +local SHA = ("a1b2c3d4"):rep(8) +local SHA2 = ("beefcafe"):rep(8) +local MD5 = ("0123456789abcdef"):rep(2) + +local function memfs() + local files, dirs = {}, {} + local fs + fs = { + files = files, + write = function(path, body) files[path] = body return true end, + read = function(path) return files[path] end, + remove = function(path) files[path] = nil return true end, + createDirectory = function(path) dirs[path] = true return true end, + getInfo = function(path) + if files[path] ~= nil then return { type = "file" } end + if dirs[path] then return { type = "directory" } end + for name in pairs(files) do + if name:sub(1, #path + 1) == path .. "/" then return { type = "directory" } end + end + return nil + end, + getDirectoryItems = function(path) + local prefix = (path == "" or path == nil) and "" or (path .. "/") + local out, seen = {}, {} + for name in pairs(files) do + if name:sub(1, #prefix) == prefix then + local child = name:sub(#prefix + 1):match("^([^/]+)") + if child and not seen[child] then + seen[child] = true + out[#out + 1] = child + end + end + end + table.sort(out) + return out + end, + } + return fs +end + +local function cartTable(over) + local tbl = { + id = "kanto_plus", + title = "Kanto Plus", + version = "1.2.0", + author = "Ren", + shell = "#3fa9f5", + base = "red", + seal = "sealed", + mods = { + { id = "rare_soda", source = "github", repo = "ren/rare-soda", + version = "0.4.1", sha256 = SHA, + options = { flavour = "grape", sweetness = 3 } }, + { id = "hard_mode", source = "gamebanana", mod = 4821, file = 99123, + md5 = MD5 }, + }, + } + for key, value in pairs(over or {}) do tbl[key] = value end + return tbl +end + +local function bytesOf(over) + local cart, err = CartManifest.parse(cartTable(over)) + if not cart then error("fixture does not parse: " .. tostring(err)) end + return CartManifest.encode(cart), cart +end + +local fs = memfs() +local bytes, fixture = bytesOf() +local installed, hash = CartStore.install(bytes, fs) +T.check(installed ~= nil, "a good cart installs: " .. tostring(hash)) +T.eq(installed.id, "kanto_plus", "install returns the parsed cart") +T.eq(hash, CartManifest.hash(fixture), "install returns the cart hash") +T.check(fs.files["carts/kanto_plus.g1rcart"] ~= nil, + "install writes carts/.g1rcart") + +local reg = SaveData.loadOptions(fs).carts +T.check(type(reg) == "table" and type(reg.kanto_plus) == "table", + "install registers the cart in options.carts") +T.eq(reg.kanto_plus.title, "Kanto Plus", "the registry carries the title") +T.eq(reg.kanto_plus.base, "red", "the registry carries the base game") +T.eq(reg.kanto_plus.version, "1.2.0", "the registry carries the cart version") +T.eq(reg.kanto_plus.hash, hash, "the registry carries the cart hash") +T.eq(reg.kanto_plus.file, "carts/kanto_plus.g1rcart", + "the registry names the cart file") + +local index = CartStore.index(fs) +T.eq(#index, 1, "index lists the registry without reading the files") +T.eq(index[1].base, "red", "an index row carries the base game") + +local rows = CartStore.list(fs) +T.eq(#rows, 1, "list returns the installed cart") +T.eq(rows[1].id, "kanto_plus", "the row names the cart") +T.eq(rows[1].base, "red", "the row carries the base") +T.eq(rows[1].cartHash, hash, "the row carries the cart hash") +T.check(type(rows[1].cart) == "table" and rows[1].cart.mods ~= nil, + "the row carries the parsed cart") +T.eq(rows[1].cart.mods[1].sha256, SHA, "the parsed cart keeps its pins") + +local got, gotHash = CartStore.get("kanto_plus", fs) +T.check(got ~= nil, "get returns the cart") +T.eq(gotHash, hash, "get returns the cart hash") +T.same(got, fixture, "get returns exactly what was installed") +T.eq(CartStore.get("nothing_here", fs), nil, "get refuses an unknown id") +T.eq(CartStore.get("../etc/passwd", fs), nil, "get refuses a climbing id") + +local exported, exportHash = CartStore.export("kanto_plus", fs) +T.check(type(exported) == "string", "export hands back bytes") +T.eq(exportHash, hash, "export reports the cart hash") +T.same(CartManifest.decode(exported), fixture, "the exported bytes decode back") +T.eq(CartStore.export("nothing_here", fs), nil, "export refuses an unknown id") + +local blueBytes = bytesOf({ id = "johto_lite", title = "Aaa Johto Lite", + base = "blue" }) +T.check(CartStore.install(blueBytes, fs) ~= nil, "a blue cart installs") +T.eq(#CartStore.list(fs), 2, "list returns both carts") +T.eq(CartStore.list(fs)[1].id, "johto_lite", "list sorts by title") +T.eq(#CartStore.listFor("red", fs), 1, "listFor red returns one cart") +T.eq(CartStore.listFor("red", fs)[1].id, "kanto_plus", "listFor red picks the red cart") +T.eq(#CartStore.listFor("blue", fs), 1, "listFor blue returns one cart") +T.eq(#CartStore.listFor("yellow", fs), 0, "listFor yellow returns nothing") + +local newer, newerErr = CartStore.install( + bytesOf({ version = "1.3.0", title = "Kanto Plus" }), fs) +T.check(newer ~= nil, "a newer cart replaces the installed one: " .. tostring(newerErr)) +T.eq(CartStore.get("kanto_plus", fs).version, "1.3.0", + "the newer version is what is installed") +T.eq(#CartStore.list(fs), 2, "replacing does not add a second row") +T.eq(SaveData.loadOptions(fs).carts.kanto_plus.version, "1.3.0", + "the registry follows the replacement") + +local same, sameErr = CartStore.install(bytesOf({ version = "1.3.0" }), fs) +T.check(same ~= nil, "the same version reinstalls: " .. tostring(sameErr)) + +local older, olderErr = CartStore.install(bytesOf({ version = "1.1.0" }), fs) +T.eq(older, nil, "an older cart is refused") +T.check(type(olderErr) == "string" and olderErr:find("older", 1, true) ~= nil, + "the refusal says why (got " .. tostring(olderErr) .. ")") +T.eq(CartStore.get("kanto_plus", fs).version, "1.3.0", + "the refused install leaves the newer cart in place") + +T.eq(CartStore.install("return { }", fs), nil, "install refuses an untagged file") +T.eq(CartStore.install(nil, fs), nil, "install refuses a non-string") +T.eq(CartStore.install("\1\2\3 not lua", fs), nil, "install refuses noise") + +fs.files["saves/cart_kanto_plus/slot1.lua"] = "return { player = { name = \"RED\" } }" +local opts = SaveData.loadOptions(fs) +opts.cartSlots = { kanto_plus = { list = { "slot1" }, active = "slot1" } } +SaveData.saveOptions(opts, fs) + +T.check(CartStore.uninstall("kanto_plus", fs), "uninstall reports success") +T.eq(fs.files["carts/kanto_plus.g1rcart"], nil, "uninstall removes the cart file") +T.eq(SaveData.loadOptions(fs).carts.kanto_plus, nil, + "uninstall clears the registry entry") +T.eq(#CartStore.list(fs), 1, "the uninstalled cart is gone from the list") +T.check(fs.files["saves/cart_kanto_plus/slot1.lua"] ~= nil, + "uninstall leaves the cart's save file alone") +local slots = SaveData.loadOptions(fs).cartSlots +T.check(type(slots) == "table" and type(slots.kanto_plus) == "table", + "uninstall leaves the cart's slot registry alone") +T.eq(slots.kanto_plus.active, "slot1", "the active slot survives an uninstall") + +local gone, goneErr = CartStore.uninstall("kanto_plus", fs) +T.eq(gone, nil, "uninstalling twice is refused") +T.check(type(goneErr) == "string" and goneErr:find("not installed", 1, true) ~= nil, + "the second uninstall says why (got " .. tostring(goneErr) .. ")") +T.eq(CartStore.uninstall("../etc/passwd", fs), nil, "uninstall refuses a climbing id") + +T.check(CartStore.install(bytes, fs) ~= nil, "the cart reinstalls after removal") +T.same(CartStore.get("kanto_plus", fs), fixture, "reinstalling restores the cart") +T.check(fs.files["saves/cart_kanto_plus/slot1.lua"] ~= nil, + "the old playthrough is still there for the reinstalled cart") + +fs.files["carts/kanto_plus.g1rcart"] = "return { format = \"nonsense\" }" +local damaged = CartStore.list(fs) +T.eq(#damaged, 1, "a corrupt cart file is skipped and the rest still list") +T.eq(damaged[1].id, "johto_lite", "the healthy cart survives a corrupt sibling") +T.check(fs.files["carts/kanto_plus.g1rcart"] ~= nil, + "listing never deletes the file it could not read") +T.eq(CartStore.get("kanto_plus", fs), nil, "get reports the corrupt cart as unreadable") +fs.files["carts/kanto_plus.g1rcart"] = nil + +opts = SaveData.loadOptions(fs) +opts.carts = opts.carts or {} +opts.carts.ghost = { id = "ghost", title = "Ghost", base = "red", + version = "1.0.0", file = "carts/ghost.g1rcart" } +SaveData.saveOptions(opts, fs) +local haunted = CartStore.list(fs) +T.eq(#haunted, 1, "a registry entry with no file is skipped") +T.eq(haunted[1].id, "johto_lite", "the rest of the list still comes back") +T.eq(SaveData.loadOptions(fs).carts.ghost, nil, + "listing prunes the registry entry whose file is gone") + +local strayCart = select(2, bytesOf({ id = "wanderer", title = "Zzz Wanderer" })) +fs.files["carts/wanderer.g1rcart"] = CartManifest.encode(strayCart) +local adopted = CartStore.list(fs) +T.eq(#adopted, 2, "a cart file with no registry entry is still listed") +T.eq(adopted[2].id, "wanderer", "the stray cart sorts in by title") +T.eq(adopted[2].cartHash, CartManifest.hash(strayCart), + "the stray cart is hashed from its own file") +T.check(SaveData.loadOptions(fs).carts.wanderer ~= nil, + "listing registers the stray cart it adopted") + +fs.files["carts/readme.txt"] = "hello" +fs.files["carts/half written.g1rcart"] = "return { }" +T.eq(#CartStore.list(fs), 2, "junk in carts/ is ignored") + +local empty = memfs() +T.eq(#CartStore.list(empty), 0, "a fresh install lists no carts") +T.eq(#CartStore.listFor("red", empty), 0, "listFor is empty on a fresh install") + +-- ------- the fields the registry rows have to carry through the store + +local dressedFs = memfs() +local dressed = select(2, bytesOf({ id = "dressed", title = "Dressed", + finish = "holo", speeds = { 1, 2 }, seal = "sealed+", + options = { textSpeed = 1 } })) +local dressedCart, dressedHash = CartStore.install(CartManifest.encode(dressed), + dressedFs) +T.check(dressedCart ~= nil, "a cart with a finish, speeds and settings installs") +T.eq(dressedHash, CartManifest.hash(dressed), "hashing the same as it was written") +T.same(CartStore.get("dressed", dressedFs), dressed, + "and coming back off disk unchanged") + +local dressedReg = SaveData.loadOptions(dressedFs).carts.dressed +T.eq(dressedReg.finish, "holo", "the registry row carries the finish") +T.same(dressedReg.speeds, { 1, 2 }, "and the speed ladder") +T.eq(dressedReg.seal, "sealed+", "and the seal, spelled in full") + +local dressedRow = CartStore.list(dressedFs)[1] +T.eq(dressedRow.finish, "holo", "the list row carries the finish") +T.same(dressedRow.speeds, { 1, 2 }, "and the speed ladder") +T.eq(dressedRow.seal, "sealed+", "and the seal") +T.eq(dressedRow.cart.options.textSpeed, 1, + "and the settings the cart ships, on the parsed cart") +T.eq(SaveData.loadOptions(dressedFs).carts.dressed.hash, dressedHash, + "listing does not rewrite the registry it already agrees with") + +local function rowSet() + return { + { id = "hard_mode", name = "Hard Mode", version = "2.0.0", enabled = true, + github = "ren/hard-mode", + manifest = { id = "hard_mode", version = "2.0.0", + github = "ren/hard-mode", sha256 = SHA } }, + { id = "off_mode", name = "Off Mode", version = "1.0.0", enabled = false, + github = "ren/off-mode", + manifest = { id = "off_mode", version = "1.0.0", + github = "ren/off-mode", sha256 = SHA2 } }, + { id = "rare_soda", name = "Rare Soda", version = "0.4.1", enabled = true, + github = "ren/rare-soda", + manifest = { id = "rare_soda", version = "0.4.1", + github = "ren/rare-soda" } }, + { id = "sprite_pack", name = "Sprite Pack", version = "beta", enabled = true, + manifest = { id = "sprite_pack", version = "beta" } }, + } +end + +local identity = { id = "my_cart", title = "My Cart", version = "0.1.0", + author = "Ren", base = "red", shell = "#FF8800", + seal = "open", summary = "Built in the launcher" } +local modOptions = { + rare_soda = { flavour = "grape", sweetness = 3, nested = { 1, 2 } }, + off_mode = { unused = true }, +} + +local captured, unresolved = CartStore.capture(identity, rowSet(), modOptions) +T.check(captured ~= nil, "capture builds a cart: " .. tostring(unresolved)) +T.eq(captured.id, "my_cart", "the captured cart keeps the identity id") +T.eq(captured.title, "My Cart", "the captured cart keeps the title") +T.eq(captured.shell, "#ff8800", "the captured shell normalises") +T.eq(captured.seal, "open", "the captured seal is the author's choice") +T.eq(captured.base, "red", "the captured base is the identity's") +T.eq(#captured.mods, 4, "every installed mod is pinned, switched on or off") +T.eq(captured.load_order[1], "hard_mode", "load order follows the row order") +T.eq(captured.load_order[2], "off_mode", "including the row that is switched off") +T.eq(captured.load_order[3], "rare_soda", "load order follows the row order") +T.eq(captured.load_order[4], "sprite_pack", "load order follows the row order") + +T.eq(captured.mods[1].source, "github", "a mod with repo, version and hash pins to github") +T.eq(captured.mods[1].repo, "ren/hard-mode", "the github pin keeps the repo") +T.eq(captured.mods[1].sha256, SHA, "the github pin keeps the recorded hash") +T.eq(captured.mods[1].enabled, nil, "an enabled mod pins with no enabled field") +T.eq(CartManifest.modEnabled(captured.mods[1]), true, "and reads back as enabled") +T.eq(captured.mods[2].id, "off_mode", "the switched-off mod is pinned in place") +T.eq(captured.mods[2].enabled, false, "and is pinned switched off") +T.eq(CartManifest.modEnabled(captured.mods[2]), false, "which reads back as off") +T.eq(captured.mods[2].source, "github", "a disabled pin still resolves its source") +T.eq(captured.mods[2].sha256, SHA2, "and still carries its archive hash") +T.eq(captured.mods[2].options.unused, true, + "a disabled mod's options are captured like any other") +T.eq(captured.mods[3].source, "local", "a mod with no archive hash pins locally") +T.eq(captured.mods[3].version, "0.4.1", "the local pin keeps the installed version") +T.eq(captured.mods[3].repo, nil, "a local pin carries no repo") +T.eq(captured.mods[3].sha256, nil, "a local pin carries no hash") +T.eq(captured.mods[3].options.flavour, "grape", "the author's option values are frozen in") +T.eq(captured.mods[3].options.sweetness, 3, "every scalar option is frozen in") +T.eq(captured.mods[3].options.nested, nil, "a table option value is dropped") +T.eq(captured.mods[4].source, "local", "a mod with no repo pins locally") +T.eq(captured.mods[4].version, "0.0.0", "an unparsable version pins as 0.0.0") +T.eq(captured.mods[1].options, nil, "a mod with no options freezes none") + +T.eq(#unresolved, 2, "capture reports every locally pinned mod") +T.eq(unresolved[1].id, "rare_soda", "the first unresolved mod is named") +T.eq(unresolved[1].name, "Rare Soda", "the unresolved row carries the mod name") +T.check(unresolved[1].reason:find("archive hash", 1, true) ~= nil, + "a missing hash is the reason (got " .. tostring(unresolved[1].reason) .. ")") +T.eq(unresolved[2].id, "sprite_pack", "the second unresolved mod is named") +T.check(unresolved[2].reason:find("GitHub repo", 1, true) ~= nil, + "a missing repo is a reason (got " .. tostring(unresolved[2].reason) .. ")") +T.check(unresolved[2].reason:find("semantic version", 1, true) ~= nil, + "an unpinnable version is a reason (got " .. tostring(unresolved[2].reason) .. ")") + +local publishable, why = CartManifest.publishable(captured) +T.eq(publishable, false, "a captured cart with local pins cannot be published") +T.check(why:find("rare_soda", 1, true) ~= nil, "the reason names rare_soda") +T.check(why:find("sprite_pack", 1, true) ~= nil, "the reason names sprite_pack") +T.check(why:find("hard_mode", 1, true) == nil, "the reason leaves the pinned mod out") + +local storeFs = memfs() +local roundTrip, roundHash = CartStore.install(CartManifest.encode(captured), storeFs) +T.check(roundTrip ~= nil, "a captured cart installs: " .. tostring(roundHash)) +T.same(roundTrip, captured, "a captured cart survives the file round trip") +T.eq(roundHash, CartManifest.hash(captured), "a captured cart hashes the same on disk") + +local pinned = rowSet() +pinned[3].manifest.sha256 = SHA2 +pinned[4] = nil +local full, fullUnresolved = CartStore.capture(identity, pinned, modOptions) +T.check(full ~= nil, "a fully pinned capture builds a cart") +T.eq(#fullUnresolved, 0, "a fully pinned capture reports nothing unresolved") +T.eq(full.mods[3].source, "github", "a recorded hash promotes the pin to github") +T.eq(full.mods[3].sha256, SHA2, "the promoted pin uses the recorded hash") +T.eq(CartManifest.publishable(full), true, "a fully pinned cart is publishable") + +local offOnly = CartStore.capture(identity, { rowSet()[2] }, modOptions) +T.check(offOnly ~= nil, "a capture of nothing but switched-off mods still builds") +T.eq(#offOnly.mods, 1, "pinning the one mod it was given") +T.eq(offOnly.mods[1].enabled, false, "switched off") +T.eq(offOnly.mods[1].options.unused, true, "with its settings kept") + +local noMods, noModsErr = CartStore.capture(identity, {}, modOptions) +T.eq(noMods, nil, "a capture with no mods at all is refused") +T.check(type(noModsErr) == "string" and noModsErr:find("cart must pin", 1, true) ~= nil, + "the empty capture says why (got " .. tostring(noModsErr) .. ")") +T.eq(CartStore.capture({ id = "bad id" }, rowSet(), modOptions), nil, + "a capture with a bad identity is refused") +T.eq(CartStore.capture(nil, rowSet(), modOptions), nil, + "a capture with no identity is refused") + +local PNG = CartManifest.PNG_SIGNATURE .. "\0\0\0\13IHDRa cart label" +local ART_DATA = Base64.encode(PNG) + +local function artedCart() + local plain = select(2, bytesOf({ id = "art_cart", title = "Art Cart", + label = "label.png" })) + plain.labelArt = { name = "label.png", encoding = "base64", bytes = #PNG, + data = ART_DATA } + return plain +end + +local artFs = memfs() +local artFixture = artedCart() +local packed = CartManifest.encode(artFixture) +local artInstalled, artHash = CartStore.install(packed, artFs) +T.check(artInstalled ~= nil, "a cart with label art installs: " .. tostring(artHash)) +T.eq(artInstalled.labelArt.data, ART_DATA, "install returns the cart with its art") +T.check(artFs.files["carts/art_cart.g1rcart"]:find(ART_DATA, 1, true) ~= nil, + "install writes the art payload to the cart file") +T.same(CartStore.get("art_cart", artFs), artFixture, + "the installed cart reads back with its art") + +local artless = select(2, bytesOf({ id = "art_cart", title = "Art Cart", + label = "label.png" })) +T.eq(artHash, CartManifest.hash(artless), + "the art is not part of the hash a save pins itself to") + +local artExported, artExportHash = CartStore.export("art_cart", artFs) +T.eq(artExportHash, artHash, "export reports the same hash for an arted cart") +T.eq(artExported, packed, "export hands back the bytes that were packed") +local reread = CartManifest.decode(artExported) +T.same(reread, artFixture, "the exported bytes decode to the same cart and art") +T.eq(reread.labelArt.data, ART_DATA, "the exported payload is byte identical") + +local sharedFs = memfs() +T.check(CartStore.install(artExported, sharedFs) ~= nil, + "an exported cart installs somewhere else") +T.same(CartStore.get("art_cart", sharedFs), artFixture, + "pack, install, export and install again leaves the cart unchanged") + +local shownBytes, shownName = CartStore.labelArt("art_cart", sharedFs) +T.eq(shownBytes, PNG, "labelArt hands back the decoded PNG") +T.eq(shownName, "label.png", "labelArt hands back the art name") +T.eq(CartStore.labelArt("nothing_here", sharedFs), nil, + "labelArt refuses an unknown id") +T.eq(CartStore.labelArt("../etc/passwd", sharedFs), nil, + "labelArt refuses a climbing id") + +local plainFs = memfs() +T.check(CartStore.install(CartManifest.encode(artless), plainFs) ~= nil, + "a cart with no art still installs") +T.eq(CartStore.labelArt("art_cart", plainFs), nil, + "a cart with no art has no label art") +T.same(CartStore.get("art_cart", plainFs), artless, + "a cart with no art round trips exactly as before") + +local tamperedBytes = (packed:gsub("bytes = " .. #PNG, "bytes = " .. (#PNG + 1), 1)) +T.neq(tamperedBytes, packed, "the tampered bundle really changed") +local tamperedFs = memfs() +local tampered, tamperedErr = CartStore.install(tamperedBytes, tamperedFs) +T.check(tampered ~= nil, "bad art never fails the install: " .. tostring(tamperedErr)) +T.eq(tampered.labelArt, nil, "the bad art is dropped") +T.eq(CartStore.labelArt("art_cart", tamperedFs), nil, + "the installed cart shows no art") +T.eq(tamperedFs.files["carts/art_cart.g1rcart"], CartManifest.encode(artless), + "the bad art is not written back to disk") +T.eq(#CartStore.list(tamperedFs), 1, "the cart still lists without its art") + +T.finish("cart_store") diff --git a/tests/engine/copyright_splash_backdrop.lua b/tests/engine/copyright_splash_backdrop.lua new file mode 100644 index 00000000..9e82b458 --- /dev/null +++ b/tests/engine/copyright_splash_backdrop.lua @@ -0,0 +1,45 @@ +-- Crystal's boot copyright card is white text on black: SplashScreen sets +-- SCGB_GAMEFREAK_LOGO before calling Copyright, so the card runs on +-- PREDEFPAL_GAMEFREAK_LOGO_BG rather than the default BGP Gold's DMG boot +-- leaves up (../pokecrystal/engine/movie/splash.asm:21-30). The card used to +-- hardcode a white fill and Crystal shipped no extracted splash at all, so the +-- first screen of the game was blank white. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local CopyrightSplash = require("src.ui.gen2.CopyrightSplash") + +local function colorsEq(got, want, message) + T.eq(#got, 3, message .. " (three channels)") + for index = 1, 3 do + T.eq(got[index], want[index], message .. " channel " .. index) + end +end + +local gold = CopyrightSplash.new({}, { title = {} }) +colorsEq(gold.backdrop, { 1, 1, 1 }, "Gold keeps the white card") +colorsEq(gold.ink, { 0, 0, 0 }, "Gold keeps black text") + +local crystal = CopyrightSplash.new({}, { + title = { + copyrightBackdrop = { 0, 0, 0 }, + copyrightInk = { 1, 1, 1 }, + }, +}) +colorsEq(crystal.backdrop, { 0, 0, 0 }, "Crystal's card is black") +colorsEq(crystal.ink, { 1, 1, 1 }, "Crystal's text is white") + +-- The extractor has to emit the image too, or the card is an empty backdrop. +local CrystalMovie = require("src.import.CrystalMovie") +T.eq(type(CrystalMovie.extractTitle), "function", + "CrystalMovie still owns the Crystal title stage") + +local CacheContract = require("src.import.CacheContract") +local required = CacheContract.VERSION_REQUIRED_FILES_OVERRIDE.crystal +local found = false +for _, path in ipairs(required) do + if path == "assets/generated/title/copyright_splash.png" then found = true end +end +T.eq(found, true, "a Crystal cache without the splash is rebuilt") + +T.finish("copyright splash backdrop") diff --git a/tests/engine/crystal_version_test.lua b/tests/engine/crystal_version_test.lua new file mode 100644 index 00000000..cddcacd2 --- /dev/null +++ b/tests/engine/crystal_version_test.lua @@ -0,0 +1,215 @@ +-- Crystal registration: VERSIONS row, engine lineage, ORDER slot, sha1 +-- routing, the importer's required-file override and the script dialect. +-- luajit tests/engine/crystal_version_test.lua +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local S = require("tests.harness").suite("crystal version registration") +local check = S.check +local eq = S.eq + +local GameVersion = require("src.core.GameVersion") +local Opcodes = require("src.script.gen2.Opcodes") + +-- ------- 1. the VERSIONS row + +local row = GameVersion.VERSIONS.crystal +check(row ~= nil, "GameVersion.VERSIONS carries a crystal row") +eq(row.id, "crystal", "row id") +eq(row.label, "Crystal", "row label") +eq(row.displayName, "Pokemon Crystal", "row display name") +eq(row.sha1, "f4cd194bdee0d04ca4eac29e09b8e4e9d818c133", "retail Crystal sha1") +eq(row.manifest, "tools/rom_manifest_crystal.json", "row manifest path") +eq(row.cachePrefix, "crystal/", "cache prefix") +eq(row.saveSuffix, "_crystal", "save suffix") +eq(GameVersion.cachePrefix("crystal"), "crystal/", "cachePrefix() agrees") +eq(GameVersion.saveSuffix("crystal"), "_crystal", "saveSuffix() agrees") + +local prefixes, suffixes = {}, {} +for _, id in ipairs(GameVersion.ORDER) do + local info = GameVersion.info(id) + eq(prefixes[info.cachePrefix], nil, id .. " cache prefix is unique") + eq(suffixes[info.saveSuffix], nil, id .. " save suffix is unique") + prefixes[info.cachePrefix] = id + suffixes[info.saveSuffix] = id +end + +-- ------- 2. generation and engine lineage + +eq(GameVersion.generation("crystal"), 2, "Crystal is Gen 2") +eq(GameVersion.engine("crystal"), "crystal", "Crystal's engine lineage") +eq(GameVersion.engine("gold"), "gs", "Gold is the gs lineage") +eq(GameVersion.engine("silver"), "gs", "and so is Silver") +eq(GameVersion.engine("red"), "gen1", "Red is gen1") +eq(GameVersion.engine("blue"), "gen1", "Blue is gen1") +eq(GameVersion.engine("yellow"), "gen1", "Yellow is gen1") + +local LINEAGES = { gen1 = true, gs = true, crystal = true } +for _, id in ipairs(GameVersion.ORDER) do + check(LINEAGES[GameVersion.engine(id)] == true, + id .. " reports a known engine lineage") +end + +eq(GameVersion.generation("gold"), 2, "Gold is Gen 2") +eq(GameVersion.generation("silver"), 2, "Silver is Gen 2") +eq(GameVersion.generation("red"), 1, "Red is Gen 1") + +-- ------- 3. launcher ORDER + +local index +for i, id in ipairs(GameVersion.ORDER) do + if id == "crystal" then index = i end +end +eq(index, 6, "crystal is ORDER slot 6") +eq(#GameVersion.ORDER, 6, "ORDER is six games") +eq(GameVersion.ORDER[4], "gold", "gold keeps slot 4") +eq(GameVersion.ORDER[5], "silver", "silver keeps slot 5") + +-- ------- 4. sha1 routing + +eq(GameVersion.forSha1("f4cd194bdee0d04ca4eac29e09b8e4e9d818c133"), "crystal", + "the retail Crystal sha1 resolves to crystal") +eq(GameVersion.forSha1("d8b8a3600a465308c9953dfa04f0081c05bdcb94"), "gold", + "Gold's sha1 still resolves to gold") +eq(GameVersion.forSha1("deadbeef"), nil, "an unknown ROM resolves to nothing") + +-- ------- 5. set / get round trip + +local savedCurrent = GameVersion.get() +eq(GameVersion.set("crystal"), "crystal", "set('crystal') is accepted") +eq(GameVersion.get(), "crystal", "and becomes current") +eq(GameVersion.engine(), "crystal", "engine() with no argument reads current") +check(not GameVersion.isGold(), "isGold() stays false on Crystal") +GameVersion.set(savedCurrent) + +-- ------- 6. the importer's required-file list + +local CacheContract = require("src.import.CacheContract") + +local function requiredSet(version) + local prefix = version == "red" and "" or GameVersion.cachePrefix(version) + local required, isOverride = CacheContract.requiredFilesFor(version) + local seen, count = {}, 0 + local function add(path) + if not seen[prefix .. path] then count = count + 1 end + seen[prefix .. path] = true + end + for _, path in ipairs(required) do add(path) end + if not isOverride then + for _, path in ipairs(CacheContract.VERSION_REQUIRED_FILES[version] or {}) do + add(path) + end + end + return seen, count +end + +local crystalSeen, crystalCount = requiredSet("crystal") +local redSeen = requiredSet("red") + +check(select(2, CacheContract.requiredFilesFor("crystal")) == true, + "crystal has its own required-file override, not the Gen 1 list") +check(crystalCount > 20, + ("the crystal list is substantial (%d entries)"):format(crystalCount)) +for _, path in ipairs({ + "crystal/data/generated/encounters.lua", + "crystal/data/generated/landmarks.lua", + "crystal/data/generated/title.lua", + "crystal/data/generated/intro.lua", + "crystal/assets/generated/title/crystal_logo.png", + "crystal/assets/generated/title/crystal_wordmark.png", + "crystal/assets/generated/title/crystal_suicune.png", + "crystal/assets/generated/splash/ditto.png", + "crystal/assets/generated/intro/chris.png", + "crystal/assets/generated/intro/kris.png", + "crystal/assets/generated/battle/front/wooper.png", +}) do + check(crystalSeen[path] == true, "crystal requires " .. path) +end + +for _, path in ipairs({ + "crystal/assets/generated/battle/anims/move_anim_0.png", + "crystal/assets/generated/battle/anims/move_anim_1.png", + "crystal/data/generated/battle_anims.lua", + "crystal/assets/generated/trade/game_boy.png", +}) do + eq(crystalSeen[path], nil, "crystal does not wait on " .. path) +end + +check(redSeen["assets/generated/battle/anims/move_anim_0.png"] == true, + "red still requires the Gen 1 battle anim sheet") +eq(redSeen["assets/generated/title/crystal_logo.png"], nil, + "and none of Crystal's title art") + +-- ------- 7. script dialect + +local crystalTable = Opcodes.forEdition("crystal") +local goldTable = Opcodes.forEdition("gold") +check(crystalTable ~= goldTable, + "forEdition('crystal') is not the Gold table") +eq(Opcodes.forEdition("silver"), goldTable, "Silver shares Gold's table") +eq(goldTable, Opcodes, "and Gold's table is the module itself") +eq(Opcodes.forEdition(nil), Opcodes, "an absent edition falls back to Gold") + +-- pokecrystal/macros/scripts/events.asm:541 +eq(crystalTable[0x52] and crystalTable[0x52].name, "farjumptext", + "Crystal $52 is farjumptext") +eq(goldTable[0x52] and goldTable[0x52].name, "jumptext", + "Gold $52 is jumptext") +eq(crystalTable[0x53] and crystalTable[0x53].name, "jumptext", + "and Crystal's jumptext moved to $53") + +local function count(tbl) + local n = 0 + for key in pairs(tbl) do if type(key) == "number" then n = n + 1 end end + return n +end +eq(count(crystalTable), 170, "Crystal names 170 commands") +eq(count(goldTable), 162, "Gold names 162") + +local diverged +for byte = 0x00, 0x51 do + local a = crystalTable[byte] and crystalTable[byte].name + local b = goldTable[byte] and goldTable[byte].name + if a ~= b then diverged = byte; break end +end +eq(diverged, nil, "$00-$51 are the same commands in both dialects") + +check(Opcodes.TERMINATORS.farjumptext == true, + "farjumptext is a terminator") + +-- ------- 8. the launcher reaches the crystal tab + +local RomImporter = require("src.import.RomImporter") + +local visited = {} +local fake = setmetatable({ tab = GameVersion.ORDER[1] }, RomImporter) +fake._switchTab = function(self, id) + self.tab = id + visited[#visited + 1] = id +end + +for _ = 1, 12 do fake:_cycleTab(1) end +local sawCrystal, sawMods, sawBug = false, false, false +for _, id in ipairs(visited) do + if id == "crystal" then sawCrystal = true end + if id == "mods" then sawMods = true end + if id == "bug" then sawBug = true end +end +check(sawCrystal, "cycling the launcher tabs reaches crystal") +check(sawMods and sawBug, "and still reaches the mods and bug tabs") + +local seen, cycle = {}, 0 +fake.tab = "crystal" +repeat + seen[fake.tab] = true + fake:_cycleTab(1) + cycle = cycle + 1 +until fake.tab == "crystal" or cycle > 40 +eq(cycle, #GameVersion.ORDER + 4, + "the ring is the six games plus mods/find/skins/bug") + +fake.tab = "crystal" +fake:_cycleTab(-1) +eq(fake.tab, "silver", "and stepping back off crystal lands on silver") + +S.finish() diff --git a/tests/engine/game_speed_categories_test.lua b/tests/engine/game_speed_categories_test.lua index 57bcd86f..6303fd42 100644 --- a/tests/engine/game_speed_categories_test.lua +++ b/tests/engine/game_speed_categories_test.lua @@ -193,4 +193,31 @@ do eq(g.save.options.speedMenu, 2, "cycling in a menu bumps speedMenu") end +-- A cart may narrow the ladder (CartManifest's `speeds`), and returning to +-- the launcher must put it back. +do + local GameSpeed = require("src.core.GameSpeed") + eq(#GameSpeed.allowed(), #GameSpeed.LEVELS, "no cart means the full ladder") + check(not GameSpeed.isLocked(), "and nothing is pinned") + + GameSpeed.setAllowed({ 1, 2 }) + eq(#GameSpeed.allowed(), 2, "a cart narrows the ladder") + eq(GameSpeed.cycle(1, 1), 2, "cycling stays inside the cart's levels") + eq(GameSpeed.cycle(2, 1), 1, "and wraps within them") + eq(GameSpeed.clamp(100), 2, "a value past the cart's top clamps into it") + check(not GameSpeed.isLocked(), "two levels is narrowed, not pinned") + + GameSpeed.setAllowed({ 1 }) + check(GameSpeed.isLocked(), "one level reads as pinned") + eq(GameSpeed.cycle(1, 1), 1, "and cycling cannot leave it") + + GameSpeed.setAllowed({ 1, 7 }) + eq(#GameSpeed.allowed(), 1, "a level that is not on the ladder is dropped") + + GameSpeed.setAllowed(nil) + eq(#GameSpeed.allowed(), #GameSpeed.LEVELS, + "leaving the cart restores the full ladder") + eq(GameSpeed.cycle(4, 1), 10, "and cycling reaches the levels again") +end + T.finish("game_speed_categories") diff --git a/tests/engine/gate_gen2_mod_api.lua b/tests/engine/gate_gen2_mod_api.lua index 01558e97..ad632b2c 100644 --- a/tests/engine/gate_gen2_mod_api.lua +++ b/tests/engine/gate_gen2_mod_api.lua @@ -24,6 +24,7 @@ T.eq(GameVersion.generation("blue"), 1, "Blue is Gen 1") T.eq(GameVersion.generation("yellow"), 1, "Yellow is Gen 1") T.eq(GameVersion.generation("gold"), 2, "Gold is Gen 2") T.eq(GameVersion.generation("silver"), 2, "Silver is Gen 2") +T.eq(GameVersion.generation("crystal"), 2, "Crystal is Gen 2") -- ------- 2. manifest: gen2compat is opt-in and defaults off diff --git a/tests/engine/gen2_script_opcodes_test.lua b/tests/engine/gen2_script_opcodes_test.lua new file mode 100644 index 00000000..566d8c2e --- /dev/null +++ b/tests/engine/gen2_script_opcodes_test.lua @@ -0,0 +1,292 @@ +-- Gen 2 script bytecode dialects: Gold/Silver vs Crystal. +-- +-- Crystal inserts farjumptext at $52 and pushes every later opcode up by one +-- (pokecrystal/macros/scripts/events.asm:541). Decoding a Crystal script with +-- the Gold table is silent: a Crystal $53 `jumptext` (2 operand bytes) reads as +-- Gold's `waitbutton` (0), the pointer walk desynchronises, and the extractor +-- emits plausible garbage rather than an error. So the expected tables below +-- are transcribed from the two macro files by hand and pinned here. +-- luajit tests/engine/gen2_script_opcodes_test.lua +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check = T.check +local eq = T.eq + +local Opcodes = require("src.script.gen2.Opcodes") + +-- pokegold/macros/scripts/events.asm:1-1015, in const order from $00. +-- Sizes are the macro body minus its own `db _command`: db 1, dw 2, +-- dba 3, bigdt 3, map_id 2 (pokegold/macros/scripts/maps.asm:1-6). +-- `givepoke` is the one variable-length row: the macro emits 8 bytes when the +-- trainer argument is non-zero (events.asm:352-365) and the table declares the +-- 4-byte base, which src/import/RomExtractorGen2.lua re-measures per call site. +local GOLD_EXPECTED = { + "scall 2", "farscall 3", "memcall 2", "sjump 2", "farsjump 3", + "memjump 2", "ifequal 3", "ifnotequal 3", "iffalse 2", "iftrue 2", + "ifgreater 3", "ifless 3", "jumpstd 2", "callstd 2", "callasm 3", + "special 2", "memcallasm 2", "checkmapscene 2", "setmapscene 3", + "checkscene 0", "setscene 1", "setval 1", "addval 1", "random 1", + "checkver 0", "readmem 2", "writemem 2", "loadmem 3", "readvar 1", + "writevar 1", "loadvar 2", "giveitem 2", "takeitem 2", "checkitem 1", + "givemoney 4", "takemoney 4", "checkmoney 4", "givecoins 2", + "takecoins 2", "checkcoins 2", "addcellnum 1", "delcellnum 1", + "checkcellnum 1", "checktime 1", "checkpoke 1", "givepoke 4", + "giveegg 2", "givepokemail 2", "checkpokemail 2", "checkevent 2", + "clearevent 2", "setevent 2", "checkflag 2", "clearflag 2", "setflag 2", + "wildon 0", "wildoff 0", "xycompare 2", "warpmod 3", "blackoutmod 2", + "warp 4", "getmoney 2", "getcoins 1", "getnum 1", "getmonname 2", + "getitemname 2", "getcurlandmarkname 1", "gettrainername 3", + "getstring 3", "itemnotify 0", "pocketisfull 0", "opentext 0", + "reanchormap 1", "closetext 0", "writeunusedbyte 1", "farwritetext 3", + "writetext 2", "repeattext 2", "yesorno 0", "loadmenu 2", + "closewindow 0", "jumptextfaceplayer 2", "jumptext 2", "waitbutton 0", + "promptbutton 0", "pokepic 1", "closepokepic 0", "_2dmenu 0", + "verticalmenu 0", "loadpikachudata 0", "randomwildmon 0", + "loadtemptrainer 0", "loadwildmon 2", "loadtrainer 2", "startbattle 0", + "reloadmapafterbattle 0", "catchtutorial 1", "trainertext 1", + "trainerflagaction 1", "winlosstext 4", "scripttalkafter 0", + "endifjustbattled 0", "checkjustbattled 0", "setlasttalked 1", + "applymovement 3", "applymovementlasttalked 2", "faceplayer 0", + "faceobject 2", "variablesprite 2", "disappear 1", "appear 1", + "follow 2", "stopfollow 0", "moveobject 3", "writeobjectxy 1", + "loademote 1", "showemote 3", "turnobject 2", "follownotexact 2", + "earthquake 1", "changemapblocks 3", "changeblock 3", "reloadmap 0", + "refreshmap 0", "writecmdqueue 2", "delcmdqueue 1", "playmusic 2", + "encountermusic 0", "musicfadeout 3", "playmapmusic 0", + "dontrestartmapmusic 0", "cry 2", "playsound 2", "waitsfx 0", + "warpsound 0", "specialsound 0", "autoinput 3", "newloadmap 1", + "pause 1", "deactivatefacing 1", "sdefer 2", "warpcheck 0", + "stopandsjump 2", "endcallback 0", "end 0", "reloadend 1", "endall 0", + "pokemart 3", "elevator 2", "trade 1", "askforphonenumber 1", + "phonecall 2", "hangup 0", "describedecoration 1", "fruittree 1", + "specialphonecall 2", "checkphonecall 0", "verbosegiveitem 2", "swarm 2", + "halloffame 0", "credits 0", "warpfacing 5", +} + +-- pokecrystal/macros/scripts/events.asm:1-1068, same transcription rules. +-- Cross-checked row for row against ScriptCommandTable +-- (pokecrystal/engine/overworld/scripting.asm:64-237), which is the table the +-- hardware actually indexes and therefore the authority over the macro file. +local CRYSTAL_EXPECTED = { + "scall 2", "farscall 3", "memcall 2", "sjump 2", "farsjump 3", + "memjump 2", "ifequal 3", "ifnotequal 3", "iffalse 2", "iftrue 2", + "ifgreater 3", "ifless 3", "jumpstd 2", "callstd 2", "callasm 3", + "special 2", "memcallasm 2", "checkmapscene 2", "setmapscene 3", + "checkscene 0", "setscene 1", "setval 1", "addval 1", "random 1", + "checkver 0", "readmem 2", "writemem 2", "loadmem 3", "readvar 1", + "writevar 1", "loadvar 2", "giveitem 2", "takeitem 2", "checkitem 1", + "givemoney 4", "takemoney 4", "checkmoney 4", "givecoins 2", + "takecoins 2", "checkcoins 2", "addcellnum 1", "delcellnum 1", + "checkcellnum 1", "checktime 1", "checkpoke 1", "givepoke 4", + "giveegg 2", "givepokemail 2", "checkpokemail 2", "checkevent 2", + "clearevent 2", "setevent 2", "checkflag 2", "clearflag 2", "setflag 2", + "wildon 0", "wildoff 0", "xycompare 2", "warpmod 3", "blackoutmod 2", + "warp 4", "getmoney 2", "getcoins 1", "getnum 1", "getmonname 2", + "getitemname 2", "getcurlandmarkname 1", "gettrainername 3", + "getstring 3", "itemnotify 0", "pocketisfull 0", "opentext 0", + "reanchormap 1", "closetext 0", "writeunusedbyte 1", "farwritetext 3", + "writetext 2", "repeattext 2", "yesorno 0", "loadmenu 2", + "closewindow 0", "jumptextfaceplayer 2", "farjumptext 3", "jumptext 2", + "waitbutton 0", "promptbutton 0", "pokepic 1", "closepokepic 0", + "_2dmenu 0", "verticalmenu 0", "loadpikachudata 0", "randomwildmon 0", + "loadtemptrainer 0", "loadwildmon 2", "loadtrainer 2", "startbattle 0", + "reloadmapafterbattle 0", "catchtutorial 1", "trainertext 1", + "trainerflagaction 1", "winlosstext 4", "scripttalkafter 0", + "endifjustbattled 0", "checkjustbattled 0", "setlasttalked 1", + "applymovement 3", "applymovementlasttalked 2", "faceplayer 0", + "faceobject 2", "variablesprite 2", "disappear 1", "appear 1", + "follow 2", "stopfollow 0", "moveobject 3", "writeobjectxy 1", + "loademote 1", "showemote 3", "turnobject 2", "follownotexact 2", + "earthquake 1", "changemapblocks 3", "changeblock 3", "reloadmap 0", + "refreshmap 0", "writecmdqueue 2", "delcmdqueue 1", "playmusic 2", + "encountermusic 0", "musicfadeout 3", "playmapmusic 0", + "dontrestartmapmusic 0", "cry 2", "playsound 2", "waitsfx 0", + "warpsound 0", "specialsound 0", "autoinput 3", "newloadmap 1", + "pause 1", "deactivatefacing 1", "sdefer 2", "warpcheck 0", + "stopandsjump 2", "endcallback 0", "end 0", "reloadend 1", "endall 0", + "pokemart 3", "elevator 2", "trade 1", "askforphonenumber 1", + "phonecall 2", "hangup 0", "describedecoration 1", "fruittree 1", + "specialphonecall 2", "checkphonecall 0", "verbosegiveitem 2", + "verbosegiveitemvar 2", "swarm 3", "halloffame 0", "credits 0", + "warpfacing 5", "battletowertext 1", "getlandmarkname 2", + "gettrainerclassname 2", "getname 3", "wait 1", "checksave 0", +} + +local gold = Opcodes.forEdition("gold") +local crystal = Opcodes.forEdition("crystal") + +-- CT-1: gold and silver share one dialect, and Opcodes[byte] keeps answering. +check(Opcodes.forEdition("silver") == gold, "silver resolves to the Gold table") +check(gold == Opcodes, "the Gold table is the module itself (Opcodes[byte])") +check(crystal ~= gold, "crystal resolves to a different table") +check(Opcodes.forEdition(nil) == gold, "an unknown edition falls back to Gold") +eq(Opcodes[0x52] and Opcodes[0x52].name, "jumptext", + "Opcodes[0x52] is unchanged for existing callers") + +local function auditTable(label, tbl, expected) + local holes, wrong = {}, {} + for i, want in ipairs(expected) do + local byte = i - 1 + local name, size = want:match("^(%S+) (%d+)$") + local row = tbl[byte] + if not row then + holes[#holes + 1] = ("$%02x"):format(byte) + elseif row.name ~= name or row.size ~= tonumber(size) then + wrong[#wrong + 1] = ("$%02x %s/%d wanted %s/%s") + :format(byte, tostring(row.name), row.size or -1, name, size) + end + end + eq(#holes, 0, label .. " has no missing opcode (" .. table.concat(holes, " ") + .. ")") + eq(#wrong, 0, label .. " matches events.asm (" .. table.concat(wrong, "; ") + .. ")") + local extra = {} + for byte = 0x00, 0xff do + if tbl[byte] and byte >= #expected then + extra[#extra + 1] = ("$%02x %s"):format(byte, tbl[byte].name) + end + end + eq(#extra, 0, label .. " declares nothing past the last command (" + .. table.concat(extra, " ") .. ")") +end + +auditTable("gold", gold, GOLD_EXPECTED) +auditTable("crystal", crystal, CRYSTAL_EXPECTED) + +-- (a) $00-$51 is byte-identical between the dialects. +local drift = {} +for byte = 0x00, 0x51 do + local g, c = gold[byte], crystal[byte] + if not (g and c and g.name == c.name and g.size == c.size) then + drift[#drift + 1] = ("$%02x"):format(byte) + end +end +eq(#drift, 0, "$00-$51 is identical in both dialects (" + .. table.concat(drift, " ") .. ")") + +-- and the two dialects agree on NOTHING from $52 up, because the whole tail is +-- shifted by one. farjumptext is the wedge. +eq(crystal[0x52].name, "farjumptext", "$52 is farjumptext on Crystal") +eq(crystal[0x52].size, 3, "farjumptext carries a dba, so 3 operand bytes") +eq(crystal[0x53].name, "jumptext", "Gold's $52 jumptext moved to $53") +eq(crystal[0xa0].size, 3, + "Crystal swarm gained a leading flag byte (events.asm:1003-1008)") +eq(gold[0x9e].size, 2, "Gold swarm is still a bare map_id") + +-- (c) NUM_EVENT_COMMANDS, both as the declared constant and as the row count. +local function rowCount(tbl) + local n = 0 + for byte = 0x00, 0xff do if tbl[byte] then n = n + 1 end end + return n +end +eq(Opcodes.NUM_EVENT_COMMANDS, 162, + "pokegold events.asm:1015 NUM_EVENT_COMMANDS = $a2") +eq(crystal.NUM_EVENT_COMMANDS, 170, + "pokecrystal events.asm:1068 NUM_EVENT_COMMANDS = $aa") +eq(rowCount(gold), Opcodes.NUM_EVENT_COMMANDS, + "the Gold table is dense up to NUM_EVENT_COMMANDS") +eq(rowCount(crystal), crystal.NUM_EVENT_COMMANDS, + "the Crystal table is dense up to NUM_EVENT_COMMANDS") + +-- (d) farjumptext ends the walk the same way jumptext does. +check(Opcodes.TERMINATORS.farjumptext, + "farjumptext is a TERMINATOR (jp ScriptJump, scripting.asm:318-327)") +check(Opcodes.TERMINATORS.jumptext, "jumptext still is") +check(crystal.TERMINATORS == Opcodes.TERMINATORS, + "the Crystal table answers TERMINATORS too") +eq(crystal.key, Opcodes.key, "and key(), so a resolved table is self-sufficient") + +-- (e) MOD_COMMAND is reachable only by name: no byte in EITHER dialect decodes +-- to it, so ROM data can never be mistaken for a mod verb. +local collide = {} +for byte = 0x00, 0xff do + if gold[byte] and gold[byte].name == Opcodes.MOD_COMMAND then + collide[#collide + 1] = ("gold $%02x"):format(byte) + end + if crystal[byte] and crystal[byte].name == Opcodes.MOD_COMMAND then + collide[#collide + 1] = ("crystal $%02x"):format(byte) + end +end +eq(#collide, 0, "MOD_COMMAND has no byte in either dialect (" + .. table.concat(collide, " ") .. ")") +check(type(Opcodes.MOD_COMMAND) == "string" and Opcodes.MOD_COMMAND ~= "", + "MOD_COMMAND is a name, not a byte") + +-- The Vm side of the dialect: every Crystal-only verb has a branch, and the +-- shifted `swarm` reads its flag rather than its map group. +love = require("tests.love_stub") +local Vm = require("src.script.gen2.Vm") +local Events = require("src.world.gen2.Events") + +do + local seen, swarmArgs = {}, nil + local events = Events.new() + local vm = Vm.new({ + generation = 2, + ["s:crystal"] = { + -- pokecrystal/macros/scripts/events.asm:1003-1008: flag, then map_id. + { op = "swarm", args = { 1, 24, 3 } }, + { op = "checksave" }, + { op = "wait", args = { 2 } }, + { op = "getlandmarkname", args = { 5, 3 } }, + { op = "gettrainerclassname", args = { 9, 3 } }, + { op = "getname", args = { 1, 152, 3 } }, + { op = "verbosegiveitemvar", args = { 20, 7 } }, + { op = "farjumptext", text = "t:far" }, + { op = "setevent", event = 1 }, + }, + }, { ["t:far"] = "Far text." }, events, { + setSwarm = function(group, mapNum, kind) + swarmArgs = { group, mapNum, kind } + end, + checkSave = function() return true end, + getLandmarkName = function(id) seen.landmark = id return "RUINS" end, + getTrainerClassName = function(id) seen.class = id return "SAGE" end, + getMonName = function(id) seen.mon = id return "CHIKORITA" end, + readVar = function(id) seen.var = id return 4 end, + giveItem = function(item, qty) seen.give = { item, qty } return true end, + getItemName = function() return "REPEL" end, + showText = function(body, onDone) seen.text = body onDone() end, + }) + + check(vm:start("s:crystal"), "a Crystal-shaped script starts") + for _ = 1, 40 do vm:update() end + check(not vm:running(), "and runs to completion") + + eq(swarmArgs and swarmArgs[1], 24, "swarm reads the map group from args[2]") + eq(swarmArgs and swarmArgs[2], 3, "and the map number from args[3]") + eq(swarmArgs and swarmArgs[3], 1, "and passes SWARM_YANMA through as the kind") + eq(seen.landmark, 5, "getlandmarkname passes its landmark id to the hook") + eq(seen.class, 9, "gettrainerclassname passes the trainer group") + eq(seen.mon, 152, "getname with MON_NAME routes to the mon-name hook") + eq(seen.var, 7, "verbosegiveitemvar reads the quantity out of a var") + eq(seen.give and seen.give[1], 20, "and gives the item the first byte names") + eq(seen.give and seen.give[2], 4, "at the quantity the var held") + eq(seen.text, "Far text.", "farjumptext prints its text") + eq(next(vm.unknownOps or {}), nil, + "no Crystal verb in the script fell through to the unknown ledger") + -- Script_farjumptext ends on `jp ScriptJump`, so the setevent after it never + -- runs -- the same reading Opcodes.TERMINATORS encodes for the extractor. + check(not events:get(1), + "farjumptext ended the script before the setevent below it") + events:set(1, true) + check(events:get(1), "and the event really is observable when it is set") +end + +do + -- Script_wait is SIX frames per unit (scripting.asm:2336-2347), not + -- Script_pause's two. + local vm = Vm.new({ generation = 2, + ["s:wait"] = { { op = "wait", args = { 3 } }, { op = "end" } }, + }, {}, nil, {}) + check(vm:start("s:wait"), "a lone `wait` starts") + local frames = 0 + while vm:running() and frames < 100 do + vm:update() + frames = frames + 1 + end + eq(frames, 18, "`wait 3` holds the script for 3 * 6 frames") +end + +T.finish() diff --git a/tests/engine/gen2_stats_tiles_bug1558.lua b/tests/engine/gen2_stats_tiles_bug1558.lua index de6fe03c..79ff10da 100644 --- a/tests/engine/gen2_stats_tiles_bug1558.lua +++ b/tests/engine/gen2_stats_tiles_bug1558.lua @@ -21,6 +21,21 @@ for p = 1, 3 do end end +-- gfx/stats/stats.pal, the colour LoadStatsScreenPals drops into colour 0 of +-- BG palettes 0 and 2 (engine/gfx/color.asm:386-390) +local ROM_TINTS = { { 31, 19, 31 }, { 21, 31, 14 }, { 17, 31, 31 } } +for p = 1, 3 do + for ch = 1, 3 do + T.eq(SummaryMenu.PAGE_TINTS[p][ch], up(ROM_TINTS[p][ch]), + ("stats.pal tint %d channel %d"):format(p, ch)) + end + local page = setmetatable({ page = p }, { __index = SummaryMenu }) + local lower = page:lowerColors() + T.eq(lower[1][1], SummaryMenu.PAGE_TINTS[p][1], + ("lower half palette %d takes the page tint as colour 0"):format(p)) + T.eq(lower[4][1], 0, ("lower half palette %d keeps black ink"):format(p)) +end + -- the quads: 17 tiles from $31, one 8x8 cell each off the 136x8 sheet love.graphics = love.graphics or {} local realNewQuad = love.graphics.newQuad @@ -48,4 +63,4 @@ local old = setmetatable({ menuGfx = {} }, { __index = SummaryMenu }) T.check(old:statsTiles() == nil, "no menu_gfx.stats falls back, not crashes") love.graphics.newQuad = realNewQuad -T.finish("gen2 stats tiles and page palettes (#1558)") +T.finish("gen2 stats tiles and page palettes (#1558, #1693)") diff --git a/tests/engine/launcher_gold_touch_rows.lua b/tests/engine/launcher_gold_touch_rows.lua index a09f6b27..de78bc71 100644 --- a/tests/engine/launcher_gold_touch_rows.lua +++ b/tests/engine/launcher_gold_touch_rows.lua @@ -39,7 +39,7 @@ end local edited = 0 local hooks = { editTouchControls = function() edited = edited + 1 end } -for _, version in ipairs({ "gold", "silver" }) do +for _, version in ipairs({ "gold", "silver", "crystal" }) do local model = LauncherSettings.open(hooks, version) check(has(model, "TOUCH PAD"), version .. " gear offers TOUCH PAD") check(has(model, "VIBRATION"), version .. " and VIBRATION") @@ -82,6 +82,7 @@ for _, version in ipairs({ "gold", "silver" }) do version .. " leaving the flat Gen 1 key alone") eq(model.opts.silver, nil, version .. " and inventing no second Gen 2 block beside it") + eq(model.opts.crystal, nil, version .. " nor a third") local buzz = findRow(model, "VIBRATION") local buzzBefore = buzz.value() @@ -114,6 +115,8 @@ eq(has(LauncherSettings.open(nil, "gold"), "TOUCH CONTROLS"), false, "no hook, no editor row on Gold") eq(has(LauncherSettings.open(nil, "silver"), "TOUCH CONTROLS"), false, "nor on Silver") +eq(has(LauncherSettings.open(nil, "crystal"), "TOUCH CONTROLS"), false, + "nor on Crystal") eq(has(LauncherSettings.open(nil, "red"), "TOUCH CONTROLS"), false, "nor on Red") -- The Edit row hands the screen to the host, and the host has to know WHICH diff --git a/tests/engine/launcher_skins_tab.lua b/tests/engine/launcher_skins_tab.lua index 3d410283..76a078bf 100644 --- a/tests/engine/launcher_skins_tab.lua +++ b/tests/engine/launcher_skins_tab.lua @@ -90,11 +90,23 @@ check(imp:find('if self.tab == "skins" then', 1, true) ~= nil, check(imp:find("_installSkinZip", 1, true) ~= nil, "skin zip installer exists") check(imp:find("_installMod", 1, true) ~= nil, "and a zip elsewhere still installs a mod") -local cycle = imp:match("local order = %{(.-)%}") -check(cycle and cycle:find('"skins"', 1, true) ~= nil, +local RomImporter = require("src.import.RomImporter") +local GameVersion = require("src.core.GameVersion") +local cycled, probe = {}, nil +probe = setmetatable({ tab = GameVersion.ORDER[1] }, { __index = RomImporter }) +probe._switchTab = function(self, id) self.tab = id; cycled[#cycled + 1] = id end +for _ = 1, #GameVersion.ORDER + 3 do RomImporter._cycleTab(probe, 1) end +local reached = " " .. table.concat(cycled, " ") .. " " +check(reached:find(" skins ", 1, true) ~= nil, "shoulder-button tab cycling reaches the skins tab") -check(cycle and cycle:find('"bug"', 1, true) ~= nil, +check(reached:find(" bug ", 1, true) ~= nil, "shoulder-button tab cycling reaches the bug tab") +for _, id in ipairs(GameVersion.ORDER) do + if id ~= GameVersion.ORDER[1] then + check(reached:find(" " .. id .. " ", 1, true) ~= nil, + "shoulder-button tab cycling reaches " .. id) + end +end local switch = imp:match("function RomImporter:_switchTab%(id%)(.-)\nend") check(switch and switch:find("_ensureSkins(true)", 1, true) ~= nil, "switching to the tab re-reads the skin list") diff --git a/tests/engine/launcher_skins_ux.lua b/tests/engine/launcher_skins_ux.lua index 3ce36f51..9ee14f87 100644 --- a/tests/engine/launcher_skins_ux.lua +++ b/tests/engine/launcher_skins_ux.lua @@ -265,4 +265,32 @@ check(update and update:find("_pumpSkinFetch", 1, true) ~= nil, check(TouchSkin.ARCHIVE_EXTS.deltaskin == true, "and the installer accepts the extension") +-- Turning skins off must leave the pad on. `enabled` is the pad's own switch, +-- and the button that calls this only exists while a skin is active, which +-- already requires the pad to be enabled. +do + local SaveData = require("src.core.SaveData") + local TouchControls = require("src.core.TouchControls") + + local opts = SaveData.loadOptions() + opts.touchControls = { enabled = true, skin = "some_skin" } + SaveData.saveOptions(opts) + + local imp = RomImporter.new(function() end, { launcher = true }) + imp:_disableSkins() + + local after = SaveData.loadOptions().touchControls + eq(after.skin, nil, "turning skins off clears the selected skin") + check(after.enabled ~= false, "and leaves the touch pad enabled") + + local cfg = TouchControls.normalizeConfig(after) + check(cfg.enabled, "so the pad is on after normalizing") + eq(cfg.skin, nil, "with no skin behind it") + + check(imp._skinNotice ~= nil and imp._skinNotice.ok == true, + "and the notice reports success") + check(tostring(imp._skinNotice.text):find("built-in pad", 1, true) ~= nil, + "promising the built-in pad, which is now what happens") +end + T.finish("launcher_skins_ux") diff --git a/tests/engine/mod_index_tests.lua b/tests/engine/mod_index_tests.lua index 93e1a455..b05d040f 100644 --- a/tests/engine/mod_index_tests.lua +++ b/tests/engine/mod_index_tests.lua @@ -385,4 +385,152 @@ do eq(cats[1], "GAMEPLAY", "and they keep the feed's declared order") end +-- ------- carts: a second array on the same feed, at the same schema_version +-- +-- The published index added carts without bumping schema_version, so an older +-- build has to keep reading the feed and this one has to read both halves. +-- The fixture below is the shape carts/@/meta.json validates to. + +local OMEGA = { + folder = "bryanthaboi@omega_random_competition", + id = "omega_random_competition", + title = "OMEGA RANDOM COMPETITION", + author = "bryanthaboi", + summary = "Bois Club Randomizer as its own cartridge.", + version = "1.0.0", + base = "red", + seal = "sealed", + shell = "#7B1B22", + finish = "holo", + speeds = { 1, 2 }, + tags = { "randomizer", "competition" }, + repo = "https://github.com/bryanthaboi/omega-random-competition", + github = "bryanthaboi/omega-random-competition", + automatic_version_check = true, + mods = { { id = "bcr", source = "github", repo = "bryanthaboi/bcr", + version = "1.0.0", sha256 = ("83a111b4"):rep(8) } }, + load_order = { "bcr" }, + license = "MIT", + description_url = "data/carts/bryanthaboi@omega_random_competition/description.md", + update_check = "pending", +} + +local JOHTO_CART = { + id = "johto_run", title = "Johto Run", author = "Ren", version = "2.1.0", + base = "gold", seal = "open", + repo = "https://github.com/ren/johto-run", + github = "ren/johto-run", + mods = { { id = "steps", source = "gamebanana", mod = 42, file = 99, + md5 = ("ab"):rep(16), enabled = false, + options = { pace = "fast" } } }, + update_check = "ok", + latest = { version = "2.1.0", tag = "v2.1.0", + zip = { name = "johto_run-2.1.0.zip", + url = "https://example.test/johto_run-2.1.0.zip" } }, +} + +local function cartFeed(carts, overrides) + local doc = { schema_version = 1, count = 1, cart_count = #carts, + categories = { "GAMEPLAY" }, + base_games = { "red", "blue", "yellow", "gold", "silver" }, + mods = { NUZLOCKE }, carts = carts } + for k, v in pairs(overrides or {}) do doc[k] = v end + return Json.encode(doc) +end + +do + local index, err = ModIndex.parse(cartFeed({ OMEGA, JOHTO_CART })) + check(index ~= nil, "a feed carrying carts parses: " .. tostring(err)) + eq(index.schemaVersion, 1, "carts arrive at schema_version 1, unbumped") + eq(#index.mods, 1, "the mods array still parses") + eq(#index.carts, 2, "and the carts array parses beside it") + local c = index.carts[1] + eq(c.id, "omega_random_competition", "cart id") + eq(c.title, "OMEGA RANDOM COMPETITION", "cart title") + eq(c.base, "red", "the game the cart plays as") + eq(c.seal, "sealed", "its seal") + eq(c.shell, "#7B1B22", "its shell colour") + eq(c.finish, "holo", "its finish") + eq(c.speeds[2], 2, "its speed ladder") + eq(c.tags[1], "randomizer", "its tags") + eq(c.license, "MIT", "its license") + eq(c.load_order[1], "bcr", "its load order") + eq(#c.mods, 1, "its pinned mod set") + eq(c.mods[1].source, "github", "a github pin keeps its source") + eq(c.mods[1].repo, "bryanthaboi/bcr", "with the repo it comes from") + eq(c.mods[1].version, "1.0.0", "the exact pinned version") + eq(#c.mods[1].sha256, 64, "and the digest that gates it") + check(ModIndex.isCart(c), "a parsed cart is marked as one") + check(not ModIndex.isCart(index.mods[1]), "a mod is not") + + local g = index.carts[2] + eq(g.mods[1].source, "gamebanana", "a gamebanana pin keeps its source") + eq(g.mods[1].mod, 42, "with its mod page id") + eq(g.mods[1].file, 99, "and its file id") + eq(#g.mods[1].md5, 32, "and the digest GameBanana reports") + eq(g.mods[1].enabled, false, "a pin shipped switched off stays off") + eq(g.mods[1].options.pace, "fast", "frozen options survive") + eq(ModIndex.installUrl(g), "https://example.test/johto_run-2.1.0.zip", + "a cart resolves its release asset the same way a mod does") +end + +-- the old-feed case: no carts key at all +do + local index, err = ModIndex.parse(feed({ NUZLOCKE })) + check(index ~= nil, "a feed with no carts key still parses: " .. tostring(err)) + eq(#index.mods, 1, "its mods are unaffected") + eq(type(index.carts), "table", "and carts is a list, never nil") + eq(#index.carts, 0, "an absent carts array is an empty one") +end + +-- a broken cart row costs itself, not the whole feed +do + local noBase = {} + for k, v in pairs(OMEGA) do noBase[k] = v end + noBase.base = nil + local noMods = {} + for k, v in pairs(JOHTO_CART) do noMods[k] = v end + noMods.id, noMods.mods = "empty_pins", {} + local index, err = ModIndex.parse(cartFeed({ + noBase, "not even an object", { id = "bare" }, noMods, OMEGA })) + check(index ~= nil, "a feed with malformed carts still parses: " .. tostring(err)) + eq(#index.carts, 1, "only the well-formed cart is listed") + eq(index.carts[1].id, "omega_random_competition", "and it is the intact one") + eq(#index.mods, 1, "the mods array is untouched by a bad cart") +end + +-- search and filter: matches() already spans title / author / summary / id, +-- so the cart half reuses it wholesale. The category filter does not apply +-- (a cart has none); base does. +do + local index = ModIndex.parse(cartFeed({ OMEGA, JOHTO_CART })) + local carts = index.carts + eq(#ModIndex.filter(carts, {}), 2, "no filter keeps every cart") + eq(ModIndex.filter(carts, { query = "omega" })[1].id, + "omega_random_competition", "search matches a cart by title") + eq(ModIndex.filter(carts, { query = "Ren" })[1].id, "johto_run", + "search matches a cart by author") + eq(ModIndex.filter(carts, { query = "randomizer" })[1].id, + "omega_random_competition", "and by summary") + eq(#ModIndex.filter(carts, { query = "omega johto" }), 0, + "cart search terms are ANDed too") + eq(ModIndex.filter(carts, { base = "gold" })[1].id, "johto_run", + "filtering by base game keeps the carts for that game") + eq(#ModIndex.filter(carts, { base = "RED" }), 1, + "and compares case-insensitively") + eq(#ModIndex.filter(carts, { base = "silver" }), 0, + "a base no cart plays as filters everything out") + eq(#ModIndex.filter(carts, { category = "GAMEPLAY" }), 0, + "a cart carries no categories, so a category filter never matches one") + eq(ModIndex.filter(carts, { tag = "competition" })[1].id, + "omega_random_competition", "cart tags filter") + + local bases = ModIndex.baseGamesIn(index) + eq(#bases, 2, "only base games a cart actually plays as are offered") + eq(bases[1], "red", "and they keep the feed's declared base_games order") + eq(bases[2], "gold", "in that order") + eq(#ModIndex.baseGamesIn(ModIndex.parse(feed({ NUZLOCKE }))), 0, + "a cartless feed offers no base games") +end + print("ok mod_index_tests") diff --git a/tests/engine/mod_targets_tests.lua b/tests/engine/mod_targets_tests.lua index 87f8fc93..a04c9069 100644 --- a/tests/engine/mod_targets_tests.lua +++ b/tests/engine/mod_targets_tests.lua @@ -27,18 +27,25 @@ end -- ------- tokens expand off GameVersion, never a literal list +-- "nonesuch" is the deliberate never-a-game token for every unknown-version +-- case below. A real version id was used here twice ("gold", then "crystal") +-- and both had to be swapped the day that game shipped; this one never will. +local NO_SUCH_GAME = "nonesuch" + do eq(table.concat(ModTargets.expand("red"), ","), "red", "a version id names exactly that game") eq(table.concat(ModTargets.expand("GEN1"), ","), "red,blue,yellow", "gen1 is every Gen 1 game, case-insensitive") - eq(table.concat(ModTargets.expand("gen2"), ","), "gold,silver", + eq(table.concat(ModTargets.expand("gen2"), ","), "gold,silver,crystal", "gen2 is every Gen 2 game") eq(table.concat(ModTargets.expand("silver"), ","), "silver", "and each of them names itself") + eq(table.concat(ModTargets.expand("crystal"), ","), "crystal", + "Crystal included, the day its VERSIONS row landed") eq(table.concat(ModTargets.expand("all"), ","), table.concat(GameVersion.ORDER, ","), "all is the launcher order itself") - eq(ModTargets.expand("crystal"), nil, "a game this engine has no cache for") + eq(ModTargets.expand(NO_SUCH_GAME), nil, "a game this engine has no cache for") eq(ModTargets.expand("gen9"), nil, "a generation with no games is unknown") eq(ModTargets.expand(7), nil, "a non-string token is not a game") end @@ -48,9 +55,9 @@ do eq(table.concat(versions, ","), "red,gold", "normalize dedupes and sorts into GameVersion.ORDER") eq(#unknown, 0, "known tokens leave nothing unreported") - local _, bad = ModTargets.normalize({ "crystal", "gen1" }) + local _, bad = ModTargets.normalize({ NO_SUCH_GAME, "gen1" }) eq(#bad, 1, "an unknown token comes back for the caller to report") - eq(bad[1], "crystal", "by name") + eq(bad[1], NO_SUCH_GAME, "by name") end -- ------- the legacy reading: gen2compat only ever ADDS Gen 2 @@ -58,7 +65,7 @@ end do eq(list(mf({})), "red,blue,yellow", "a manifest with no games key is Gen 1, which is what it was tested as") - eq(list(mf({ gen2compat = true })), "red,blue,yellow,gold,silver", + eq(list(mf({ gen2compat = true })), "red,blue,yellow,gold,silver,crystal", "gen2compat keeps Gen 1 and adds Gen 2") eq(mf({}).gen2compat, false, "and the derived flag agrees") eq(mf({ gen2compat = true }).gen2compat, true, "both ways") @@ -69,23 +76,23 @@ end do local gen2 = mf({ games = { "gen2" } }) - eq(list(gen2), "gold,silver", "games can name Gen 2 alone") + eq(list(gen2), "gold,silver,crystal", "games can name Gen 2 alone") eq(gen2.gen2compat, true, "which IS the gen2compat claim the gate reads") local both = mf({ games = { "gen1", "gen2" } }) - eq(list(both), "red,blue,yellow,gold,silver", "or both generations") + eq(list(both), "red,blue,yellow,gold,silver,crystal", "or both generations") local one = mf({ games = { "blue" } }) eq(list(one), "blue", "or one single game") eq(one.gen2compat, false, "a Gen 1 game is not a Gen 2 claim") - eq(list(mf({ games = { "red" }, gen2compat = true })), "red,gold,silver", + eq(list(mf({ games = { "red" }, gen2compat = true })), "red,gold,silver,crystal", "an old gen2compat beside a new games list still adds its game") end do -- vocabulary: api 1 warns and keeps loading, api 2 refuses, exactly like -- every other manifest vocabulary (Manifest.violation) - local lenient = mf({ games = { "crystal", "red" } }) + local lenient = mf({ games = { NO_SUCH_GAME, "red" } }) eq(list(lenient), "red", "api 1 drops the unknown game and keeps the rest") - check(not pcall(mf, { api = 2, games = { "crystal" } }), + check(not pcall(mf, { api = 2, games = { NO_SUCH_GAME } }), "api 2 refuses a game it does not have") check(not pcall(mf, { games = "gen1" }), "games must be an array, not a bare string") @@ -226,12 +233,13 @@ do local bad = ModProfile.decode(require("src.core.SaveSerializer").encode({ format = "g1rmodlist", formatVersion = 1, profile = { name = "P", enabledByVersion = { - gold = { a = true }, silver = { a = true }, crystal = { a = true }, + gold = { a = true }, silver = { a = true }, + [NO_SUCH_GAME] = { a = true }, red = "nope" } }, })) eq(bad.enabledByVersion.gold.a, true, "a shared file's known game is kept") eq(bad.enabledByVersion.silver.a, true, "every one of them, not just the first") - eq(bad.enabledByVersion.crystal, nil, "an unknown game is dropped on read") + eq(bad.enabledByVersion[NO_SUCH_GAME], nil, "an unknown game is dropped on read") eq(bad.enabledByVersion.red, nil, "and so is a bucket that is not a table") end diff --git a/tests/engine/museum_clerk_back_entrance_bug1690.lua b/tests/engine/museum_clerk_back_entrance_bug1690.lua new file mode 100644 index 00000000..ebced384 --- /dev/null +++ b/tests/engine/museum_clerk_back_entrance_bug1690.lua @@ -0,0 +1,222 @@ +-- The Pewter museum ticket clerk dispatches on the player's coordinates +-- before it looks at the ticket flag (#1690). scripts/Museum1F.asm:45 +-- reads wYCoord/wXCoord first: (13,4) and (12,3) are behind the counter +-- (the AMBER question), Y==4 otherwise is the ticket path, and anything +-- else is the "go to the other side" brush-off. The port only ever had +-- the middle branch, so two thirds of the NPC never fired. +-- +-- tests/engine/museum_money_box_bug1335.lua and museum_1f_clerk_ +-- translation_test.lua both call the clerk with ow == nil, which stays on +-- the ticket path either way, so neither can see the coordinate branches. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") + +package.loaded["src.render.TextBox"] = { + new = function(_, text, onDone, opts) + return { text = text, onDone = onDone, opts = opts } + end, +} + +local M = assert(loadfile("data/scripts/story2.lua"))() +local clerk = M.MUSEUM_1F.talk.TEXT_MUSEUM1F_SCIENTIST1 + +-- data/maps/objects/Museum1F.asm: MUSEUM1F_SCIENTIST1 stands at (12,4) +-- with the counter column at x==11. (13,4) is the cell east of him and +-- (12,3) the cell north of him, both reachable only from the back door; +-- (10,4) talks to him across the counter from the public side. +local BEHIND = { { 13, 4 }, { 12, 3 } } +local FRONT = { 10, 4 } + +local pushed +local function mkGame(cash) + pushed = {} + return { + data = { text = {} }, + save = { money = cash, flags = {} }, + stack = { push = function(_, box) pushed[#pushed + 1] = box end }, + } +end + +local function mkOw(x, y) + return { player = { cellX = x, cellY = y } } +end + +-- nil-tolerant: a branch that pushes nothing at all is a failure to +-- report, not a crash that hides every check after it +local function has(box, needle) + return box ~= nil and tostring(box.text):find(needle, 1, true) ~= nil +end + +-- answering a box that never offered a choice is the failure mode itself, +-- so report it rather than dying on a nil index and hiding the rest +local function answer(box, yes) + if box and box.opts and box.opts.choice then box.opts.choice(yes) end +end + +-- ------------------------------------------------ behind the counter +for _, cell in ipairs(BEHIND) do + local where = ("(%d,%d)"):format(cell[1], cell[2]) + + local g = mkGame(3000) + clerk(g, mkOw(cell[1], cell[2]), nil, function() end) + local ask = pushed[1] + T.check(has(ask, "sneak"), + where .. " opens the can't-sneak-in-the-back-way box") + T.check(ask.opts ~= nil and ask.opts.choice ~= nil, + where .. " carries the AMBER yes/no choice") + T.check(ask.opts.money == nil, + where .. " raises no money window behind the AMBER question") + T.check(ask.onDone == nil, where .. " chains through the choice, not onDone") + + -- YES: .TheresALabSomewhereText + answer(ask, true) + T.check(has(pushed[2], "lab"), where .. " YES gives the resurrection lab line") + + -- NO: .AmberIsFossilizedTreeSapText + g = mkGame(3000) + clerk(g, mkOw(cell[1], cell[2]), nil, function() end) + answer(pushed[1], false) + T.check(has(pushed[2], "tree sap"), where .. " NO explains AMBER is tree sap") + + T.eq(g.save.money, 3000, where .. " never charges the player") + T.check(not g.save.flags.EVENT_BOUGHT_MUSEUM_TICKET, + where .. " never hands out a ticket") +end + +-- the coordinate check runs BEFORE the ticket check, so a ticket holder +-- who wanders round the back still gets told off (asm:45 precedes asm:59) +for _, cell in ipairs(BEHIND) do + local where = ("(%d,%d)"):format(cell[1], cell[2]) + local g = mkGame(3000) + g.save.flags.EVENT_BOUGHT_MUSEUM_TICKET = true + clerk(g, mkOw(cell[1], cell[2]), nil, function() end) + T.check(has(pushed[1], "sneak"), + where .. " with a ticket is still the back-way box, not take-your-time") + answer(pushed[1], false) + T.check(has(pushed[2], "tree sap"), + where .. " with a ticket still answers NO with the tree sap line") +end + +-- the done callback reaches the second box on both answers +local g = mkGame(3000) +local doneFired = false +clerk(g, mkOw(13, 4), nil, function() doneFired = true end) +answer(pushed[1], true) +T.check(pushed[2].onDone ~= nil, "the AMBER answer box carries the done callback") +pushed[2].onDone() +T.check(doneFired, "and it chains back out of the conversation") + +-- ------------------------------------------------ the brush-off +-- scripts/Museum1F.asm:58: no ticket and not on row 4 means "other side" +local OFF_ROW = { { 12, 5 }, { 13, 3 }, { 12, 2 }, { 11, 3 } } +for _, cell in ipairs(OFF_ROW) do + local where = ("(%d,%d)"):format(cell[1], cell[2]) + local gg = mkGame(3000) + clerk(gg, mkOw(cell[1], cell[2]), nil, function() end) + T.check(has(pushed[1], "other side"), where .. " gets the brush-off line") + T.check(pushed[1].opts == nil, where .. " raises no money window") + T.eq(gg.save.money, 3000, where .. " never charges the player") +end + +-- with a ticket, off-row talk falls through to take-your-time instead +-- (asm:59 CheckEvent gates the brush-off) +local g2 = mkGame(3000) +g2.save.flags.EVENT_BOUGHT_MUSEUM_TICKET = true +clerk(g2, mkOw(12, 5), nil, function() end) +T.check(has(pushed[1], "Take your time"), + "(12,5) with a ticket gets take-your-time, not the brush-off") + +-- ------------------------------------------------ the ticket path holds +-- across the counter from the public side is row 4: the money box +local g3 = mkGame(3000) +clerk(g3, mkOw(FRONT[1], FRONT[2]), nil, function() end) +T.check(has(pushed[1], "50"), "(10,4) still opens the child's ticket ask") +T.check(pushed[1].opts ~= nil and pushed[1].opts.money ~= nil, + "and the ask still raises the money window") +answer(pushed[1], true) +T.eq(g3.save.money, 2950, "buying from the front still costs 50") +T.check(g3.save.flags.EVENT_BOUGHT_MUSEUM_TICKET, "and still sets the ticket flag") + +local g4 = mkGame(3000) +g4.save.flags.EVENT_BOUGHT_MUSEUM_TICKET = true +clerk(g4, mkOw(FRONT[1], FRONT[2]), nil, function() end) +T.check(has(pushed[1], "Take your time"), + "(10,4) with a ticket still gets take-your-time") +T.check(pushed[1].opts == nil, "and no money window on that branch") + +-- a caller with no overworld (the two older suites, and any script that +-- talks to the clerk out of band) must keep the pre-#1690 ticket path +local g5 = mkGame(3000) +clerk(g5, nil, nil, function() end) +T.check(has(pushed[1], "50"), "ow == nil still opens the ticket ask") +T.check(pushed[1].opts ~= nil and pushed[1].opts.money ~= nil, + "ow == nil still raises the money window") + +-- ------------------------------------------------ the rope onStep path +-- Museum1FDefaultScript calls the clerk over when the player steps onto +-- (9,4) or (10,4); OverworldController passes the player's own cell, so +-- the brush-off must not swallow the ask there +for _, x in ipairs({ 9, 10 }) do + local where = ("(%d,4)"):format(x) + local g6 = mkGame(3000) + local moved = false + local ow = mkOw(x, 4) + ow.scriptMove = function() moved = true end + local handled = M.MUSEUM_1F.onStep(g6, ow, x, 4) + T.check(handled, where .. " on the rope is handled by onStep") + T.check(has(pushed[1], "50"), where .. " on the rope opens the ticket ask") + T.check(pushed[1].opts ~= nil and pushed[1].opts.money ~= nil, + where .. " on the rope keeps the money window") + answer(pushed[1], false) + T.check(has(pushed[2], "Come again"), where .. " declining says come again") + pushed[2].onDone() + T.check(moved, where .. " declining still shoves the player back south (#151)") +end + +-- a ticket holder walking the rope is not stopped at all +local g7 = mkGame(3000) +g7.save.flags.EVENT_BOUGHT_MUSEUM_TICKET = true +local ow7 = mkOw(9, 4) +ow7.scriptMove = function() end +T.check(not M.MUSEUM_1F.onStep(g7, ow7, 9, 4), + "a ticket holder crosses the rope without being stopped") +T.eq(#pushed, 0, "and no box is pushed") + +-- ------------------------------------------------ translation reaches it +-- the two new branches must read game.data.text, not bare literals +local TRANSLATED = { + _Museum1FScientist1DoYouKnowWhatAmberIsText = "Pas par derriere !\nL'AMBRE, tu connais ?", + _Museum1FScientist1TheresALabSomewhereText = "Un labo essaie de\nles ressusciter.", + _Museum1FScientist1AmberIsFossilizedTreeSapText = "C'est de la resine\nfossilisee.", + _Museum1FScientist1GoToOtherSideText = "Passe de l'autre\ncote !", +} +local function mkTranslated() + pushed = {} + return { + data = { text = TRANSLATED }, + save = { money = 3000, flags = {} }, + stack = { push = function(_, box) pushed[#pushed + 1] = box end }, + } +end + +local g8 = mkTranslated() +clerk(g8, mkOw(13, 4), nil, function() end) +T.eq(pushed[1].text, TRANSLATED._Museum1FScientist1DoYouKnowWhatAmberIsText, + "the back-way box uses the translated AMBER question") +answer(pushed[1], true) +T.eq(pushed[2].text, TRANSLATED._Museum1FScientist1TheresALabSomewhereText, + "YES uses the translated lab line") + +g8 = mkTranslated() +clerk(g8, mkOw(12, 3), nil, function() end) +answer(pushed[1], false) +T.eq(pushed[2].text, TRANSLATED._Museum1FScientist1AmberIsFossilizedTreeSapText, + "NO uses the translated tree sap line") + +g8 = mkTranslated() +clerk(g8, mkOw(12, 5), nil, function() end) +T.eq(pushed[1].text, TRANSLATED._Museum1FScientist1GoToOtherSideText, + "the brush-off uses the translated other-side line") + +T.finish("museum_clerk_back_entrance_bug1690") diff --git a/tests/engine/performance_tiers.lua b/tests/engine/performance_tiers.lua index 153d414b..1f494743 100644 --- a/tests/engine/performance_tiers.lua +++ b/tests/engine/performance_tiers.lua @@ -84,12 +84,14 @@ T.eq(Performance.label(nil), "AUTO", "label nil -> AUTO") -- -------------------------------------------------------------------- caps local hi, lo = Performance.CAPS.high, Performance.CAPS.low -T.check(hi.tilt and hi.gbcfx and hi.survey and not hi.fpsMax, "high caps: all on, no fps ceiling") -T.check((not lo.tilt) and (not lo.gbcfx) and (not lo.survey), "low caps: heavy extras off") +T.check(hi.tilt and hi.survey and hi.shaderfx and not hi.fpsMax, + "high caps: all on, no fps ceiling") +T.check((not lo.tilt) and (not lo.survey) and (not lo.shaderfx), + "low caps: heavy extras off") T.eq(lo.fpsMax, 60, "low caps: FPS ceiling of 60") local bal = Performance.CAPS.balanced -T.check((not bal.tilt) and (not bal.gbcfx) and bal.survey and not bal.fpsMax, - "balanced caps: no tilt/gbcfx, survey kept, no fps ceiling") +T.check((not bal.tilt) and bal.survey and (not bal.shaderfx) and not bal.fpsMax, + "balanced caps: no tilt, no shaderfx, survey kept, no fps ceiling") device("Linux", "arm64", 4) T.eq(Performance.caps("auto"), Performance.CAPS.low, "caps(auto) resolves through detect") diff --git a/tests/engine/rom_importer_nx_saves_inbox_test.lua b/tests/engine/rom_importer_nx_saves_inbox_test.lua index 5a22437e..4884c8fe 100644 --- a/tests/engine/rom_importer_nx_saves_inbox_test.lua +++ b/tests/engine/rom_importer_nx_saves_inbox_test.lua @@ -57,9 +57,10 @@ end package.loaded["src.core.Platform"] = nil package.loaded["src.import.RomImporter"] = nil RomImporter = require("src.import.RomImporter") +local GameVersion = require("src.core.GameVersion") local function clearSavesInbox() - for _, ver in ipairs({ "red", "blue", "yellow", "gold", "silver" }) do + for _, ver in ipairs(GameVersion.ORDER) do local dir = "imports/saves/" .. ver for _, name in ipairs(love.filesystem.getDirectoryItems(dir) or {}) do love.filesystem.remove(dir .. "/" .. name) diff --git a/tests/engine/uwp_baseroms_test.lua b/tests/engine/uwp_baseroms_test.lua index abe735a3..0d74eab7 100644 --- a/tests/engine/uwp_baseroms_test.lua +++ b/tests/engine/uwp_baseroms_test.lua @@ -82,7 +82,7 @@ local function importer(ready) end local allReady = importer({ red = true, blue = true, yellow = true, gold = true, - silver = true }) + silver = true, crystal = true }) allReady:_queueBaseRomScan() eq(allReady.baseRomScan.state, "done", "ready launcher skips discovery") eq(listings, 0, "ready launcher does not enumerate baseroms") @@ -125,7 +125,7 @@ missing:choose("red") eq(picks, 1, "the next import attempt falls back to the native picker") local rescanned = importer({ red = true, blue = true, yellow = true, gold = true, - silver = true }) + silver = true, crystal = true }) rescanned.baseRoms.red = { path = "baseroms/z-red.gb", name = "z-red.gb" } rescanned:reimport("red") check(rescanned.baseRoms.red == nil, "re-import clears the detected ROM") diff --git a/tests/gen2_audio_test.lua b/tests/gen2_audio_test.lua index 9d0031fa..e9f50e88 100644 --- a/tests/gen2_audio_test.lua +++ b/tests/gen2_audio_test.lua @@ -74,6 +74,12 @@ if ok then peak = math.max(peak, math.abs(l), math.abs(r)) end check(peak > 0.01, "TitleScreen renders audible samples (peak=" .. peak .. ")") + -- audio/drumkits.asm kit 5: the NR42 envelope rings on past the noise_note + -- script, so a snare must outlast the one frame its script occupies. + local frame = ChipSynth.SAMPLE_RATE / 60 + local snare = engine:drumInstrumentGen2(5, 1) + check(snare[#snare].endSample > frame * 2, + "kit 5 snare rings past its script (" .. snare[#snare].endSample .. " samples)") end local bark = audio.songs.Music_NewBarkTown diff --git a/tests/gen2_badges_test.lua b/tests/gen2_badges_test.lua index a782e7d5..9a138c58 100644 --- a/tests/gen2_badges_test.lua +++ b/tests/gen2_badges_test.lua @@ -37,6 +37,31 @@ eq(FieldMoves.BADGE_FLAG[26].store, "badges", "Johto badges go to player.badges" eq(FieldMoves.BADGE_FLAG[34].store, "kantoBadges", "ENGINE_BOULDERBADGE goes to player.kantoBadges") +-- pokecrystal/constants/engine_flags.asm:39 declares 162 flags to pokegold's +-- 93, moving the whole badge block up one. +do + local order = {} + for i = 1, 43 do order[i] = "ENGINE_UNRELATED" .. i end + order[10] = nil -- a const_skip hole must not truncate the map + for index, name in ipairs(FieldMoves.JOHTO_BADGES) do + order[27 + index] = "ENGINE_" .. name .. "BADGE" + end + for index, name in ipairs(FieldMoves.KANTO_BADGES) do + order[35 + index] = "ENGINE_" .. name .. "BADGE" + end + FieldMoves.bindEngineFlags(order) + eq(FieldMoves.BADGE_FLAG[27].name, "ZEPHYR", "crystal ZEPHYRBADGE is 27") + eq(FieldMoves.BADGE_FLAG[28].name, "HIVE", "crystal HIVEBADGE is 28") + eq(FieldMoves.BADGE_FLAG[34].name, "RISING", "crystal RISINGBADGE is 34") + eq(FieldMoves.BADGE_FLAG[35].name, "BOULDER", "crystal BOULDERBADGE is 35") + eq(FieldMoves.BADGE_FLAG[35].store, "kantoBadges", + "a renumbered Kanto badge still lands in kantoBadges") + eq(FieldMoves.BADGE_FLAG[26], nil, "Gold's ZEPHYR slot is vacated") + + FieldMoves.bindEngineFlags(nil) + eq(FieldMoves.BADGE_FLAG[26].name, "ZEPHYR", "no map falls back to Gold") +end + -- --------------------------------------------------------------------------- -- The round trip: what a gym script writes is what a field move reads. -- --------------------------------------------------------------------------- diff --git a/tests/gen2_battle_cursor_test.lua b/tests/gen2_battle_cursor_test.lua new file mode 100644 index 00000000..0b77c7df --- /dev/null +++ b/tests/gen2_battle_cursor_test.lua @@ -0,0 +1,200 @@ +-- The battle main menu's 2x2 cursor clamps at every edge instead of wrapping +-- to the opposite command (#1706). BattleMenuHeader's .MenuData flag byte is +-- `db STATICMENU_CURSOR | STATICMENU_DISABLE_B` (engine/battle/menu.asm:31-33) +-- with no STATICMENU_WRAP, so Init2DMenuCursorPosition leaves both wrap bits +-- clear (engine/menus/menu.asm:156-166) and _2DMenuInterpretJoypad's +-- .check_wrap_around_* arms answer `xor a / ret`: the cursor does not move. +-- ContestBattleMenuHeader (menu.asm:70-77) is the same grid, same flag byte. +-- +-- luajit tests/gen2_battle_cursor_test.lua +-- +-- ROM-free: the fixtures below are the extractor's shapes, and every press +-- goes through the real Input edge detector and the real screen. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 battle cursor") +local check, eq = S.check, S.eq + +love = require("tests.love_stub") + +local Battle = require("src.battle.gen2.Battle") +local BattleState = require("src.ui.gen2.BattleState") +local Input = require("src.core.Input") +local Mon = require("src.battle.gen2.Mon") + +-- ---------------------------------------------------------------- fixtures + +local TYPES = { NORMAL = { id = "NORMAL", index = 0, category = "physical" } } + +local MOVES = { + TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, type = "NORMAL", + accuracy = 95, pp = 35, effect = "EFFECT_NORMAL_HIT" }, +} + +local GROWTH = { + GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0, + linear = 0, constant = 0 }, +} + +local POKEMON = { + growthRates = GROWTH, + CYNDAQUIL = { + id = "CYNDAQUIL", index = 155, name = "CYNDAQUIL", + baseStats = { hp = 39, attack = 52, defense = 43, speed = 65, + specialAttack = 60, specialDefense = 50 }, + types = { "NORMAL", "NORMAL" }, catchRate = 45, baseExp = 65, + growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 31, + levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {}, + }, + PIDGEY = { + id = "PIDGEY", index = 16, name = "PIDGEY", + baseStats = { hp = 40, attack = 45, defense = 40, speed = 56, + specialAttack = 35, specialDefense = 35 }, + types = { "NORMAL", "NORMAL" }, catchRate = 255, baseExp = 55, + growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 127, + levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {}, + }, +} + +local DATA = { + pokemon = POKEMON, + moves = MOVES, + type_chart = { types = TYPES, matchups = {} }, + items = {}, +} + +local perfect = { attack = 15, defense = 15, speed = 15, special = 15 } +perfect.hp = Mon.hpDV(perfect) + +-- The smallest roll that neither crits nor misses. +local function detRandom(n) + if (n or 1) <= 1 then return 0 end + return 1 +end + +local function newScreen(opts) + opts = opts or {} + Input:init() + local player = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }) + player.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + local wild = Mon.new(DATA, "PIDGEY", 5, { dvs = perfect }) + wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + local save = { party = { player }, inventory = {}, player = { name = "GOLD" } } + local pushed = {} + local game = { + data = DATA, save = save, input = Input, options = {}, + stack = { + push = function(_, screen) pushed[#pushed + 1] = screen end, + pop = function() table.remove(pushed) end, + top = function() return pushed[#pushed] end, + clear = function(self) while #pushed > 0 do self:pop() end end, + }, + } + local battle = Battle.new({ data = DATA, party = { player }, wild = wild, + save = save, random = detRandom }) + local screen = BattleState.new(game, { battle = battle, save = save, + contest = opts.contest }) + game.stack:push(screen) + return screen, pushed +end + +-- Battle lines end in `prompt`, and PromptButton waits on A or B with no frame +-- countdown (home/joypad.asm:383-412), so the drain presses like a player. +local function runToMenu(screen, cap) + for _ = 1, (cap or 3000) do + local waiting = (screen.messageTimer or 0) > 0 + if waiting then Input:overlayPressed("a") end + Input:step() + screen:update(1 / 60) + if waiting then Input:overlayReleased("a") end + if screen.phase == "menu" then return true end + end + return false +end + +local function press(screen, button) + Input:overlayPressed(button) + Input:step() + screen:update(1 / 60) + Input:overlayReleased(button) + Input:step() +end + +-- One press from a known cursor position, answering where it landed. +local function pressAt(screen, index, button) + screen.menuIndex = index + press(screen, button) + return screen.menuIndex +end + +-- ---- the whole 2x2 transition table --------------------------------------- +-- +-- Row-major, FIGHT / PkMn over PACK / RUN (BattleMenuHeader's menu_coords). +-- The eight edge rows are the bug; the eight interior rows are the guard +-- against a fix that clamps the raw index instead of the column and the row, +-- which would hold every edge and still break FIGHT-down and RUN-left. +local GRID = { + { 1, "left", 1 }, { 2, "left", 1 }, { 3, "left", 3 }, { 4, "left", 3 }, + { 1, "right", 2 }, { 2, "right", 2 }, { 3, "right", 4 }, { 4, "right", 4 }, + { 1, "up", 1 }, { 2, "up", 2 }, { 3, "up", 1 }, { 4, "up", 2 }, + { 1, "down", 3 }, { 2, "down", 4 }, { 3, "down", 3 }, { 4, "down", 4 }, +} + +local LABEL = { "FIGHT", "PkMn", "PACK", "RUN" } + +do + local screen = newScreen() + check(runToMenu(screen), "the intro drains to the battle menu") + eq(screen.menuIndex, 1, "which opens on FIGHT") + + for _, row in ipairs(GRID) do + local from, button, want = row[1], row[2], row[3] + local note = from == want + and ("%s holds against %s"):format(LABEL[from], button) + or ("%s goes %s to %s"):format(LABEL[from], button, LABEL[want]) + eq(pressAt(screen, from, button), want, note) + end + + -- Whatever the cursor lands on has to stay a command the menu can run; + -- an off-by-one clamp would index MENU past its end and choose nothing. + for _, row in ipairs(GRID) do + local landed = pressAt(screen, row[1], row[2]) + check(BattleState.MENU[landed] ~= nil, + ("%s %s leaves the cursor on a real command"):format( + LABEL[row[1]], row[2])) + end +end + +-- ---- the same grid inside the bug-catching contest ------------------------- +-- +-- ContestBattleMenuHeader only moves the box out to menu_coords 2, 12 +-- (engine/battle/menu.asm:70-77); flag byte and cells are BattleMenuHeader's. +do + local screen = newScreen({ contest = true }) + check(runToMenu(screen), "a contest battle drains to its menu") + eq(screen.contest, true, "with the contest menu's coordinates") + eq(pressAt(screen, 2, "right"), 2, "PkMn holds against right in the contest") + eq(pressAt(screen, 3, "down"), 3, "PARKBALL holds against down") + eq(pressAt(screen, 1, "down"), 3, "and FIGHT still drops onto it") +end + +-- ---- the D-pad is silent --------------------------------------------------- +-- +-- MenuClickSound / PlayClickSFX play SFX_READ_TEXT_2 for A and B only +-- (home/menu.asm:746-762), so a rebuilt branch must not click on a move. +do + local screen = newScreen() + check(runToMenu(screen), "reached the menu again") + local played = {} + screen.playSfx = function(_, name) played[#played + 1] = name end + for _, button in ipairs({ "left", "right", "up", "down" }) do + press(screen, button) + end + eq(#played, 0, "no click sound on any of the four directions") + screen.menuIndex = 1 + press(screen, "a") + eq(played[1], "Sfx_ReadText2", "A on FIGHT is what clicks") +end + +S.finish() diff --git a/tests/gen2_battle_options_test.lua b/tests/gen2_battle_options_test.lua new file mode 100644 index 00000000..51c59947 --- /dev/null +++ b/tests/gen2_battle_options_test.lua @@ -0,0 +1,262 @@ +-- BATTLE BG on Gold (#1709): the one battle-surround setting Gold's renderer +-- can honour. This covers the wiring a screenshot cannot -- the default key, +-- the OPTION row and its ladder, the launcher gear row, the mod-facing member +-- table -- plus the band geometry Game2 paints, asserted as rectangles rather +-- than as pixels. +-- +-- What colour those bands actually come out is not a claim this file makes; +-- tests/drivers/gold_battle_bg_bug1709_test.lua is where a human judges that. + +package.path = "./?.lua;" .. package.path + +love = love or {} +love.graphics = love.graphics or { + getColor = function() return 1, 1, 1, 1 end, + setColor = function() end, + rectangle = function() end, + print = function() end, + printf = function() end, + draw = function() end, + newQuad = function() return {} end, + newImage = function() return nil end, + getShader = function() return nil end, + setShader = function() end, + newShader = function() error("no shaders in this harness") end, + getDimensions = function() return 160, 144 end, + push = function() end, pop = function() end, + translate = function() end, scale = function() end, + circle = function() end, clear = function() end, +} +love.math = love.math or { random = function(a, b) return b and a or 0.5 end } +love.image = love.image or {} +love.timer = love.timer or { getTime = function() return 0 end } +love.filesystem = love.filesystem or { + load = function() return nil end, + getInfo = function() return nil end, + read = function() return nil end, + write = function() return true end, + remove = function() return true end, +} +require("src.core.Logger").warn = function() end + +local BattleState = require("src.ui.gen2.BattleState") +local Chrome = require("src.ui.gen2.Chrome") +local Game2 = require("src.core.Game2") +local Gen2Compat = require("src.mods.Gen2Compat") +local LauncherSettings = require("src.import.LauncherSettings") +local OptionsMenu = require("src.ui.gen2.OptionsMenu") +local Save = require("src.core.gen2.Save") +local ScreenPosition = require("src.core.ScreenPosition") + +local checks, failures = 0, 0 +local function check(label, got, want) + checks = checks + 1 + if got ~= want then + failures = failures + 1 + print(("FAIL %s: got %s want %s"):format(label, tostring(got), + tostring(want))) + end +end + +-- --------------------------------------------------------------- the key + +check("the default surround is the cart's paper white", + Save.DEFAULT_OPTIONS.battleBg, "white") +check("and a fresh options table carries it", + Save.defaultOptions().battleBg, "white") + +-- --------------------------------------------------------------- the row + +local function rowNamed(label) + for i, row in ipairs(OptionsMenu.ROWS) do + if row.label == label then return i, row end + end +end + +local bgIndex, bgRow = rowNamed("BATTLE BG") +check("OPTION has a BATTLE BG row", bgRow ~= nil, true) +if bgRow then + check("it edits battleBg", bgRow.key, "battleBg") + check("it is a port row, not one of the cart's seven", bgRow.port, true) + check("WHITE and BLACK are the whole ladder", #bgRow.values, 2) + check("stored lowercase, shown WHITE", bgRow.display.white, "WHITE") + check("and BLACK", bgRow.display.black, "BLACK") + -- Appended at the tail, so nothing the other suites index by position moves. + check("it follows MAX FPS", OptionsMenu.ROWS[bgIndex - 1].label, "MAX FPS") + check("and CANCEL still ends the list", + OptionsMenu.ROWS[bgIndex + 1].cancel, true) + check("CANCEL is last", bgIndex + 1, #OptionsMenu.ROWS) +end +check("the cart's rows are unmoved", OptionsMenu.ROWS[7].key, "frame") +check("the rebind screen is unmoved", OptionsMenu.ROWS[8].id, "controls") +check("the port's audio group is unmoved", OptionsMenu.ROWS[9].key, "musicVol") + +-- The screen is built from ROWS, so the row has to survive buildRows too. +local menu = OptionsMenu.new({ options = Save.defaultOptions() }, + { options = Save.defaultOptions() }) +local built +for _, row in ipairs(menu.rows) do + if row.key == "battleBg" then built = row end +end +check("buildRows keeps it", built ~= nil, true) +if built then + check("and gives it the mod-facing id battleBg", built.id, "battleBg") + check("the menu starts on WHITE", menu.options.battleBg, "white") + menu:cycle(built, 1) + check("right stores black, not the display string", + menu.options.battleBg, "black") + menu:cycle(built, 1) + check("right again wraps to white", menu.options.battleBg, "white") + menu:cycle(built, -1) + check("left walks the ladder the other way", menu.options.battleBg, "black") + menu:cycle(built, -1) + check("and back", menu.options.battleBg, "white") +end + +-- ------------------------------------------------------ the launcher gear + +local model = LauncherSettings.open(nil, "gold") +local gearRow +for _, section in ipairs(model.sections) do + for _, row in ipairs(section.rows) do + if row.label == "BATTLE BG" then gearRow = row end + end +end +check("the gold gear offers BATTLE BG", gearRow ~= nil, true) +if gearRow then + model.opts.gold.battleBg = nil + check("an options.lua with no battleBg reads WHITE", gearRow.value(), "WHITE") + gearRow.step(1) + check("stepping writes the gold block, not the flat Gen 1 one", + model.opts.gold.battleBg, "black") + check("and the row reads BLACK", gearRow.value(), "BLACK") + gearRow.step(1) + check("stepping again returns to white", model.opts.gold.battleBg, "white") +end + +-- ---------------------------------------------------------- the battle end + +local bgMode = BattleState.bgMode +check("the Gold battle screen answers bgMode", type(bgMode), "function") +if type(bgMode) == "function" then + check("black when the option says so", + bgMode({ game = { options = { battleBg = "black" } } }), "black") + check("white when it says white", + bgMode({ game = { options = { battleBg = "white" } } }), "white") + check("white on an options table that predates the key", + bgMode({ game = { options = {} } }), "white") + check("white with no game at all", bgMode({}), "white") + -- Gen 1's third mode leaves the battle non-opaque so the map shows through; + -- Gold has no such path, so an options.lua carried over from Red must not + -- switch the Gold battle to a mode nothing paints. + check("Gen 1's WORLD degrades to white here", + bgMode({ game = { options = { battleBg = "world" } } }), "white") +end + +check("and mods are told bgMode is backed on this side", + Gen2Compat.memberStatus("src.battle.BattleState", "bgMode"), "backed") + +-- ------------------------------------------------------------- the bands +-- +-- Game2 paints the surround, not the battle screen: the widescreen layer +-- resolves to the TOP state, so a party menu or text box opened over the +-- battle would otherwise repaint the void white under itself. + +local G = love.graphics +local realRect, realSetColor = G.rectangle, G.setColor +local painter = Game2.paintBattleSurround +check("Game2 owns the surround repaint", type(painter), "function") + +local function paint(states, w, h) + if type(painter) ~= "function" then return {} end + local rects, pen = {}, { 1, 1, 1, 1 } + G.setColor = function(r, g, b, a) + pen = { r or 0, g or 0, b or 0, a or 1 } + end + G.rectangle = function(_, x, y, rw, rh) + rects[#rects + 1] = { x = x, y = y, w = rw, h = rh, pen = pen } + end + local ok, err = pcall(painter, { stack = { states = states } }, w, h) + G.rectangle, G.setColor = realRect, realSetColor + if not ok then + check("paintBattleSurround ran: " .. tostring(err), false, true) + end + return rects +end + +local blackBattle = { bgMode = function() return "black" end } +local whiteBattle = { bgMode = function() return "white" end } +local plainMenu = {} +local overworld = {} + +check("BLACK paints the four bands", #paint({ blackBattle }, 1024, 768), 4) +check("WHITE paints nothing at all", #paint({ whiteBattle }, 1024, 768), 0) +check("the overworld alone paints nothing", + #paint({ overworld, plainMenu }, 1024, 768), 0) + +-- A menu over the battle keeps the battle's void: the walk goes down the +-- stack past states that have no bgMode of their own. +check("a party menu over the battle still finds the battle's mode", + #paint({ overworld, blackBattle, plainMenu }, 1024, 768), 4) + +local function panelSafe(states, w, h, label) + local scale = Chrome.fitScale(w, h) + local ox, oy = Chrome.fitOrigin(w, h, scale) + local pw, ph = 160 * scale, 144 * scale + local rects = paint(states, w, h) + local covered, overlap, offColour = 0, 0, 0 + for _, r in ipairs(rects) do + covered = covered + r.w * r.h + if r.pen[1] ~= 0 or r.pen[2] ~= 0 or r.pen[3] ~= 0 then + offColour = offColour + 1 + end + local ix = math.max(0, math.min(r.x + r.w, ox + pw) - math.max(r.x, ox)) + local iy = math.max(0, math.min(r.y + r.h, oy + ph) - math.max(r.y, oy)) + overlap = overlap + ix * iy + end + -- The band maths has to tile the void exactly: any overlap with the panel is + -- a black frame eating the HUD or the message box, and any shortfall is a + -- strip of white left behind on one edge. + check(label .. ": no band touches the battle panel", overlap, 0) + check(label .. ": the void is covered edge to edge", covered, w * h - pw * ph) + check(label .. ": every band is black", offColour, 0) +end + +for _, size in ipairs({ { 1024, 768 }, { 1280, 840 }, { 1920, 1080 }, + { 800, 600 }, { 1366, 768 } }) do + panelSafe({ blackBattle }, size[1], size[2], + ("%dx%d"):format(size[1], size[2])) +end + +-- SCREEN POS lifts the panel off centre, which is the other way the four +-- bands can stop agreeing with where the panel actually landed. +for _, mode in ipairs({ "center", "upper", "top" }) do + ScreenPosition.setMode(mode) + panelSafe({ blackBattle, plainMenu }, 1280, 840, "screen pos " .. mode) +end +ScreenPosition.setMode("center") + +-- A window smaller than the GB screen has no void to paint; the bands must +-- not wrap around to the far edge on the negative origin. +check("nothing to paint under 160x144", #paint({ blackBattle }, 100, 90), 0) + +-- ----------------------------------------------------- the call site +-- +-- Constructing a Game2 needs love, so where the paint is called from is read +-- out of the source, the way the other Gen 2 suites check this file. It has +-- to run right after the widescreen layer has painted its white surround and +-- before the letterbox, or there is nothing to repaint over. +local handle = io.open("src/core/Game2.lua", "r") +local source = handle and handle:read("*a") +if handle then handle:close() end +check("Game2's source is readable", source ~= nil, true) +if source then + check("drawScene repaints straight after the widescreen layer", + source:find("wide:drawWidescreen%(w, h%)%s*self:paintBattleSurround%(w, h%)") + ~= nil, true) +end + +print(("gen2 battle options: %d checks, %d failures"):format(checks, failures)) +if failures > 0 then + error(("%d assertion(s) failed"):format(failures), 0) +end diff --git a/tests/gen2_battle_test.lua b/tests/gen2_battle_test.lua index ff60c311..3fb4c794 100644 --- a/tests/gen2_battle_test.lua +++ b/tests/gen2_battle_test.lua @@ -689,15 +689,16 @@ statusBattle.player.moves[1].pp = 20 statusBattle:takeTurn({ kind = "move", move = "THUNDER_WAVE" }) check("a second status fails", statusBattle.enemy.status, "paralyze") --- Sleep lands with a turn counter, and canAct spends it. Asserted on the move --- rather than a whole turn: with this deterministic random the roll is the --- minimum 1 turn, and the slower foe's own turn later in the same round then --- wakes it -- which is what the cart does as well. +-- Sleep lands with a turn counter, and canAct spends it. The counter opens at +-- 2, so the target cannot wake in the round it was slept +-- (engine/battle/effect_commands.asm:3591-3598, #1707). local sleepBattle = newBattle() sleepBattle.player.moves = { { id = "SPORE", pp = 15, maxPp = 15 } } sleepBattle:useMove(sleepBattle.player, sleepBattle.enemy, "SPORE") check("spore slept the target", sleepBattle.enemy.status, "sleep") -check("sleep has turns", (sleepBattle.enemy.statusTurns or 0) >= 1, true) +check("sleep never opens shorter than two turns", + (sleepBattle.enemy.statusTurns or 0) >= 2, true) +check("the lowest roll is exactly two", sleepBattle.enemy.statusTurns, 2) -- A sleeping mon cannot act, and the counter runs down to a wake-up. sleepBattle.enemy.statusTurns = 2 check("asleep cannot act", sleepBattle:canAct(sleepBattle.enemy), false) @@ -1386,12 +1387,25 @@ check("Ground is immune", Effects.sandstormHits({ "NORMAL", "GROUND" }), false) check("Steel is immune", Effects.sandstormHits({ "STEEL" }), false) check("Flying is not", Effects.sandstormHits({ "NORMAL", "FLYING" }), true) --- BattleCommand_Heal's .Weather ladder: a half normally, two thirds in sun, --- a quarter in rain or sandstorm. -check("Morning Sun heals half in clear weather", - Effects.weatherHealFraction(nil), 1 / 2) -checkNear("...two thirds in sun", Effects.weatherHealFraction("sun"), 2 / 3, 0.001) -check("...a quarter in rain", Effects.weatherHealFraction("rain"), 1 / 4) +-- BattleCommand_TimeBasedHealContinue's .Multipliers ladder, walked by the +-- time of day and the weather (engine/battle/effect_commands.asm:6388-6454). +local DAY_F = Effects.SUN_HEAL.EFFECT_SYNTHESIS +check("Synthesis heals half in the day in clear weather", + Effects.timeBasedHealFraction(nil, DAY_F, 1), 1 / 2) +check("...the lot in sun", Effects.timeBasedHealFraction("sun", DAY_F, 1), 1) +check("...a quarter in rain", + Effects.timeBasedHealFraction("rain", DAY_F, 1), 1 / 4) +check("...a quarter at night in clear weather", + Effects.timeBasedHealFraction(nil, DAY_F, 2), 1 / 4) +check("...a half at night in sun", + Effects.timeBasedHealFraction("sun", DAY_F, 2), 1 / 2) +check("...an eighth at night in a sandstorm", + Effects.timeBasedHealFraction("sandstorm", DAY_F, 2), 1 / 8) +check("Moonlight wants NITE instead", + Effects.timeBasedHealFraction(nil, Effects.SUN_HEAL.EFFECT_MOONLIGHT, 2), + 1 / 2) +check("a battle with no clock still heals half", + Effects.timeBasedHealFraction(nil, DAY_F, nil), 1 / 2) -- ProtectChance halves for every consecutive use and gives up after eight. check("first Protect always works", Effects.protectChance(0), 0xff) diff --git a/tests/gen2_battle_tower_battle_test.lua b/tests/gen2_battle_tower_battle_test.lua new file mode 100644 index 00000000..55b73b60 --- /dev/null +++ b/tests/gen2_battle_tower_battle_test.lua @@ -0,0 +1,603 @@ +-- The Battle Tower BATTLE: the roster tables, the opponent draw and the two +-- specials maps/BattleTowerBattleRoom.asm's loop is built out of. +-- +-- luajit tests/gen2_battle_tower_battle_test.lua +-- +-- ROM-free. The extractor half runs against a synthetic cartridge whose +-- tables are laid out byte for byte the way data/battle_tower/ assembles +-- them, and the rest against a fixture roster taken from +-- ../pokecrystal/data/battle_tower/parties.asm group 1. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 battle tower battle") +local check, eq = S.check, S.eq + +love = require("tests.love_stub") + +local BattleTower = require("src.core.gen2.BattleTower") +local Mon = require("src.battle.gen2.Mon") +local RomExtractorGen2 = require("src.import.RomExtractorGen2") +local Save = require("src.core.gen2.Save") +local Specials = require("src.script.gen2.Specials") + +-- ---------------------------------------------------------------- fixtures + +-- ../pokecrystal/data/pokemon/base_stats/*.asm, the five species group 1 of +-- BattleTowerMons opens with. +local POKEMON = { + JOLTEON = { name = "JOLTEON", index = 135, types = { "ELECTRIC" }, + growthRate = "MEDIUM_FAST", levelMoves = {}, + baseStats = { hp = 65, attack = 65, defense = 60, speed = 130, + specialAttack = 110, specialDefense = 95 } }, + ESPEON = { name = "ESPEON", index = 196, types = { "PSYCHIC_TYPE" }, + growthRate = "MEDIUM_FAST", levelMoves = {}, + baseStats = { hp = 65, attack = 65, defense = 60, speed = 110, + specialAttack = 130, specialDefense = 95 } }, + UMBREON = { name = "UMBREON", index = 197, types = { "DARK" }, + growthRate = "MEDIUM_FAST", levelMoves = {}, + baseStats = { hp = 95, attack = 65, defense = 110, speed = 65, + specialAttack = 60, specialDefense = 130 } }, + WOBBUFFET = { name = "WOBBUFFET", index = 202, types = { "PSYCHIC_TYPE" }, + growthRate = "MEDIUM_FAST", levelMoves = {}, + baseStats = { hp = 190, attack = 33, defense = 58, speed = 33, + specialAttack = 33, specialDefense = 58 } }, + KANGASKHAN = { name = "KANGASKHAN", index = 115, types = { "NORMAL" }, + growthRate = "MEDIUM_FAST", levelMoves = {}, + baseStats = { hp = 105, attack = 95, defense = 80, speed = 90, + specialAttack = 40, specialDefense = 80 } }, +} + +local MOVES = { + THUNDERBOLT = { pp = 15 }, HYPER_BEAM = { pp = 5 }, SHADOW_BALL = { pp = 15 }, + ROAR = { pp = 20 }, MUD_SLAP = { pp = 10 }, PSYCHIC_M = { pp = 10 }, + PSYCH_UP = { pp = 10 }, TOXIC = { pp = 10 }, IRON_TAIL = { pp = 15 }, + COUNTER = { pp = 20 }, MIRROR_COAT = { pp = 20 }, SAFEGUARD = { pp = 25 }, + DESTINY_BOND = { pp = 5 }, REVERSAL = { pp = 15 }, EARTHQUAKE = { pp = 10 }, + ATTRACT = { pp = 15 }, +} + +-- ../pokecrystal/data/battle_tower/parties.asm:4-140, transcribed field for +-- field: the DVs, the stat exp words, the PP bytes and the stats the cart +-- copies straight into wOTPartyMon. +local function row(species, item, moves, pp, dvs, statExp, stats) + return { + species = species, item = item, moves = moves, pp = pp, + level = 10, happiness = 100, experience = 1000, otId = 0, + dvs = { attack = dvs[1], defense = dvs[2], speed = dvs[3], + special = dvs[4] }, + statExp = { hp = statExp[1], attack = statExp[2], defense = statExp[3], + speed = statExp[4], special = statExp[5] }, + hp = stats[1], maxHp = stats[1], + stats = { hp = stats[1], attack = stats[2], defense = stats[3], + speed = stats[4], specialAttack = stats[5], specialDefense = stats[6] }, + } +end + +local GROUP1 = { + row("JOLTEON", "MIRACLEBERRY", + { "THUNDERBOLT", "HYPER_BEAM", "SHADOW_BALL", "ROAR" }, { 15, 5, 15, 20 }, + { 13, 13, 11, 13 }, { 50000, 40000, 40000, 35000, 40000 }, + { 41, 25, 24, 37, 34, 31 }), + row("ESPEON", "LEFTOVERS", + { "MUD_SLAP", "PSYCHIC_M", "PSYCH_UP", "TOXIC" }, { 10, 10, 10, 10 }, + { 14, 13, 15, 11 }, { 40000, 50000, 35000, 40000, 40000 }, + { 39, 26, 24, 35, 38, 31 }), + row("UMBREON", "GOLD_BERRY", + { "SHADOW_BALL", "IRON_TAIL", "PSYCH_UP", "TOXIC" }, { 15, 15, 10, 10 }, + { 13, 11, 14, 15 }, { 40000, 40000, 45000, 50000, 40000 }, + { 46, 25, 34, 26, 25, 39 }), + row("WOBBUFFET", "FOCUS_BAND", + { "COUNTER", "MIRROR_COAT", "SAFEGUARD", "DESTINY_BOND" }, + { 20, 20, 25, 5 }, { 7, 15, 13, 7 }, + { 50000, 50000, 50000, 50000, 50000 }, { 66, 18, 25, 19, 18, 23 }), + -- MIRACLEBERRY again, which is the item collision the draw refuses. + row("KANGASKHAN", "MIRACLEBERRY", + { "REVERSAL", "HYPER_BEAM", "EARTHQUAKE", "ATTRACT" }, { 15, 5, 10, 15 }, + { 14, 15, 12, 15 }, { 40000, 30000, 40000, 30000, 30000 }, + { 47, 31, 29, 29, 20, 28 }), +} + +-- ../pokecrystal/data/battle_tower/classes.asm:11-17 +local ROSTER = { + partyLength = 3, + levelGroups = 2, + uniqueMon = #GROUP1, + uniqueTrainers = 4, + sampleTrainers = 4, + trainers = { + { index = 0, name = "HANSON", class = 37, classId = "FISHER" }, + { index = 1, name = "SAWYER", class = 30, classId = "POKEMANIAC" }, + { index = 2, name = "MASUDA", class = 43, classId = "GUITARIST" }, + { index = 3, name = "NICKEL", class = 20, classId = "SCIENTIST" }, + }, + groups = { GROUP1, GROUP1 }, + classSprites = { + FISHER = "SPRITE_FISHER", POKEMANIAC = "SPRITE_SUPER_NERD", + GUITARIST = "SPRITE_ROCKER", SCIENTIST = "SPRITE_SCIENTIST", + }, +} + +local DATA = { + pokemon = POKEMON, + moves = MOVES, + trainers = { + battleTower = ROSTER, + classes = { + FISHER = { index = 37, name = "FISHER", baseMoney = 40, + attributes = { 0, 0, 40, 1, 0, 0, 0 }, items = {} }, + POKEMANIAC = { index = 30, name = "POKEMANIAC", baseMoney = 60, + attributes = { 0, 0, 60, 1, 0, 0, 0 }, items = {} }, + GUITARIST = { index = 43, name = "GUITARIST", baseMoney = 36, + attributes = { 0, 0, 36, 1, 0, 0, 0 }, items = {} }, + SCIENTIST = { index = 20, name = "SCIENTIST", baseMoney = 44, + attributes = { 0, 0, 44, 1, 0, 0, 0 }, items = {} }, + }, + }, +} + +local function crystalSave(levelGroup) + local save = Save.normalize({ version = "crystal", generation = 2 }) + save.battleTower.levelGroup = levelGroup or 1 + save.party = {} + return save +end + +-- A roll that walks a canned list, so every draw below is pinned. Same +-- 1..n convention Specials.random uses. +local function rolls(list) + local at = 0 + return function(n) + at = at + 1 + local value = list[at] or 1 + if value > n then value = ((value - 1) % n) + 1 end + return value + end +end + +local function fakeVm(save, hooks) + local vm = { + scriptVar = 0, + mem = {}, + stringBuffer = "", + specials = hooks or {}, + } + vm.specials.save = vm.specials.save or function() return save end + vm.specials.data = vm.specials.data or function() return DATA end + vm.specials.party = vm.specials.party or function() return save.party end + function vm:setStringBuffer(value) self.stringBuffer = value or "" end + function vm:showRaw() end + return vm +end + +-- =========================================== the extractor's roster decode +-- +-- A synthetic cartridge: BattleTowerTrainers, BattleTowerMons and +-- BTTrainerClassSprites laid out exactly as data/battle_tower/classes.asm, +-- parties.asm and data/trainers/sprites.asm assemble, plus the two +-- `maskbits N / cp N / jr nc` pairs load_trainer.asm samples with. +do + local NAME_LENGTH, MON_NAME_LENGTH = 11, 11 + local NICKNAMED = 48 + MON_NAME_LENGTH + local TRAINERS_AT, MONS_AT = 0x4100, 0x4100 + 3 * NAME_LENGTH + local SPRITES_AT, TR_SAMPLE_AT, MON_SAMPLE_AT = 0x5000, 0x5100, 0x5200 + local bytes = {} + local function put(offset, list) + for index, value in ipairs(list) do bytes[offset + index] = value end + end + local function name(text, width) + local out = {} + for index = 1, width do + out[index] = (index <= #text) and text:byte(index) or 0x50 + end + return out + end + -- charmap rows for the letters the three names use. + local charmap, letters = {}, "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + for index = 1, #letters do + charmap[tostring(0x80 + index - 1)] = letters:sub(index, index) + end + local function encode(text, width) + local out = {} + for index = 1, width do + if index <= #text then + out[index] = 0x80 + (text:byte(index) - 65) + else + out[index] = 0x50 + end + end + return out + end + -- Three rows, the third exactly NAME_LENGTH - 1 long so the unterminated + -- name the `dname` macro leaves (classes.asm:16 ZABOROWSKI) is covered. + local rows = { { "ABLE", 1 }, { "BAKER", 2 }, { "CDEFGHIJKL", 3 } } + for index, entry in ipairs(rows) do + local at = TRAINERS_AT + (index - 1) * NAME_LENGTH + put(at, encode(entry[1], NAME_LENGTH - 1)) + bytes[at + NAME_LENGTH] = entry[2] + end + -- One level group of two mons. Only the fields the reader names are set; + -- everything else stays 0, which is what ByteFill leaves. + local function monBytes(species, item, moves, pp, dv1, dv2, level, stats) + local raw = {} + for index = 1, NICKNAMED do raw[index] = 0 end + raw[1] = species + raw[2] = item + for slot = 1, 4 do raw[2 + slot] = moves[slot] or 0 end + raw[9], raw[10], raw[11] = 0, 0x03, 0xe8 + raw[12], raw[13] = 0xc3, 0x50 + raw[22], raw[23] = dv1, dv2 + for slot = 1, 4 do raw[23 + slot] = pp[slot] or 0 end + raw[28] = 100 + raw[32] = level + raw[35], raw[36] = 0, stats[1] + raw[37], raw[38] = 0, stats[1] + raw[39], raw[40] = 0, stats[2] + raw[41], raw[42] = 0, stats[3] + raw[43], raw[44] = 0, stats[4] + raw[45], raw[46] = 0, stats[5] + raw[47], raw[48] = 0, stats[6] + return raw + end + put(MONS_AT, monBytes(135, 109, { 85, 63, 247, 46 }, { 15, 5, 15, 20 }, + 0xdd, 0xbd, 10, { 41, 25, 24, 37, 34, 31 })) + put(MONS_AT + NICKNAMED, monBytes(196, 78, { 189, 94, 0, 0 }, { 10, 10, 0, 0 }, + 0xed, 0xfb, 10, { 39, 26, 24, 35, 38, 31 })) + put(SPRITES_AT, { 7, 9, 11 }) + -- `and $03 / cp 3 / jr nc, .resample` and `and $01 / cp 2 / jr nc`. + put(TR_SAMPLE_AT, { 0xcd, 0x8c, 0x2f, 0xf0, 0xe1, 0x80, 0x47, + 0xe6, 0x03, 0xfe, 0x03, 0x30, 0xf3 }) + put(MON_SAMPLE_AT, { 0xcd, 0x8c, 0x2f, 0xf0, 0xe1, 0x80, 0x47, + 0xe6, 0x01, 0xfe, 0x02, 0x30, 0xf3 }) + + local rom = {} + for index = 1, 0x8000 do rom[index] = string.char(bytes[index] or 0) end + local manifest = { + romSha1 = "", + charmap = charmap, + constants = { + trainerClassOrder = { "TRAINER_NONE", "FALKNER", "WHITNEY", "BUGSY", + "MYSTICALMAN" }, + spriteOrder = { "SPRITE_A", "SPRITE_B", "SPRITE_C", "SPRITE_D", + "SPRITE_E", "SPRITE_F", "SPRITE_G", "SPRITE_H", "SPRITE_I", + "SPRITE_J", "SPRITE_K" }, + moveOrder = {}, itemOrder = {}, speciesOrder = {}, + }, + symbols = { + BattleTowerTrainers = { 1, TRAINERS_AT }, + BattleTowerMons = { 1, MONS_AT }, + BTTrainerClassSprites = { 1, SPRITES_AT }, + ["LoadOpponentTrainerAndPokemon.resample"] = { 1, TR_SAMPLE_AT }, + ["LoadRandomBattleTowerMon.resample"] = { 1, MON_SAMPLE_AT }, + }, + } + manifest.constants.speciesOrder[135] = "JOLTEON" + manifest.constants.speciesOrder[196] = "ESPEON" + manifest.constants.itemOrder[109] = "MIRACLEBERRY" + manifest.constants.itemOrder[78] = "LEFTOVERS" + manifest.constants.moveOrder[85] = "THUNDERBOLT" + manifest.constants.moveOrder[63] = "HYPER_BEAM" + manifest.constants.moveOrder[247] = "SHADOW_BALL" + manifest.constants.moveOrder[46] = "ROAR" + manifest.constants.moveOrder[189] = "MUD_SLAP" + manifest.constants.moveOrder[94] = "PSYCHIC_M" + + local ex = RomExtractorGen2.new(table.concat(rom), manifest, nil) + local read = ex:readBattleTowerRoster(manifest.constants, charmap) + check(read ~= nil, "the roster reads out of the cartridge") + eq(read.uniqueTrainers, 3, "the trainer count is the gap to BattleTowerMons") + eq(read.uniqueMon, 2, "NUM_UNIQUE_MON comes off the mon resample loop") + eq(read.sampleTrainers, 3, + "and the trainer ceiling off its own, which Crystal 1.0 gets wrong") + eq(read.trainers[1].name, "ABLE", "a terminated name stops at '@'") + eq(read.trainers[3].name, "CDEFGHIJKL", + "and a full-width one runs to NAME_LENGTH - 1 with no terminator") + eq(read.trainers[2].classId, "WHITNEY", "the class byte resolves to a name") + eq(read.classSprites.FALKNER, "SPRITE_G", "class 1 takes the first sprite") + eq(read.classSprites.BUGSY, "SPRITE_K", "class 3 the third") + eq(read.classSprites.MYSTICALMAN, nil, + "and MYSTICALMAN is off the end of BTTrainerClassSprites") + eq(#read.groups, 10, "ten level groups are always read") + local first = read.groups[1][1] + eq(first.species, "JOLTEON", "group 1 mon 1 species") + eq(first.item, "MIRACLEBERRY", "its held item") + eq(first.level, 10, "its level") + eq(first.happiness, 100, "its happiness") + eq(first.experience, 1000, "its three exp bytes, big-endian") + eq(first.statExp.hp, 50000, "its HP stat exp word") + eq(first.dvs.attack, 13, "attack DV out of the high nibble") + eq(first.dvs.defense, 13, "defense DV out of the low nibble") + eq(first.dvs.speed, 11, "speed DV") + eq(first.dvs.special, 13, "special DV") + eq(#first.moves, 4, "four moves") + eq(first.moves[3], "SHADOW_BALL", "move three") + eq(first.pp[2], 5, "and its PP byte") + eq(first.maxHp, 41, "the stored max HP") + eq(first.stats.speed, 37, "and the stored Speed") + local second = read.groups[1][2] + eq(#second.moves, 2, "a row with two NO_MOVE slots keeps two moves") + eq(#second.pp, 2, "and two PP bytes") +end + +-- =========================== Mon.stats agrees with the cart's stored stats +-- +-- ../pokecrystal/engine/pokemon/move_mon.asm CalcMonStats is what produced the +-- bytes in parties.asm, so the port's own formula has to land on them. +do + for _, mon in ipairs(GROUP1) do + local dvs = { attack = mon.dvs.attack, defense = mon.dvs.defense, + speed = mon.dvs.speed, special = mon.dvs.special } + local stats = Mon.stats(POKEMON[mon.species].baseStats, dvs, mon.level, + mon.statExp) + eq(stats.hp, mon.stats.hp, mon.species .. " HP matches the ROM") + eq(stats.attack, mon.stats.attack, mon.species .. " Attack matches") + eq(stats.defense, mon.stats.defense, mon.species .. " Defense matches") + eq(stats.speed, mon.stats.speed, mon.species .. " Speed matches") + eq(stats.specialAttack, mon.stats.specialAttack, + mon.species .. " Sp.Atk matches") + eq(stats.specialDefense, mon.stats.specialDefense, + mon.species .. " Sp.Def matches") + end +end + +-- ================================================ roster lookup and gating +do + eq(BattleTower.roster(DATA), ROSTER, "the roster comes off trainers.lua") + eq(BattleTower.roster({ trainers = {} }), nil, + "a Gold cache has no battleTower block") + eq(BattleTower.roster({ trainers = { battleTower = { trainers = {} } } }), nil, + "and half a block is not one either") + + local save = crystalSave(0) + eq(BattleTower.opponentGroup(save, ROSTER), 1, + "a level group of 0 cannot index before the table") + save.battleTower.levelGroup = 9 + eq(BattleTower.opponentGroup(save, ROSTER), 2, + "nor past the last group the roster carries") + save.battleTower.levelGroup = 2 + eq(BattleTower.opponentGroup(save, ROSTER), 2, "a real group is kept") +end + +-- ============================================== the trainer draw and sBTTrainers +do + local save = crystalSave() + local trainer = BattleTower.chooseTrainer(save, ROSTER, rolls({ 2 })) + eq(trainer.name, "SAWYER", "the second row is drawn") + eq(save.battleTower.trainers[1], 1, + "and recorded in sBTTrainers[sNrOfBeatenBattleTowerTrainers]") + + -- ../pokecrystal/engine/events/battle_tower/load_trainer.asm:44-51: the + -- streak list is what the reroll refuses. + save.battleTower.streak = 1 + local again = BattleTower.chooseTrainer(save, ROSTER, rolls({ 2 })) + check(again.name ~= "SAWYER", "a trainer already in the streak is refused") + eq(save.battleTower.trainers[2], again.index, + "and the new one lands in the next slot") + + -- :29-37, Crystal 1.0's ceiling: with sampleTrainers 2 only the first two + -- rows can ever come out, however the roll lands. + local capped = { partyLength = 3, levelGroups = 1, uniqueMon = #GROUP1, + uniqueTrainers = 4, sampleTrainers = 2, trainers = ROSTER.trainers, + groups = { GROUP1 }, classSprites = ROSTER.classSprites } + local seen = {} + for roll = 1, 8 do + local fresh = crystalSave() + seen[BattleTower.chooseTrainer(fresh, capped, rolls({ roll })).index] = true + end + eq(seen[2], nil, "row 2 is past the 1.0 ceiling and never drawn") + eq(seen[3], nil, "nor row 3") + check(seen[0] and seen[1], "the first two rows both are") +end + +-- ================================================== the three-mon team draw +do + local save = crystalSave() + local team = BattleTower.chooseTeam(save, ROSTER, 1, rolls({ 1, 1, 1 })) + eq(#team, 3, "three mons come out") + eq(team[1].species, "JOLTEON", "the first roll takes the first row") + -- :131-136, the species and item collisions: JOLTEON is out, and so is + -- KANGASKHAN, which holds JOLTEON's MIRACLEBERRY. + eq(team[2].species, "ESPEON", + "the second roll skips the species already picked") + eq(team[3].species, "UMBREON", "and so does the third") + local held = {} + for _, mon in ipairs(team) do + eq(held[mon.item], nil, mon.species .. " brings an unheld item") + held[mon.item] = true + end + + -- :149-166 and :195-206: the last two teams' species are refused too. + eq(save.battleTower.prevTeams.prev[1], "JOLTEON", + "the drawn team becomes sBTMonPrevTrainer") + eq(#save.battleTower.prevTeams.prevPrev, 0, + "with nothing displaced into sBTMonPrevPrevTrainer yet") + local second = BattleTower.chooseTeam(save, ROSTER, 1, rolls({ 1, 1, 1 })) + eq(second[1].species, "WOBBUFFET", "the next team skips the previous one") + eq(second[2].species, "KANGASKHAN", "and keeps skipping it") + eq(save.battleTower.prevTeams.prevPrev[1], "JOLTEON", + "and the old team shifts down to sBTMonPrevPrevTrainer") + + -- Five rows cannot field three fresh species twice running, which is + -- exactly where the cart's `jr z, .FindARandomBattleTowerMon` would spin + -- forever: the port takes the unfiltered draw instead and still hands back + -- a full team. Twenty-one rows never reach it. + eq(#second, 3, "an exhausted pool still fields three rather than hanging") + eq(second[3].species, "JOLTEON", "the third falls back to a plain roll") +end + +-- ============================================== the party the battle fights +do + local party = BattleTower.battleParty(DATA, { GROUP1[1], GROUP1[3] }) + eq(#party, 2, "one battle mon per roster row") + local jolteon = party[1] + eq(jolteon.species, "JOLTEON", "species") + eq(jolteon.name, "JOLTEON", "and the species name, not the ROM nickname") + eq(jolteon.nickname, nil, "which is why no nickname is carried") + eq(jolteon.level, 10, "level") + eq(jolteon.item, "MIRACLEBERRY", "held item") + eq(jolteon.happiness, 100, "happiness") + eq(jolteon.maxHp, 41, "max HP off the stored bytes") + eq(jolteon.hp, 41, "at full") + eq(jolteon.stats.speed, 37, "stored Speed") + eq(jolteon.stats.specialDefense, 31, "stored Sp.Def") + eq(jolteon.dvs.attack, 13, "the row's own DVs, not a roll") + eq(#jolteon.moves, 4, "four moves") + eq(jolteon.moves[2].id, "HYPER_BEAM", "move two") + eq(jolteon.moves[2].pp, 5, "with the row's PP") + eq(jolteon.moves[2].maxPp, 5, "and the move's own maximum") + eq(party[2].species, "UMBREON", "and the second row follows") + -- Mon.new mutates the dvs table it is handed, so a second build has to see + -- the roster row untouched. + local again = BattleTower.battleParty(DATA, { GROUP1[1] }) + eq(again[1].stats.attack, 25, "a second build reads the same row") + eq(GROUP1[1].dvs.hp, nil, "and leaves the roster row alone") +end + +-- ============================== LoadOpponentTrainerAndPokemonWithOTSprite +do + local painted = {} + local save = crystalSave() + local vm = fakeVm(save, { + setObjectSprite = function(object, sprite) + painted[#painted + 1] = { object = object, sprite = sprite } + end, + }) + vm.scriptVar = 2 + Specials.random = rolls({ 1, 1, 1, 1 }) + Specials.ALL.LoadOpponentTrainerAndPokemonWithOTSprite(vm) + eq(vm.scriptVar, 2, "wScriptVar still names the object, unwritten") + check(vm.btOpponent ~= nil, "the opponent is drawn onto the VM") + eq(vm.btOpponent.name, "HANSON", "the trainer's own name") + eq(vm.btOpponent.classId, "FISHER", "its class") + eq(#vm.btOpponent.rows, 3, "with three mons") + eq(#painted, 1, "the map object is repainted once") + eq(painted[1].object, 2, "the object wScriptVar named") + eq(painted[1].sprite, "SPRITE_FISHER", + "with BTTrainerClassSprites[class - 1]") + eq(save.battleTower.streak, 0, "the draw does not step the streak") + + -- A save is all it needs; a world with no sprite hook still draws. + local bare = crystalSave() + local plain = fakeVm(bare, {}) + Specials.random = rolls({ 1, 1, 1, 1 }) + Specials.ALL.LoadOpponentTrainerAndPokemonWithOTSprite(plain) + check(plain.btOpponent ~= nil, "and the draw survives a missing hook") + + -- A cache with no roster leaves nothing behind rather than half an opponent. + local goldVm = fakeVm(crystalSave(), { data = function() return {} end }) + Specials.ALL.LoadOpponentTrainerAndPokemonWithOTSprite(goldVm) + eq(goldVm.btOpponent, nil, "a cache with no roster draws nobody") +end + +-- ============================================================ BattleTowerBattle +local function towerBattle(save, outcome, extra) + local log = { heals = 0, started = nil } + local hooks = { + healParty = function() log.heals = log.heals + 1 end, + startTowerBattle = function(spec, done) + log.started = spec + log.healsAtStart = log.heals + done(outcome) + return true + end, + } + for key, value in pairs(extra or {}) do hooks[key] = value end + local vm = fakeVm(save, hooks) + Specials.random = rolls({ 1, 1, 1, 1 }) + Specials.ALL.LoadOpponentTrainerAndPokemonWithOTSprite(vm) + Specials.ALL.BattleTowerBattle(vm) + return vm, log +end + +do + local save = crystalSave() + local vm, log = towerBattle(save, "win") + -- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:236-237 + eq(vm.scriptVar, 0, "a win leaves wBattleResult 0 in wScriptVar") + eq(log.heals, 2, ":228 and :235, HealParty on both sides of the battle") + eq(log.healsAtStart, 1, "the first one before the send-out") + check(log.started ~= nil, "the battle was started") + eq(log.started.name, "FISHER HANSON", "named class-then-trainer") + eq(log.started.trainerName, "HANSON", "with the bare name kept") + eq(log.started.classId, "FISHER", "and the class key the pic reads") + eq(log.started.class, 37, "and its constant") + eq(log.started.baseMoney, 40, "the class's own payout multiplier") + eq(#log.started.party, 3, "three mons walk in") + eq(log.started.party[1].species, "JOLTEON", "the drawn team, built") + eq(log.started.party[1].maxHp, 41, "with the roster's stats") + + -- :549-570 CopyBTTrainer, which is what steps the counter. + eq(save.battleTower.streak, 1, "the streak steps once") + eq(save.battleTower.challenge, BattleTower.CHALLENGE_IN_PROGRESS, + "and the challenge is now in progress") + -- :240-250, the byte maps/BattleTowerBattleRoom.asm:38 reads back. + eq(vm.mem[BattleTower.WRAM_NR_BEATEN], 1, + "wNrOfBeatenBattleTowerTrainers holds the new count") + eq(vm.stringBuffer, "2", "and wStringBuffer3 the NEXT opponent's number") + eq(vm.btBattleEnded, 1, "wBattleTowerBattleEnded ends the loop") + eq(vm.btOpponent, nil, "and the drawn opponent is spent") +end + +do + local save = crystalSave() + save.battleTower.streak = 6 + local vm = towerBattle(save, "win") + eq(save.battleTower.streak, 7, "the seventh win reaches BATTLETOWER_STREAK_LENGTH") + eq(vm.mem[BattleTower.WRAM_NR_BEATEN], 7, + "which is what the room script's `ifequal 7` reads") +end + +do + local save = crystalSave() + local vm, log = towerBattle(save, "lose") + eq(vm.scriptVar, 1, "a loss leaves wBattleResult 1") + eq(log.heals, 2, "and still heals on both sides") + eq(vm.mem[BattleTower.WRAM_NR_BEATEN], nil, + ":238-239 skips the counter copy when the battle was lost") + eq(vm.stringBuffer, "", "and prints no next-opponent number") + eq(save.battleTower.streak, 1, + "the counter still stepped, because ReadBTTrainerParty ran") +end + +do + -- No hook at all: the challenge has to END rather than loop on an opponent + -- that never appears. + local save = crystalSave() + local vm = fakeVm(save, {}) + Specials.random = rolls({ 1, 1, 1, 1 }) + Specials.ALL.BattleTowerBattle(vm) + eq(vm.scriptVar, 1, "an unwired world reports a loss") + eq(vm.btBattleEnded, 1, "and ends the tower loop") + + local goldVm = fakeVm(crystalSave(), { data = function() return {} end }) + Specials.ALL.BattleTowerBattle(goldVm) + eq(goldVm.scriptVar, 1, "so does a cache with no roster") +end + +do + -- The special draws its own opponent when the room script never loaded one. + local save = crystalSave() + local log + local vm = fakeVm(save, { + healParty = function() end, + startTowerBattle = function(spec, done) log = spec; done("win"); return true end, + }) + Specials.random = rolls({ 1, 1, 1, 1 }) + Specials.ALL.BattleTowerBattle(vm) + check(log ~= nil and #log.party == 3, + "BattleTowerBattle alone still fields an opponent") +end + +-- ============================================ InitBattleTowerChallengeRAM +do + local save = crystalSave() + local vm = fakeVm(save, { pushScreen = function() return false end }) + vm.mem[BattleTower.WRAM_NR_BEATEN] = 4 + Specials.ALL.BattleTowerRoomMenu(vm) + eq(vm.mem[BattleTower.WRAM_NR_BEATEN], 0, + ":193 zeroes wNrOfBeatenBattleTowerTrainers with the rest") + eq(vm.btBattleEnded, 0, "and wBattleTowerBattleEnded with it") +end + +Specials.random = math.random + +S.finish() diff --git a/tests/gen2_battle_tower_test.lua b/tests/gen2_battle_tower_test.lua new file mode 100644 index 00000000..a4c7685c --- /dev/null +++ b/tests/gen2_battle_tower_test.lua @@ -0,0 +1,629 @@ +-- The Battle Tower: engine/events/battle_tower/battle_tower.asm, +-- engine/events/battle_tower/rules.asm and the room menu at +-- mobile/mobile_46.asm:137. +-- +-- luajit tests/gen2_battle_tower_test.lua +-- +-- ROM-free. The lobby rules, the challenge-state machine, the level/uber +-- room gates, the reward roll and the wInBattleTowerBattle badge guard, all +-- against fixtures. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 battle tower") +local check, eq = S.check, S.eq + +love = require("tests.love_stub") + +local Battle = require("src.battle.gen2.Battle") +local BattleTower = require("src.core.gen2.BattleTower") +local BattleTowerMenu = require("src.ui.gen2.BattleTowerMenu") +local Mon = require("src.battle.gen2.Mon") +local Save = require("src.core.gen2.Save") +local Screens = require("src.ui.Screens") +local Specials = require("src.script.gen2.Specials") + +local A = BattleTower.ACTIONS + +-- ---------------------------------------------------------------- fixtures + +local ITEM_ORDER = {} +do + -- ../pokecrystal/constants/item_constants.asm: only the span the reward roll + -- walks has to be at its real index, so the filler carries the rest. + for i = 1, 250 do ITEM_ORDER[i] = "FILLER_" .. i end + ITEM_ORDER[18] = "POTION" + ITEM_ORDER[26] = "HP_UP" + ITEM_ORDER[27] = "PROTEIN" + ITEM_ORDER[28] = "IRON" + ITEM_ORDER[29] = "CARBOS" + ITEM_ORDER[30] = "LUCKY_PUNCH" + ITEM_ORDER[31] = "CALCIUM" +end + +local DATA = { + gen2Constants = { itemOrder = ITEM_ORDER }, + items = { + POTION = { index = 18, pocket = "ITEM" }, + HP_UP = { index = 26, pocket = "ITEM" }, + CALCIUM = { index = 31, pocket = "ITEM" }, + }, +} + +local function mon(species, level, item, isEgg) + return { species = species, level = level, item = item, isEgg = isEgg } +end + +local function crystalSave() + local save = { version = "crystal", player = { id = 7, badges = {} }, + party = {}, inventory = {} } + return save +end + +-- The hooks World:specialHooks hands a handler, stubbed. +local function fakeVm(opts) + opts = opts or {} + local vm = { + scriptVar = opts.scriptVar or 0, + specials = opts.hooks or {}, + pages = {}, + stringBuffer = "", + } + function vm:setStringBuffer(value) self.stringBuffer = value or "" end + function vm:showRaw(body) + body = tostring(body) + if self.stringBuffer ~= "" then + body = body:gsub("{STRBUF}", self.stringBuffer) + end + self.pages[#self.pages + 1] = body + end + return vm +end + +local function towerVm(save, extra) + local hooks = { + save = function() return save end, + data = function() return DATA end, + party = function() return save.party end, + itemIndex = function(id) + for index, name in ipairs(ITEM_ORDER) do + if name == id then return index end + end + return 0 + end, + } + for key, value in pairs(extra or {}) do hooks[key] = value end + return fakeVm({ hooks = hooks }) +end + +local function action(vm, id) + vm.scriptVar = id + Specials.ALL.BattleTowerAction(vm) + return vm.scriptVar +end + +-- ============================================== the seam and the ownership +do + -- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:181-259 and + -- :1534-1576 joined the list once the roster reached trainers.lua; their own + -- behaviour is tests/gen2_battle_tower_battle_test.lua. + for _, name in ipairs({ "BattleTowerAction", "CheckForBattleTowerRules", + "Menu_ChallengeExplanationCancel", "BattleTowerRoomMenu", + "BattleTowerBattle", "LoadOpponentTrainerAndPokemonWithOTSprite" }) do + eq(Specials.HANDLER_SOURCE[name], "specials/battle_tower.lua", + name .. " is owned by specials/battle_tower.lua") + check(type(Specials.ALL[name]) == "function", + name .. " resolves to a function") + eq(Specials.STUBS[name], nil, name .. " is no longer a stub") + end + + for _, name in ipairs({ "BattleTowerMobileError" }) do + check(type(Specials.STUB_REASONS[name]) == "string", + name .. " is still a deliberate stub, with its reason") + end + + local found = false + for _, id in ipairs(Screens.GEN2_IDS) do + if id == "Gen2BattleTowerMenu" then found = true end + end + check(found, "Gen2BattleTowerMenu joined Screens.GEN2_IDS") +end + +-- ================================================= _CheckForBattleTowerRules +do + local legal = { mon("PIKACHU", 40, "BERRY"), mon("GEODUDE", 40, "GOLD_BERRY"), + mon("HORSEA", 40) } + local lines, failed = BattleTower.checkRules(legal) + eq(#lines, 0, "a legal three-mon party prints nothing") + eq(failed, false, "and _CheckForBattleTowerRules returns no carry") + + -- Two itemless mons do not collide: `ld a, [hl] / and a / jr z, .next` + -- drops a zero value before the inner scan ever runs. + local bare = { mon("PIKACHU", 40), mon("GEODUDE", 40), mon("HORSEA", 40) } + eq(#(BattleTower.checkRules(bare)), 0, + "three mons holding nothing is legal") + + lines = BattleTower.checkRules({ mon("PIKACHU", 40), mon("GEODUDE", 40) }) + eq(table.concat(lines, "|"), + "_ExcuseMeYoureNotReadyText|_OnlyThreeMonMayBeEnteredText|" + .. "_BattleTowerReturnWhenReadyText", + "two mons: the header, the count line and the tail") + + lines = BattleTower.checkRules({ mon("PIKACHU", 40), mon("PIKACHU", 40), + mon("HORSEA", 40) }) + eq(lines[2], "_TheMonMustAllBeDifferentKindsText", + "a duplicate species is the second check") + + lines = BattleTower.checkRules({ mon("PIKACHU", 40, "BERRY"), + mon("GEODUDE", 40, "BERRY"), mon("HORSEA", 40) }) + eq(lines[2], "_TheMonMustNotHoldTheSameItemsText", + "a duplicate held item is the third") + + lines = BattleTower.checkRules({ mon("PIKACHU", 40), mon("GEODUDE", 40), + mon("ODD_EGG", 5, nil, true) }) + eq(lines[2], "_YouCantTakeAnEggText", "an egg is the fourth") + + -- An egg is skipped by BOTH uniqueness walks (CheckPartyValueIsUnique's + -- `.isegg` guards each side), so it only trips its own check. + lines = BattleTower.checkRules({ mon("PIKACHU", 40, "BERRY"), + mon("PIKACHU", 40, "BERRY", true), mon("HORSEA", 40) }) + eq(table.concat(lines, "|"), + "_ExcuseMeYoureNotReadyText|_YouCantTakeAnEggText|" + .. "_BattleTowerReturnWhenReadyText", + "an egg duplicating a species and an item still only fails the egg rule") + + -- Every check runs; BattleTower_ExecuteJumptable does not stop at the first. + lines = BattleTower.checkRules({ mon("PIKACHU", 40, "BERRY"), + mon("PIKACHU", 40, "BERRY"), mon("HORSEA", 40, "BERRY"), + mon("ODD_EGG", 5, nil, true) }) + eq(table.concat(lines, "|"), + "_ExcuseMeYoureNotReadyText|_OnlyThreeMonMayBeEnteredText|" + .. "_TheMonMustAllBeDifferentKindsText|" + .. "_TheMonMustNotHoldTheSameItemsText|_YouCantTakeAnEggText|" + .. "_BattleTowerReturnWhenReadyText", + "four failures print the header, all four lines and the tail, in order") +end + +-- ---- the special itself +do + local save = crystalSave() + save.party = { mon("PIKACHU", 40), mon("GEODUDE", 40), mon("HORSEA", 40) } + local vm = towerVm(save) + Specials.ALL.CheckForBattleTowerRules(vm) + eq(vm.scriptVar, 0, "a legal party answers FALSE, the `ifnotequal FALSE` arm") + eq(#vm.pages, 0, "and prints nothing") + + save.party = { mon("PIKACHU", 40) } + vm = towerVm(save) + Specials.ALL.CheckForBattleTowerRules(vm) + eq(vm.scriptVar, 1, "a broken party answers TRUE") + eq(#vm.pages, 3, "and prints header, refusal and tail") + eq(vm.stringBuffer, "3", "wStringBuffer2 holds the '3' the lines splice in") +end + +-- ============================================== the rooms and their gates +do + local save = crystalSave() + eq(#BattleTower.levelGroupRows(save), 4, + "before the Hall of Fame only L10-L40 are offered") + save.hallOfFame = { count = 1, teams = {} } + eq(#BattleTower.levelGroupRows(save), 10, + "and all ten once STATUSFLAGS_HALL_OF_FAME_F is set") + + -- maps/BattleTowerHallway.asm:40-47, the room the receptionist walks to. + eq(BattleTower.roomOf(1), 0, "L10 is the 10/20 room") + eq(BattleTower.roomOf(2), 0, "L20 shares it") + eq(BattleTower.roomOf(3), 1, "L30 is the 30/40 room") + eq(BattleTower.roomOf(4), 1, "L40 shares it") + eq(BattleTower.roomOf(9), 4, "L90 is the 90/100 room") + eq(BattleTower.roomOf(10), 4, "L100 shares it") + + local party = { mon("PIKACHU", 30), mon("GEODUDE", 30), mon("HORSEA", 30) } + eq(BattleTower.levelCheck(party, 3), false, "L30 mons fit the L30 room") + eq(BattleTower.levelCheck(party, 2), true, "and top the L20 room") + party[1].level = 31 + eq(BattleTower.levelCheck(party, 3), true, "one mon over is enough") + + local ubers = { mon("LUGIA", 40), mon("GEODUDE", 40), mon("HORSEA", 40) } + eq(BattleTower.ubersCheck(ubers, 4), "LUGIA", + "an uber is refused below the L70 rooms") + eq(BattleTower.ubersCheck(ubers, 7), nil, + "and allowed from the L70 room up") + ubers[1] = mon("MEWTWO", 70) + eq(BattleTower.ubersCheck(ubers, 6), nil, + "an uber already at L70 is past the `cp 70 / jr c` gate") + eq(BattleTower.ubersCheck({ mon("DRAGONITE", 40) }, 4), nil, + "and DRAGONITE is not on the list") +end + +-- ==================================================== the reward (:906-976) +do + -- The `maskbits` roll is over eight values folded into six, so HP_UP and + -- PROTEIN come up twice as often; LUCKY_PUNCH is rerolled. + local seen = {} + for roll = 1, 8 do + local n = 0 + local item = BattleTower.rollReward(ITEM_ORDER, function(mask) + n = n + 1 + -- Specials.random answers 1..mask, and the handler folds it with % mask. + return (n == 1) and roll or 1 + end) + seen[roll % 8] = item + end + eq(seen[1], "PROTEIN", "roll 1 is PROTEIN") + eq(seen[2], "IRON", "roll 2 is IRON") + eq(seen[3], "CARBOS", "roll 3 is CARBOS") + eq(seen[5], "CALCIUM", "roll 5 is CALCIUM") + eq(seen[0], "HP_UP", "roll 0 is HP_UP") + eq(seen[6], "HP_UP", "roll 6 folds back onto HP_UP") + eq(seen[7], "PROTEIN", "roll 7 folds back onto PROTEIN") + check(seen[4] ~= "LUCKY_PUNCH", "roll 4 lands on LUCKY_PUNCH and rerolls") + + eq(BattleTower.rewardFits(19, 20, nil), true, + "a pocket with a free slot takes the five") + eq(BattleTower.rewardFits(20, 20, nil), false, + "a full pocket holding none of the reward cannot") + eq(BattleTower.rewardFits(20, 20, 94), true, + "a full pocket already holding 94 can stack five more") + eq(BattleTower.rewardFits(20, 20, 95), false, + "95 is where MAX_ITEM_STACK - 5 + 1 stops it") +end + +-- ================================== BattleTowerAction, every jumptable row +do + local save = crystalSave() + local vm = towerVm(save) + -- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:855-887 + local unhandled = {} + for id = 0, BattleTower.NUM_ACTIONS - 1 do + vm.scriptVar = id + local before = vm.scriptVar + local ok = pcall(Specials.ALL.BattleTowerAction, vm) + if not ok then unhandled[#unhandled + 1] = id end + if ok and vm.scriptVar == before and before ~= 0 then + -- a row that answers nothing must be one of the documented no-writes + local writesNothing = (id == A.SET_EXPLANATION_READ) + or (id == A.SAVE_AND_QUIT) or (id == A.CHALLENGECANCELED) + or (id == A.ACTION_06) or (id == A.SAVELEVELGROUP) + or (id == A.LOADLEVELGROUP) or (id == A.ACTION_0A) + or (id == A.ACTION_0C) or (id == A.ACTION_11) or (id == A.ACTION_12) + or (id == A.ACTION_15) or (id == A.ACTION_16) or (id == A.RESETDATA) + or (id == A.ACTION_1C) or (id == A.ACTION_1D) + or (id == A.CHOOSEREWARD) or (id == A.SAVEOPTIONS) + if not writesNothing then unhandled[#unhandled + 1] = id end + end + end + eq(table.concat(unhandled, ","), "", + "all 32 BattleTowerAction rows run, and only the cart's silent ones " + .. "leave wScriptVar alone") +end + +-- ---- the challenge state machine (:992-1022, :935-949) +do + local save = crystalSave() + local vm = towerVm(save) + eq(action(vm, A.GET_CHALLENGE_STATE), BattleTower.NO_CHALLENGE, + "a fresh save is BATTLETOWER_NO_CHALLENGE") + action(vm, A.SAVE_AND_QUIT) + eq(action(vm, A.GET_CHALLENGE_STATE), BattleTower.SAVED_AND_LEFT, + "the quicksave arm parks on BATTLETOWER_SAVED_AND_LEFT") + action(vm, A.ACTION_1C) + eq(action(vm, A.GET_CHALLENGE_STATE), BattleTower.WON_CHALLENGE, + "beating the seventh trainer is BATTLETOWER_WON_CHALLENGE") + action(vm, A.ACTION_1D) + eq(action(vm, A.GET_CHALLENGE_STATE), BattleTower.RECEIVED_REWARD, + "and taking the prize is BATTLETOWER_RECEIVED_REWARD") + action(vm, A.CHALLENGECANCELED) + eq(action(vm, A.GET_CHALLENGE_STATE), BattleTower.NO_CHALLENGE, + "cancelling clears it") +end + +-- ---- sBattleTowerSaveFileFlags (:978-1008, :1471-1492) +do + local save = crystalSave() + local tower = BattleTower.state(save) + eq(tower.saveFileFlags, 0, "a fresh save has no tower flags") + BattleTower.setSaveFileFlag(save, BattleTower.SAVEFILE_EXPLANATION) + eq(BattleTower.saveFileFlag(save, BattleTower.SAVEFILE_EXPLANATION), 2, + "`and 2` answers the masked byte, not a boolean") + eq(BattleTower.saveFileFlag(save, BattleTower.SAVEFILE_REGISTERED), 0, + "and bit 0 is untouched") + BattleTower.setSaveFileFlag(save, BattleTower.SAVEFILE_EXPLANATION) + eq(tower.saveFileFlags, 2, "`or 2` twice is still 2") + BattleTower.setSaveFileFlag(save, BattleTower.SAVEFILE_REGISTERED) + eq(tower.saveFileFlags, 3, "both bits live in the one byte") + + -- :979-982 -- with no save file on disk the routine returns FALSE without + -- ever reading the flags. + local vm = towerVm(save) + eq(action(vm, A.CHECK_EXPLANATION_READ), 0, + "no save file means the explanation check answers FALSE") + eq(action(vm, A.CHECKSAVEFILEISYOURS), 0, + "and so does the save-file check itself") +end + +-- ---- the level group (:1129-1155) and the GS Ball (:1179-1185) +do + local save = crystalSave() + local vm = towerVm(save) + vm.btLevelGroup = 7 + action(vm, A.SAVELEVELGROUP) + eq(BattleTower.state(save).levelGroup, 7, + "SAVELEVELGROUP banks wBTChoiceOfLvlGroup in SRAM") + vm.btLevelGroup = nil + action(vm, A.LOADLEVELGROUP) + eq(vm.btLevelGroup, 7, "and LOADLEVELGROUP brings it back for the hallway") + + eq(action(vm, A.GSBALL), 0, "no GS Ball, no scene") + Save.crystalState(save).gsBall = "have" + eq(action(vm, A.GSBALL), BattleTower.GS_BALL_AVAILABLE, + "sGSBallFlag reads back as GS_BALL_AVAILABLE ($b)") + Save.crystalState(save).gsBall = "given" + eq(action(vm, A.GSBALL), BattleTower.GS_BALL_AVAILABLE, + "and stays set once the ball has been handed over") +end + +-- ---- RESETDATA and the prize (:890-933, :955-976) +do + local save = crystalSave() + local tower = BattleTower.state(save) + tower.streak = 5 + tower.trainers = { 3, 9 } + local vm = towerVm(save) + action(vm, A.RESETDATA) + eq(tower.streak, 0, "RESETDATA clears sNrOfBeatenBattleTowerTrainers") + eq(next(tower.trainers), nil, "and the seven sBTTrainers slots") + + local rewards = {} + for _ = 1, 200 do + action(vm, A.CHOOSEREWARD) + rewards[tower.reward] = true + end + eq(rewards.POTION, nil, + "CHOOSEREWARD never falls back on POTION: it reads gen2Constants.itemOrder") + eq(rewards[BattleTower.SKIPPED_REWARD], nil, "and never rolls LUCKY_PUNCH") + for name in pairs(rewards) do + check(name == "HP_UP" or name == "PROTEIN" or name == "IRON" + or name == "CARBOS" or name == "CALCIUM", + name .. " is inside BATTLETOWER_MIN_REWARD..MAX_REWARD") + end + eq(rewards.HP_UP, true, "and the whole span comes up (HP_UP)") + eq(rewards.CALCIUM, true, "through CALCIUM") + + tower.reward = "CALCIUM" + eq(action(vm, A.GIVEREWARD), 31, "GIVEREWARD answers the item id") + -- A full ITEM pocket that does not already hold the reward: the desk hands + -- over a POTION, which is the script's `ifequal POTION` arm. + for i = 1, 20 do save.inventory["FILLER_" .. i] = 1 end + eq(action(vm, A.GIVEREWARD), 18, + "a stuffed pack turns the prize into POTION") +end + +-- ---- Menu_ChallengeExplanationCancel (mobile/mobile_5f.asm:425-468) +do + local save = crystalSave() + for row = 1, 3 do + local vm = towerVm(save, { + scriptMenu = function(_header, done) done(row) end, + }) + vm.scriptVar = 1 + Specials.ALL.Menu_ChallengeExplanationCancel(vm) + eq(vm.scriptVar, row, "row " .. row .. " comes back as wScriptVar") + end + local vm = towerVm(save, { + scriptMenu = function(_header, done) done(0) end, + }) + vm.scriptVar = 1 + Specials.ALL.Menu_ChallengeExplanationCancel(vm) + eq(vm.scriptVar, 4, "a B press is the `.Exit` arm's 4") + + vm = towerVm(save) + vm.scriptVar = 1 + Specials.ALL.Menu_ChallengeExplanationCancel(vm) + eq(vm.scriptVar, 4, "and so is a run with no menu to open") +end + +-- ---- BattleTowerRoomMenu (battle_tower.asm:1-5) +do + local save = crystalSave() + save.hallOfFame = { count = 1, teams = {} } + local pushed + local vm = towerVm(save, { + pushScreen = function(id, opts) + pushed = id + opts.onDone(6) + return true + end, + }) + Specials.ALL.BattleTowerRoomMenu(vm) + eq(pushed, "Gen2BattleTowerMenu", "the desk opens the room menu screen") + eq(vm.scriptVar, 0, "a chosen room answers 0, the script's `ifnotequal $0`") + eq(vm.btLevelGroup, 6, "and wBTChoiceOfLvlGroup holds the L60 room") + + vm = towerVm(save, { + pushScreen = function(_id, opts) + opts.onDone(nil) + return true + end, + }) + Specials.ALL.BattleTowerRoomMenu(vm) + eq(vm.scriptVar, 0x0a, "cancelling answers $a, the desk's loop-back arm") + + vm = towerVm(save) + Specials.ALL.BattleTowerRoomMenu(vm) + eq(vm.scriptVar, 0x0a, "and so does a run with no screen to push") +end + +-- ============================================ the room menu screen itself +do + eq(BattleTowerMenu.levelLabel(1), " L:10 ", "Strings_L10ToL100 row 1") + eq(BattleTowerMenu.levelLabel(10), " L:100", "and row 10, six tiles wide") + + local pressed = {} + local game = { input = { wasPressed = function(_, name) + return pressed[name] == true + end } } + local function press(name) pressed = { [name] = true } end + + local save = crystalSave() + save.hallOfFame = { count = 1, teams = {} } + local result, calls = nil, 0 + local screen = BattleTowerMenu.new(game, { + save = save, + party = { mon("PIKACHU", 20), mon("GEODUDE", 20), mon("HORSEA", 20) }, + onDone = function(value) result = value; calls = calls + 1 end, + }) + eq(screen.cursor, 1, "the spinner opens on L:10") + eq(screen:rowCount(), 11, "ten rooms plus CANCEL") + + press("up"); screen:update() + eq(screen.cursor, 2, "UP walks the level up") + press("down"); screen:update() + press("down"); screen:update() + eq(screen.cursor, 11, "DOWN off the top rolls over to CANCEL") + press("up"); screen:update() + eq(screen.cursor, 1, "and UP off CANCEL rolls back to L:10") + + -- A party at L20 tops the L10 room (mobile/mobile_46.asm:3915-3917). + press("a"); screen:update() + eq(screen.phase, "message", "picking L:10 with L20 mons prints a refusal") + eq(calls, 0, "and does not answer the script") + for _ = 1, 0x80 do pressed = {}; screen:update() end + eq(screen.phase, "pick", "the $80-frame hold puts the menu back") + eq(screen.cursor, 1, "at jumptable index 0, cursor reset") + + press("up"); screen:update() + press("a"); screen:update() + eq(result, 2, "L:20 is accepted and hands back the level group") + eq(calls, 1, "exactly once") +end + +do + -- The uber gate, and the CANCEL path's yes/no. + local pressed = {} + local game = { input = { wasPressed = function(_, name) + return pressed[name] == true + end } } + local function press(name) pressed = { [name] = true } end + + local save = crystalSave() + local result, answered = nil, false + local screen = BattleTowerMenu.new(game, { + save = save, + party = { mon("LUGIA", 40), mon("GEODUDE", 40), mon("HORSEA", 40) }, + monName = function(id) return id end, + onDone = function(value) result = value; answered = true end, + }) + eq(screen:rowCount(), 5, "no Hall of Fame, so four rooms plus CANCEL") + for _ = 1, 3 do press("up"); screen:update() end + eq(screen.cursor, 4, "on L:40") + press("a"); screen:update() + eq(screen.phase, "message", "an uber under L70 is refused below the L70 room") + check(screen.message:find("LUGIA", 1, true) ~= nil, + "and the refusal names it, the way text_ram wcd49 does") + + for _ = 1, 0x80 do pressed = {}; screen:update() end + eq(screen.cursor, 1, "the refusal restarts the menu at L:10") + for _ = 1, 4 do press("up"); screen:update() end + eq(screen.cursor, 5, "CANCEL is the last row") + press("a"); screen:update() + eq(screen.phase, "quit", "CANCEL opens the yes/no") + eq(screen.yes, true, "on YES") + press("down"); screen:update() + press("a"); screen:update() + eq(screen.phase, "pick", "NO puts the level spinner back") + eq(answered, false, "and answers nothing yet") + press("b"); screen:update() + eq(screen.phase, "quit", "B on the spinner is the same cancel prompt") + press("a"); screen:update() + eq(answered, true, "YES ends the menu") + eq(result, nil, "with no level group, which the handler turns into $a") +end + +-- ================================ wInBattleTowerBattle and the badge boosts +do + local GROWTH = { GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1, + squared = 0, linear = 0, constant = 0 } } + local BATTLE_DATA = { + pokemon = { + growthRates = GROWTH, + MACHOP = { id = "MACHOP", index = 66, name = "MACHOP", + baseStats = { hp = 70, attack = 80, defense = 50, speed = 35, + specialAttack = 35, specialDefense = 35 }, + types = { "NORMAL", "NORMAL" }, catchRate = 180, baseExp = 75, + growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 63, + levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {} }, + PIDGEY = { id = "PIDGEY", index = 16, name = "PIDGEY", + baseStats = { hp = 40, attack = 45, defense = 40, speed = 56, + specialAttack = 35, specialDefense = 35 }, + types = { "NORMAL", "FLYING" }, catchRate = 255, baseExp = 55, + growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 127, + levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {} }, + }, + moves = {}, type_chart = { types = {}, matchups = {} }, items = {}, + } + local perfect = { attack = 15, defense = 15, speed = 15, special = 15 } + perfect.hp = Mon.hpDV(perfect) + + local BADGES = { ZEPHYR = true, HIVE = true, PLAIN = true, FOG = true, + MINERAL = true, STORM = true, GLACIER = true, RISING = true } + + local function newBattle(tower) + local player = Mon.new(BATTLE_DATA, "MACHOP", 40, { dvs = perfect }) + local wild = Mon.new(BATTLE_DATA, "PIDGEY", 40, { dvs = perfect }) + return Battle.new({ data = BATTLE_DATA, party = { player }, wild = wild, + save = { player = { id = 7, badges = BADGES, kantoBadges = {} } }, + battleTower = tower, + random = function(n) return (n or 1) > 1 and 1 or 0 end }), player + end + + local outside, player = newBattle(false) + eq(outside.inBattleTowerBattle, false, "an ordinary battle is not a Tower one") + eq(outside:battleStat(player, "attack"), + Battle.boostStat(player.stats.attack), + "outside the Tower ZEPHYRBADGE still boosts Attack") + eq(outside:battleStat(player, "speed"), + Battle.boostStat(player.stats.speed), "and PLAINBADGE Speed") + eq(outside:badgeTypeBoost(player, "FLYING"), true, + "and DoBadgeTypeBoosts still fires") + + local inside, towerPlayer = newBattle(true) + eq(inside.inBattleTowerBattle, true, "the Tower battle sets it") + eq(inside:battleStat(towerPlayer, "attack"), towerPlayer.stats.attack, + "BadgeStatBoosts' second early return drops the Attack boost") + eq(inside:battleStat(towerPlayer, "defense"), towerPlayer.stats.defense, + "and Defense") + eq(inside:battleStat(towerPlayer, "speed"), towerPlayer.stats.speed, + "and Speed") + eq(inside:battleStat(towerPlayer, "specialAttack"), + towerPlayer.stats.specialAttack, "and Special Attack") + eq(inside:battleStat(towerPlayer, "specialDefense"), + towerPlayer.stats.specialDefense, + "and GLACIERBADGE's buggy Special Defense re-check with it") + eq(inside:badgeTypeBoost(towerPlayer, "FLYING"), false, + "DoBadgeTypeBoosts takes the same guard (engine/battle/misc.asm:152)") + + -- The obedience ladder shares Battle:hasBadge and the cart does NOT guard + -- it (engine/battle/effect_commands.asm:671-696 reads wJohtoBadges raw). + eq(inside:obedienceLevel(), outside:obedienceLevel(), + "obedience reads the badges either way") + eq(inside:hasBadge("badges", "ZEPHYR"), true, + "and Battle:hasBadge itself is untouched") +end + +-- ================================================ Gold and Silver are clean +do + for _, version in ipairs({ "gold", "silver" }) do + local save = Save.normalize({ version = version, generation = 2 }) + eq(save.version, version, version .. " keeps its own version") + eq(save.battleTower, nil, version .. " grows no battleTower block") + eq(save.crystal, nil, "nor a crystal one") + end + local crystal = Save.normalize({ version = "crystal", generation = 2 }) + eq(crystal.version, "crystal", "and the Crystal file stays Crystal") + check(type(crystal.battleTower) == "table", + "which does carry sBattleTowerChallengeState") + eq(crystal.battleTower.challenge, BattleTower.NO_CHALLENGE, + "starting at BATTLETOWER_NO_CHALLENGE") +end + +S.finish() diff --git a/tests/gen2_battletype_escape_test.lua b/tests/gen2_battletype_escape_test.lua new file mode 100644 index 00000000..be24cc6c --- /dev/null +++ b/tests/gen2_battletype_escape_test.lua @@ -0,0 +1,55 @@ +-- TryToRunAwayFromBattle's battle-type ladder, which nothing covered: +-- ../pokecrystal/engine/battle/core.asm:3687-3694 refuses TRAP, CELEBI, +-- FORCESHINY and SUICUNE, pokegold/engine/battle/core.asm:3476-3479 only the +-- first and third, and the values themselves come from +-- ../pokecrystal/constants/battle_constants.asm:91-103. +-- luajit tests/gen2_battletype_escape_test.lua +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 battletype escape") +local check, eq = S.check, S.eq + +love = require("tests.love_stub") + +local Battle = require("src.battle.gen2.Battle") + +-- ../pokecrystal/constants/battle_constants.asm:91-103, `const_def` from 0. +local NORMAL, CANLOSE, DEBUG, TUTORIAL = 0, 1, 2, 3 +local FISH, ROAMING, CONTEST, FORCESHINY = 4, 5, 6, 7 +local TREE, TRAP, FORCEITEM, CELEBI, SUICUNE = 8, 9, 10, 11, 12 + +eq(Battle.BATTLETYPE_CANLOSE, CANLOSE, "BATTLETYPE_CANLOSE is 1") +eq(Battle.BATTLETYPE_FORCESHINY, FORCESHINY, "BATTLETYPE_FORCESHINY is 7") +eq(Battle.BATTLETYPE_TRAP, TRAP, "BATTLETYPE_TRAP is 9") +eq(Battle.BATTLETYPE_CELEBI, CELEBI, "BATTLETYPE_CELEBI is 11, after FORCEITEM") +eq(Battle.BATTLETYPE_SUICUNE, SUICUNE, "BATTLETYPE_SUICUNE is 12") + +local function refuses(value) + return Battle.noEscapeBattleType({ battleType = value }) == true +end + +for _, row in ipairs({ + { NORMAL, false, "NORMAL" }, + { CANLOSE, false, "CANLOSE" }, + { DEBUG, false, "DEBUG" }, + { TUTORIAL, false, "TUTORIAL" }, + { FISH, false, "FISH" }, + { ROAMING, false, "ROAMING" }, + { CONTEST, false, "CONTEST" }, + { FORCESHINY, true, "FORCESHINY" }, + { TREE, false, "TREE" }, + { TRAP, true, "TRAP" }, + -- ../pokecrystal/maps/TinTower1F.asm:120 and pokegold's Lugia and Ho-Oh both + -- arm FORCEITEM, which the ladder does not name: it is escapable. + { FORCEITEM, false, "FORCEITEM" }, + { CELEBI, true, "CELEBI" }, + { SUICUNE, true, "SUICUNE" }, +}) do + local value, want, name = row[1], row[2], row[3] + eq(refuses(value), want, + ("%s (%d) %s escape"):format(name, value, want and "refuses" or "allows")) +end + +check(refuses(nil) == false, "an unarmed battle escapes") + +S.finish() diff --git a/tests/gen2_billspc_dpad_test.lua b/tests/gen2_billspc_dpad_test.lua new file mode 100644 index 00000000..29976170 --- /dev/null +++ b/tests/gen2_billspc_dpad_test.lua @@ -0,0 +1,171 @@ +-- Bill's PC d-pad: only MOVE W/O MAIL walks the boxes (#1710). +-- +-- luajit tests/gen2_billspc_dpad_test.lua +-- +-- ROM-free. Withdraw_UpDown is the entire joypad handler for the WITHDRAW +-- list and the DEPOSIT list (engine/pokemon/bills_pc.asm:806-820) and reads +-- PAD_UP and PAD_DOWN and nothing else. BillsPC_PressLeft / PressRight +-- (:909-931) are reachable only through MoveMonWithoutMail_DPad and +-- MoveMonWithoutMail_DPad_2 (:822-869), whose one caller is +-- _MovePKMNWithoutMail (:480) -- so left and right belong to the MOVE screen +-- alone, both while it is choosing a mon and while the insert cursor is up. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 bills pc dpad") +local check, eq = S.check, S.eq + +love = require("tests.love_stub") + +local BoxMenu = require("src.ui.gen2.BoxMenu") +local Boxes = require("src.core.gen2.Boxes") + +local function mon(species) + return { species = species, nickname = species, name = species, + level = 5, hp = 20, maxHp = 20 } +end + +local function newInput() + local input = { pressed = {} } + function input:press(...) + for _, button in ipairs({ ... }) do self.pressed[button] = true end + end + function input:wasPressed(button) + if self.pressed[button] then + self.pressed[button] = nil + return true + end + return false + end + function input:isDown() return false end + return input +end + +local function newGame(save) + local input = newInput() + return { + input = input, + save = save, + data = { audio = {}, pokemon = {} }, + stack = { _items = {}, + push = function(self, s) self._items[#self._items + 1] = s end, + pop = function(self) return table.remove(self._items) end, + top = function(self) return self._items[#self._items] end, + }, + }, input +end + +-- Three party mons and two boxes with something in them, so every list under +-- test has rows and the last-healthy rule never gets in the way. +local function newSave() + local save = { party = { mon("CYNDAQUIL"), mon("TOTODILE"), mon("PIDGEY") }, + boxes = {}, boxNames = {}, currentBox = 3 } + Boxes.box(save, 3)[1] = mon("GEODUDE") + Boxes.box(save, 3)[2] = mon("ZUBAT") + Boxes.box(save, 4)[1] = mon("ONIX") + return save +end + +local function press(screen, input, ...) + for _, button in ipairs({ ... }) do + input:press(button) + screen:update(0) + end +end + +local function open(mode) + local save = newSave() + local game, input = newGame(save) + local menu = BoxMenu.new(game, { save = save, mode = mode, + onClose = function() end }) + return menu, input, save +end + +-- ---- the withdraw list ---------------------------------------------------- +do + local menu, input = open("withdraw") + eq(menu.boxIndex, 3, "the withdraw list opens on the current box") + eq(menu:title(), "BOX3", "and the header names it") + + press(menu, input, "left") + eq(menu.boxIndex, 3, "LEFT on the withdraw list does not change the box") + eq(menu:title(), "BOX3", "the header still reads BOX3") + eq(menu.index, 1, "and the cursor has not been reset by a box step") + + -- Two more LEFTs, so a screen that steps and then steps back cannot pass by + -- landing on 3 again. + press(menu, input, "left", "left") + eq(menu.boxIndex, 3, "and neither do two more") + eq(#menu:list(), 2, "the list is still BOX3's two mons") + + -- RIGHT from a fresh screen, for the same reason. + menu, input = open("withdraw") + press(menu, input, "right", "right") + eq(menu.boxIndex, 3, "RIGHT does not change the box either") + eq(menu:title(), "BOX3", "the header is unmoved") + + -- Withdraw_UpDown DOES read up and down, so the fix must not have taken the + -- whole d-pad away. + press(menu, input, "down") + eq(menu.index, 2, "DOWN still walks the list") + press(menu, input, "up") + eq(menu.index, 1, "and UP walks back") +end + +-- ---- the deposit list ----------------------------------------------------- +-- +-- _DepositPKMN's .HandleJoypad calls the same Withdraw_UpDown, and its list is +-- the party (wBillsPC_LoadedBox is zeroed at bills_pc.asm:17-18), so there is +-- no box for left and right to walk to in the first place. +do + local menu, input = open("deposit") + eq(menu:title(), "PARTY ", "the deposit list browses the party") + local before = menu.boxIndex + press(menu, input, "left", "right", "left") + eq(menu.boxIndex, before, "the deposit list ignores left and right") + eq(menu:title(), "PARTY ", "and stays on the party") + eq(#menu:list(), 3, "with all three party mons") + press(menu, input, "down") + eq(menu.index, 2, "up and down still work here too") +end + +-- ---- the move screen ------------------------------------------------------ +-- +-- The half that is supposed to have box stepping. Gating this on the wrong +-- mode string ("withdraw") would leave the reported bug in place and break +-- this instead, which is why it is asserted next to the other two. +do + local menu, input, save = open("move") + eq(menu.boxIndex, 3, "the move screen opens on the current box") + + press(menu, input, "right") + eq(menu.boxIndex, 4, "RIGHT walks to the next box") + eq(menu:title(), "BOX4", "and the header follows") + eq(#menu:list(), 1, "showing BOX4's one mon") + + press(menu, input, "left", "left", "left", "left") + eq(menu.boxIndex, 0, "four LEFTs from BOX4 reach the PARTY, box 0") + eq(menu:title(), "PARTY ", "BillsPC_BoxName's .party arm") + eq(#menu:list(), 3, "and the list is the party") + + press(menu, input, "left") + eq(menu.boxIndex, Boxes.NUM_BOXES, + "LEFT off box 0 wraps to the last box (BillsPC_PressLeft)") + press(menu, input, "right") + eq(menu.boxIndex, 0, "and RIGHT wraps back through it") + + -- The insert cursor is the separate .Joypad2 arm, and it walks boxes too: + -- choose the first party mon, MOVE, then drive the cursor to BOX1. + press(menu, input, "a", "a") + eq(menu.phase, "insert", "MOVE opens the insert cursor") + press(menu, input, "right") + eq(menu.boxIndex, 1, "RIGHT walks the insert cursor to BOX1") + eq(menu:title(), "BOX1", "and the header names the destination") + press(menu, input, "left") + eq(menu.boxIndex, 0, "LEFT walks it back to the party") + press(menu, input, "right", "a") + eq(#save.party, 2, "A there really does move the mon") + eq(Boxes.box(save, 1)[1].nickname, "CYNDAQUIL", "into the box it named") +end + +S.finish() diff --git a/tests/gen2_box_intake_test.lua b/tests/gen2_box_intake_test.lua new file mode 100644 index 00000000..37e9d5d9 --- /dev/null +++ b/tests/gen2_box_intake_test.lua @@ -0,0 +1,280 @@ +-- Everything that lands a mon in a box lands it healed (#1696). +-- +-- luajit tests/gen2_box_intake_test.lua +-- +-- ROM-free. box_struct ends at Level and has no Status, no HP and no MaxHP +-- at all -- those three belong to party_struct alone (macros/ram.asm:7-40) -- +-- so a boxed mon has nowhere to keep damage, and every screen that reads one +-- goes through CalcTempmonStats, whose .not_egg arm copies MON_MAXHP over +-- MON_HP and whose .zero_status arm blanks MON_STATUS before anything is +-- drawn (engine/pokemon/tempmon.asm:39-83). The four intakes are the catch +-- that overflows to the PC (`.SendToPC` / SendMonIntoBox, +-- engine/items/item_effects.asm:604, engine/pokemon/move_mon.asm:942-1065), +-- the PC's own deposit, MOVE W/O MAIL, and the bug contest's +-- full-party arm (engine/pokemon/caught_nickname.asm:72-90). +-- +-- The asymmetry at the bottom is real cart behaviour and is the near-miss to +-- watch: a catch that fits in the PARTY goes through TryAddMonToParty +-- (item_effects.asm:551-556) and KEEPS its damage, so a fix that heals every +-- capture is as wrong as one that heals none. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 box intake") +local check, eq = S.check, S.eq + +love = require("tests.love_stub") + +local Battle = require("src.battle.gen2.Battle") +local BattleState = require("src.ui.gen2.BattleState") +local BoxMenu = require("src.ui.gen2.BoxMenu") +local Boxes = require("src.core.gen2.Boxes") +local BugContest = require("src.core.gen2.BugContest") +local Input = require("src.core.Input") +local Mon = require("src.battle.gen2.Mon") + +-- A mon hurt and statused exactly the way a ball lands on one. +local function hurt(species) + return { species = species, nickname = species, name = species, level = 5, + hp = 1, maxHp = 20, status = "sleep", statusTurns = 3, + moves = { { id = "TACKLE", pp = 2, maxPp = 35 } } } +end + +local function healthy(species) + return { species = species, nickname = species, name = species, level = 5, + hp = 20, maxHp = 20, + moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } } +end + +local function newSave(party) + return { party = party or {}, boxes = {}, boxNames = {}, currentBox = 1 } +end + +-- ---- Boxes.enterBox, the shared seam -------------------------------------- +do + local mon = hurt("PIDGEY") + Boxes.enterBox(mon) + eq(mon.hp, 20, "a mon entering a box is at full HP") + eq(mon.status, nil, "with no status byte to carry it") + eq(mon.statusTurns, nil, "and no sleep counter either") + eq(mon.moves[1].pp, 35, "RestorePPOfDepositedPokemon refilled its PP") + + -- .not_egg is skipped for an EGG: CalcTempmonStats writes 0 over MON_HP + -- instead, which is what keeps a stored egg from reading as hatchable. + local egg = hurt("TOGEPI") + egg.isEgg = true + Boxes.enterBox(egg) + eq(egg.hp, 0, "an EGG stays at 0 HP") + eq(egg.status, nil, "and still loses the status byte") + + -- A mon with no maxHp recorded must not come out with hp nil, which would + -- read as fainted everywhere downstream. + local odd = { species = "MISSINGNO", hp = 4 } + Boxes.enterBox(odd) + eq(odd.hp, 4, "a mon with no maxHp keeps the HP it had") + eq(Boxes.enterBox(nil), nil, "and nil is handed straight back") +end + +-- ---- the PC's own deposit ------------------------------------------------- +-- +-- Read while the mon is STILL IN THE BOX: this is what the PC's STATS screen +-- shows, and healing only on the way back out looks right in the party while +-- the storage screen still reports 1 HP and SLP. +do + local save = newSave({ healthy("CYNDAQUIL"), hurt("PIDGEY"), + healthy("GEODUDE") }) + local ok = Boxes.deposit(save, 2, 1) + check(ok, "the hurt mon deposits") + local boxed = Boxes.box(save, 1)[1] + eq(boxed.species, "PIDGEY", "and it is the one in the box") + eq(boxed.hp, 20, "sitting there at full HP") + eq(boxed.status, nil, "with the status gone") + eq(boxed.moves[1].pp, 35, "and its PP back") + + -- ...and the withdraw arm still hands back the same whole mon. + local took + ok, took = Boxes.withdraw(save, 1, 1) + check(ok, "withdrawing it works") + eq(took.hp, 20, "and it is still whole on the way out") +end + +-- ---- MOVE W/O MAIL ---------------------------------------------- +do + local function newInput() + local input = { pressed = {} } + function input:press(...) + for _, button in ipairs({ ... }) do self.pressed[button] = true end + end + function input:wasPressed(button) + if self.pressed[button] then + self.pressed[button] = nil + return true + end + return false + end + function input:isDown() return false end + return input + end + + local function press(screen, input, ...) + for _, button in ipairs({ ... }) do + input:press(button) + screen:update(0) + end + end + + local function openMove(save) + local input = newInput() + local game = { + input = input, save = save, data = { audio = {}, pokemon = {} }, + stack = { _items = {}, + push = function(self, s) self._items[#self._items + 1] = s end, + pop = function(self) return table.remove(self._items) end, + top = function(self) return self._items[#self._items] end, + }, + } + return BoxMenu.new(game, { save = save, mode = "move", + onClose = function() end }), input + end + + -- party -> box: .CopyToBox writes a box_struct, so the damage cannot follow. + local save = newSave({ hurt("PIDGEY"), healthy("CYNDAQUIL"), + healthy("GEODUDE") }) + local menu, input = openMove(save) + press(menu, input, "left") + eq(menu.boxIndex, 0, "the move screen walks left to the PARTY") + press(menu, input, "a", "a") + eq(menu.phase, "insert", "MOVE opens the insert cursor") + press(menu, input, "right", "a") + eq(#save.party, 2, "the hurt mon left the party") + local boxed = Boxes.box(save, 1)[1] + eq(boxed and boxed.nickname, "PIDGEY", "and landed in BOX1") + eq(boxed and boxed.hp, 20, "at full HP") + eq(boxed and boxed.status, nil, "with no status") + eq(boxed and boxed.moves[1].pp, 35, "and full PP") + + -- party -> party is the same screen's reorder, and party_struct HAS the + -- three fields, so this arm must leave the damage exactly where it was. + save = newSave({ hurt("PIDGEY"), healthy("CYNDAQUIL"), healthy("GEODUDE") }) + menu, input = openMove(save) + press(menu, input, "left") + eq(menu.boxIndex, 0, "back on the party") + press(menu, input, "a", "a", "down", "down", "a") + eq(#save.party, 3, "the party still has three") + local moved + for _, mon in ipairs(save.party) do + if mon.nickname == "PIDGEY" then moved = mon end + end + eq(moved and moved.hp, 1, "a party-to-party move keeps the damage") + eq(moved and moved.status, "sleep", "and keeps the status") +end + +-- ---- the bug contest's full-party arm ------------------------------------- +do + local save = newSave({ healthy("A"), healthy("B"), healthy("C"), + healthy("D"), healthy("E"), healthy("F") }) + save.currentBox = 2 + save.playerName = "GOLD" + BugContest.start(save) + BugContest.switchCaught(save, hurt("SCYTHER")) + local result = BugContest.collectCaughtMon(save, 6) + eq(result, BugContest.BOXED_MON, "a full party sends the contest catch to a box") + local boxed = Boxes.box(save, 2)[1] + eq(boxed and boxed.nickname, "SCYTHER", "it is in the current box") + eq(boxed and boxed.hp, 20, "at full HP") + eq(boxed and boxed.status, nil, "with no status") + eq(boxed and boxed.moves[1].pp, 35, "and its PP restored") +end + +-- ---- the catch that overflows to the PC ----------------------------------- +-- +-- The whole chain, through the real BattleState: a ball that lands while the +-- party is full runs `.SendToPC`, and what the PC's STATS screen then reads +-- is the box_struct SendMonIntoBox wrote. +do + local TYPES = { NORMAL = { id = "NORMAL", index = 0, + category = "physical" } } + local MOVES = { TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, + type = "NORMAL", accuracy = 95, pp = 35, effect = "EFFECT_NORMAL_HIT" } } + local POKEMON = { + growthRates = { GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1, + squared = 0, linear = 0, constant = 0 } }, + CYNDAQUIL = { + id = "CYNDAQUIL", index = 155, name = "CYNDAQUIL", + baseStats = { hp = 39, attack = 52, defense = 43, speed = 65, + specialAttack = 60, specialDefense = 50 }, + types = { "NORMAL", "NORMAL" }, catchRate = 45, baseExp = 65, + growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 31, + levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {}, + }, + PIDGEY = { + id = "PIDGEY", index = 16, name = "PIDGEY", + baseStats = { hp = 40, attack = 45, defense = 40, speed = 56, + specialAttack = 35, specialDefense = 35 }, + types = { "NORMAL", "NORMAL" }, catchRate = 255, baseExp = 55, + growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 127, + levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {}, + }, + } + local DATA = { + pokemon = POKEMON, moves = MOVES, + type_chart = { types = TYPES, matchups = {} }, + items = { POKE_BALL = { id = "POKE_BALL", name = "POKe BALL", + pocket = "BALL" } }, + } + local perfect = { attack = 15, defense = 15, speed = 15, special = 15 } + perfect.hp = Mon.hpDV(perfect) + + -- partySize 6 fills the party so the catch overflows; 5 leaves it room. + local function catch(partySize) + Input:init() + local party = {} + for i = 1, partySize do + local mon = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }) + mon.nickname = "MON" .. i + mon.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + party[i] = mon + end + local wild = Mon.new(DATA, "PIDGEY", 5, { dvs = perfect }) + wild.moves = { { id = "TACKLE", pp = 4, maxPp = 35 } } + -- one HP and asleep: what a ball usually lands on + wild.hp = 1 + wild.status = "sleep" + wild.statusTurns = 3 + local save = { party = party, inventory = { POKE_BALL = 1 }, + currentBox = 3, boxes = {}, boxNames = {} } + local game = { + data = DATA, save = save, input = Input, options = {}, + stack = { push = function() end, pop = function() end, + top = function() return nil end }, + } + -- random 0: the catch roll always lands. + local battle = Battle.new({ data = DATA, party = party, wild = wild, + save = save, random = function() return 0 end }) + local screen = BattleState.new(game, { battle = battle, save = save }) + screen:useItem("POKE_BALL") + return battle, save, wild + end + + local battle, save, wild = catch(6) + eq(battle.outcome, "caught", "the ball lands on a full party") + eq(#save.party, 6, "the party did not grow") + local boxed = Boxes.box(save, 3)[1] + eq(boxed, wild, "SendMonIntoBox put the catch in slot 1 of the current box") + eq(boxed and boxed.hp, boxed and boxed.maxHp, + "and the PC holds it at full HP") + eq(boxed and boxed.status, nil, "with no status on the STATS screen") + eq(boxed and boxed.statusTurns, nil, "and no sleep counter behind it") + eq(boxed and boxed.moves[1].pp, 35, "PP refilled with the rest") + + -- The other half of the cart's asymmetry. + battle, save, wild = catch(5) + eq(battle.outcome, "caught", "the ball lands with room in the party") + eq(save.party[6], wild, "and TryAddMonToParty keeps it in the party") + eq(wild.hp, 1, "at the 1 HP it was caught on") + eq(wild.status, "sleep", "still asleep") + eq(wild.moves[1].pp, 4, "and with the PP it had left") + eq(Boxes.count(save, 3), 0, "nothing went to the PC") +end + +S.finish() diff --git a/tests/gen2_chamber_fieldmoves_test.lua b/tests/gen2_chamber_fieldmoves_test.lua new file mode 100644 index 00000000..5a906da1 --- /dev/null +++ b/tests/gen2_chamber_fieldmoves_test.lua @@ -0,0 +1,71 @@ +-- The two Ruins of Alph walls the FIELD MOVES open, at their production seams: +-- ../pokecrystal/engine/events/overworld.asm:280-291 FlashFunction.CheckUseFlash +-- (ZEPHYRBADGE first, SpecialAerodactylChamber second, the darkness test last) +-- and :808-813 EscapeRopeOrDig's `.escaperope` arm. +-- luajit tests/gen2_chamber_fieldmoves_test.lua +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 chamber field moves") +local check, eq = S.check, S.eq + +love = require("tests.love_stub") + +local Events = require("src.world.gen2.Events") +local FieldMoves = require("src.world.gen2.FieldMoves") +local UnownWords = require("src.world.gen2.UnownWords") + +local function ctxFor(mapId, badges, dark) + local events = Events.new() + return { + events = events, + save = { player = { badges = badges } }, + dark = dark or false, + openAerodactylWall = function() + return UnownWords.aerodactylChamber(events, mapId) + end, + }, events +end + +local AERO = UnownWords.CHAMBER_MAPS.AERODACTYL + +-- :281-283, the badge gate ahead of the special. +local ctx, events = ctxFor(AERO, {}, false) +local refused = FieldMoves.fromMenu("FLASH", ctx) +eq(refused.ok, false, "no ZEPHYRBADGE: FLASH is refused in the chamber") +eq(refused.badge, "ZEPHYR", "and it is the badge that refused it") +check(not UnownWords.wallOpened(events, "AERODACTYL"), + "a badgeless press must not reach SpecialAerodactylChamber") + +-- :284-287, the special's carry is a second way into `.useflash`. +ctx, events = ctxFor(AERO, { ZEPHYR = true }, false) +local used = FieldMoves.fromMenu("FLASH", ctx) +eq(used.ok, true, "with the badge FLASH runs in a chamber that is not dark") +eq(used.action, "flash", "and it queues the flash") +check(UnownWords.wallOpened(events, "AERODACTYL"), + "and the wall-opened flag is set") + +-- :288-290, any other lit map still refuses. +ctx, events = ctxFor("RUINS_OF_ALPH_OUTSIDE", { ZEPHYR = true }, false) +eq(FieldMoves.fromMenu("FLASH", ctx).ok, false, "a lit route still refuses") +check(not UnownWords.wallOpened(events, "AERODACTYL"), + "and opens nothing") + +-- A dark cave is the ordinary arm, with no chamber anywhere near it. +ctx = ctxFor("SLOWPOKE_WELL_B1F", { ZEPHYR = true }, true) +eq(FieldMoves.fromMenu("FLASH", ctx).ok, true, "a dark cave is still lit") + +-- A cache with no chamber hook at all (Gold) must behave exactly as before. +eq(FieldMoves.fromMenu("FLASH", { + save = { player = { badges = { ZEPHYR = true } } }, dark = false }).ok, false, + "with no openAerodactylWall on the ctx, a lit map refuses") + +-- ../pokecrystal/engine/events/unown_walls.asm:81, the escape rope's own arm. +local ropeEvents = Events.new() +eq(UnownWords.kabutoChamber(ropeEvents, "RUINS_OF_ALPH_OUTSIDE"), false, + "the rope opens nothing outside a chamber") +check(not UnownWords.wallOpened(ropeEvents, "KABUTO"), "flag still clear") +eq(UnownWords.kabutoChamber(ropeEvents, UnownWords.CHAMBER_MAPS.KABUTO), true, + "and opens the Kabuto wall inside it") +check(UnownWords.wallOpened(ropeEvents, "KABUTO"), "flag set") + +S.finish() diff --git a/tests/gen2_contest_test.lua b/tests/gen2_contest_test.lua index 4d01b880..5d253ced 100644 --- a/tests/gen2_contest_test.lua +++ b/tests/gen2_contest_test.lua @@ -1108,7 +1108,7 @@ do "function World:bugContestBattleOver", "self:bugContestBattleOver()", "BugContestResultsWarpScript", - "BugContest.ENGINE_BUG_CONTEST_TIMER", + "FieldMoves.BUG_CONTEST_FLAG", -- Kurt's menu and the item pair the ladder needs. "scriptMenu = function(header, onChoose)", "itemIndex = function(id)", diff --git a/tests/gen2_crystal_anim_test.lua b/tests/gen2_crystal_anim_test.lua new file mode 100644 index 00000000..0aca583c --- /dev/null +++ b/tests/gen2_crystal_anim_test.lua @@ -0,0 +1,139 @@ +-- Crystal's animated front pics: the bitmask decode and the frame sequencer. +-- luajit tests/gen2_crystal_anim_test.lua +-- +-- ROM-free. The fixtures are the shapes the extractor writes into +-- data/generated/pokemon.lua, with the numbers taken from BULBASAUR's own +-- bitmask/frames data so a failure names the bytes it disagrees with. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 crystal anim") +local check, eq = S.check, S.eq + +local MonAnim = require("src.render.MonAnim") + +-- ---- bitmask decode ------------------------------------------------------- + +-- ../pokecrystal/gfx/pokemon/bulbasaur/bitmask.asm bitmask 0 and frame 1: +-- %01100000 %10101101 %00000001 %00000000, then $19..$20. The tool emits the +-- byte low bit first, so the eight set bits are tile positions 5, 6, 8, 10, +-- 11, 13, 15 and 16 in the pic's own column-major order. +local BULBASAUR = { + tiles = 5, + bitmasks = { + { 0x60, 0xad, 0x01, 0x00 }, + { 0x20, 0xad, 0x01, 0x00 }, + }, + frames = { + { bitmask = 1, tiles = { 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20 } }, + { bitmask = 2, tiles = { 0x21, 0x1b, 0x22, 0x1d, 0x1e, 0x23, 0x24 } }, + }, + play = { { 1, 10 }, { 2, 10 }, { 0, 5 } }, + idle = { { 2, 5 }, { 0, 5 } }, +} + +local base = MonAnim.tileMap(BULBASAUR, 0) +eq(base and #base, 25, "the base picture is 5x5 tiles") +eq(base and base[1], 0, "position 0 is tile 0") +eq(base and base[25], 24, "position 24 is tile 24") + +local first = MonAnim.tileMap(BULBASAUR, 1) +eq(first and #first, 25, "frame 1 is still 5x5 tiles") +local REPLACED = { [6] = 0x19, [7] = 0x1a, [9] = 0x1b, [11] = 0x1c, + [12] = 0x1d, [14] = 0x1e, [16] = 0x1f, [17] = 0x20 } +local wrong = nil +for slot = 1, 25 do + local want = REPLACED[slot] or (slot - 1) + if first[slot] ~= want then wrong = wrong or slot end +end +eq(wrong, nil, "frame 1 replaces exactly the eight bitmask-0 positions") + +-- Bitmask 1 drops bit 6 and keeps bit 5, so frame 2 leaves position 6 alone. +local second = MonAnim.tileMap(BULBASAUR, 2) +eq(second and second[6], 0x21, "frame 2 replaces position 5") +eq(second and second[7], 6, "and leaves position 6 at its base tile") +eq(second and #BULBASAUR.frames[2].tiles, 7, + "seven set bits, seven replacement tiles") + +eq(MonAnim.tileMap({ frames = {}, bitmasks = {} }, 0), nil, + "no pic size means no tile map") +eq(MonAnim.tileMap(BULBASAUR, 9), nil, "and a frame that does not exist is nil") + +-- ---- durations ------------------------------------------------------------ + +-- PokeAnim_GetDuration: a * (1 + [wPokeAnimSpeed] / 16). +eq(MonAnim.duration(10, 0), 10, "speed 0 leaves a duration alone") +eq(MonAnim.duration(10, 4), 12, "ANIM_MON_SLOW's speed 4 stretches 10 to 12") +eq(MonAnim.duration(5, 4), 6, "and 5 to 6") +eq(MonAnim.duration(200, 4), 250, "the arithmetic stays inside a byte") + +-- ---- the sequencer -------------------------------------------------------- + +-- setrepeat 2 / frame 1, 3 / frame 2, 2 / dorepeat 1 -- the shape most of +-- Crystal's entrance animations have (../pokecrystal/gfx/pokemon/abra/anim.asm). +local LOOPED = { + tiles = 5, bitmasks = BULBASAUR.bitmasks, frames = BULBASAUR.frames, + play = { { MonAnim.SETREPEAT, 2 }, { 1, 3 }, { 2, 2 }, { MonAnim.DOREPEAT, 1 } }, + idle = { { 2, 2 } }, +} + +local function timeline(data, scene, ticks) + local anim = MonAnim.new(data, scene) + local out = {} + for _ = 1, ticks do + anim:update() + out[#out + 1] = anim:currentFrame() + end + return out, anim +end + +local frames, anim = timeline(LOOPED, "battle", 14) +eq(table.concat(frames, ","), "0,1,1,1,2,2,1,1,1,2,2,2,0,0", + "the looped script plays twice and lands back on the base picture") +check(anim:finished(), "and the scene is over after PokeAnim_Finish") + +-- Setup costs a frame of its own before the script starts, so the first +-- animation frame is on tick 2, not tick 1. +eq(frames[1], 0, "PokeAnim_Setup shows the base picture for its own frame") + +-- ANIM_MON_SLOW's speed 4 stretches every duration: 8 becomes 10. +local ONESHOT = { + tiles = 5, bitmasks = BULBASAUR.bitmasks, frames = BULBASAUR.frames, + play = { { 1, 8 } }, idle = { { 2, 2 } }, +} +eq(table.concat(timeline(ONESHOT, "battle", 11), ","), + "0,1,1,1,1,1,1,1,1,0,0", "speed 0 holds frame 1 for eight frames") +eq(table.concat(timeline(ONESHOT, "battleSlow", 13), ","), + "0,1,1,1,1,1,1,1,1,1,1,0,0", "speed 4 holds it for ten") + +-- ANIM_MON_MENU: the entrance animation, an 18-frame pause, then the idle. +local menu = MonAnim.new(ONESHOT, "menu") +local seen = {} +for tick = 1, 31 do + menu:update() + seen[tick] = menu:currentFrame() +end +eq(seen[10], 0, "the entrance script ends on the base picture") +local resume +for tick = 11, 31 do + if seen[tick] ~= 0 then resume = tick break end +end +eq(resume, 11 + MonAnim.SCENE_WAIT + 1, + "eighteen wait frames, then PokeAnim_Idle's own frame, then the idle script") +eq(resume and seen[resume], 2, "and the idle script's first frame comes up") +check(not menu:finished(), "the menu scene is longer than the battle one") + +local ran = 0 +for tick = 1, 60 do + menu:update() + if menu:finished() then ran = tick break end +end +check(ran > 0, "but it does end") + +-- ---- gating --------------------------------------------------------------- + +eq(MonAnim.new(nil, "battle"), nil, "no data means no sequencer") +eq(MonAnim.new({ tiles = 5, play = {} }, "battle"), nil, + "and an empty script means no sequencer, which is every Gold species") +eq(MonAnim.new(LOOPED, "nosuchscene"), nil, "an unknown scene is nil too") + +S.finish() diff --git a/tests/gen2_crystal_caught_data_test.lua b/tests/gen2_crystal_caught_data_test.lua new file mode 100644 index 00000000..064dc710 --- /dev/null +++ b/tests/gen2_crystal_caught_data_test.lua @@ -0,0 +1,314 @@ +-- Crystal MON_CAUGHTDATA: the two packed bytes, who stamps them, and the two +-- readers that key off them (the level-up happiness pick and the fields that +-- have to survive an evolution and a Day-Care round trip). +-- luajit tests/gen2_crystal_caught_data_test.lua +-- +-- Gold has no such word in its party struct, so every assertion below comes in +-- a Crystal half and a Gold half: Gold must come out with the three fields +-- still nil. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 crystal caught data") +local check, eq = S.check, S.eq + +love = require("tests.love_stub") + +local GameVersion = require("src.core.GameVersion") +local Mon = require("src.battle.gen2.Mon") +local Catching = require("src.battle.gen2.Catching") +local Evolution = require("src.core.gen2.Evolution") +local Breeding = require("src.core.gen2.Breeding") +local Happiness = require("src.core.gen2.Happiness") +local Save = require("src.core.gen2.Save") + +local priorVersion = GameVersion.get() + +-- ---- fixtures ------------------------------------------------------------- + +-- GROWTH_MEDIUM_FAST is plain n^3 (data/growth_rates.asm). +local GROWTH = { + GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0, + linear = 0, constant = 0 }, +} + +local function species(id, index, evolutions) + return { + id = id, index = index, name = id, + baseStats = { hp = 45, attack = 49, defense = 49, speed = 45, + specialAttack = 65, specialDefense = 65 }, + types = { "NORMAL", "NORMAL" }, + growthRate = "GROWTH_MEDIUM_FAST", + genderRatio = 127, + eggGroups = { "EGG_GROUND", "EGG_GROUND" }, + eggSteps = 20, + evolutions = evolutions or {}, + levelMoves = { { level = 1, move = "TACKLE" } }, + } +end + +local DATA = { + moves = { TACKLE = { id = "TACKLE", pp = 35 } }, + pokemon = { + growthRates = GROWTH, + CATERPIE = species("CATERPIE", 10, + { { method = "EVOLVE_LEVEL", level = 7, into = "METAPOD" } }), + METAPOD = species("METAPOD", 11), + }, + -- data/generated/landmarks.lua's shape, two rows deep enough for the + -- National Park override to resolve by name. + gen2Landmarks = { + landmarks = { + LANDMARK_NATIONAL_PARK = { id = "LANDMARK_NATIONAL_PARK", index = 19 }, + LANDMARK_NEW_BARK_TOWN = { id = "LANDMARK_NEW_BARK_TOWN", index = 1 }, + }, + }, +} + +local function newMon(level, opts) + return Mon.new(DATA, "CATERPIE", level or 5, opts) +end + +-- ---- the constants -------------------------------------------------------- + +eq(Mon.CAUGHT_TIME_MASK, 0xc0, "CAUGHT_TIME_MASK") +eq(Mon.CAUGHT_LEVEL_MASK, 0x3f, "CAUGHT_LEVEL_MASK") +eq(Mon.CAUGHT_GENDER_MASK, 0x80, "CAUGHT_GENDER_MASK") +eq(Mon.CAUGHT_LOCATION_MASK, 0x7f, "CAUGHT_LOCATION_MASK") +eq(Mon.CAUGHT_EGG_LEVEL, 1, "CAUGHT_EGG_LEVEL") +eq(Mon.LANDMARK_EVENT, 0x7f, "LANDMARK_EVENT") +eq(Mon.LANDMARK_GIFT, 0x7e, "LANDMARK_GIFT") + +-- ---- the two bytes -------------------------------------------------------- + +-- `ld a, [wTimeOfDay] / inc a / rrca / rrca`: MORN 0 stores as 1, and 0 is +-- left free to mean unknown. +eq(Mon.caughtTimeOf(0), 1, "MORN stamps 1") +eq(Mon.caughtTimeOf(1), 2, "DAY stamps 2") +eq(Mon.caughtTimeOf(2), 3, "NITE stamps 3") +eq(Mon.caughtTimeOf("MORN"), 1, "the name reads the same as the id") +eq(Mon.caughtTimeOf(nil), 0, "and nothing at all is unknown") +eq(Mon.caughtTimeOf("BRUNCH"), 0, "as is a time of day that is not one") + +local stamped = Mon.setCaughtData({ level = 12 }, + { timeOfDay = 2, landmark = 1, playerGender = "male" }) +eq(stamped.caughtTime, 3, "NITE") +eq(stamped.caughtLevel, 12, "the level the catch happened at") +eq(stamped.caughtLocation, 1, "GetWorldMapLocation's landmark") +eq(stamped.caughtByGender, "boy", "and wPlayerGender bit 0 clear") + +local byte0, byte1 = Mon.packCaughtData(stamped) +eq(byte0, 3 * 0x40 + 12, "byte 0 is ((tod + 1) << 6) | level") +eq(byte1, 1, "byte 1 is (female << 7) | landmark") + +local female = Mon.setCaughtData({}, + { timeOfDay = 0, level = 7, landmark = 19, playerGender = "female" }) +eq(female.caughtByGender, "girl", "PLAYERGENDER_FEMALE_F reads as girl") +byte0, byte1 = Mon.packCaughtData(female) +eq(byte0, 0x40 + 7, "MORN and level 7") +eq(byte1, 0x80 + 19, "the gender bit rides byte 1, not byte 0") + +-- CAUGHT_LEVEL_MASK is six bits, so the packed level of a high-level catch +-- wraps exactly as `or b` leaves it on the cart. +byte0 = Mon.packCaughtData(Mon.setCaughtData({}, + { timeOfDay = 1, level = 70, landmark = 1 })) +eq(byte0 % 0x40, 70 % 0x40, "level 70 packs into six bits") + +local round = Mon.unpackCaughtData(Mon.packCaughtData(female)) +eq(round.caughtTime, 1, "unpack recovers the time") +eq(round.caughtLevel, 7, "the level") +eq(round.caughtLocation, 19, "the location") +eq(round.caughtByGender, "girl", "and the gender bit") + +local blank = Mon.unpackCaughtData(0, 0) +eq(blank.caughtTime, 0, "an all-zero word is an unknown time") +eq(blank.caughtLevel, 0, "an unknown level") +eq(blank.caughtLocation, 0, "and an unknown location") + +-- SetGiftMonCaughtData folds CAUGHT_BY_* through `rrc b`, which is why +-- CAUGHT_BY_BOY lands on LANDMARK_EVENT instead of on a gender bit. +local gift = Mon.setGiftCaughtData({}, "girl") +eq(gift.caughtTime, 0, "a gift mon has no caught time") +eq(gift.caughtLevel, 0, "and no caught level") +eq(gift.caughtLocation, Mon.LANDMARK_GIFT, "LANDMARK_GIFT") +eq(gift.caughtByGender, "girl", "with the gender bit set") +eq(Mon.setGiftCaughtData({}, "boy").caughtLocation, Mon.LANDMARK_EVENT, + "CAUGHT_BY_BOY rotates into bit 0 and makes it LANDMARK_EVENT") +eq(Mon.setGiftCaughtData({}, "unknown").caughtLocation, Mon.LANDMARK_GIFT, + "CAUGHT_BY_UNKNOWN leaves LANDMARK_GIFT alone") + +-- ---- who stamps ----------------------------------------------------------- + +GameVersion.set("gold") +check(not Mon.hasCaughtData(), "Gold's struct has no MON_CAUGHTDATA") +local goldMon = newMon(5) +eq(goldMon.caughtLevel, 5, "Gold still gets the caught level it always had") +eq(goldMon.caughtTime, nil, "but no caught time") +eq(goldMon.caughtLocation, nil, "no caught location") +eq(goldMon.caughtByGender, nil, "and no OT gender") +Catching.stampCaughtData(goldMon, + { data = DATA, timeOfDay = 1, map = { id = "ROUTE_29", landmark = 1 } }) +eq(goldMon.caughtTime, nil, "and SetCaughtData is a no-op on Gold") + +GameVersion.set("crystal") +check(Mon.hasCaughtData(), "the crystal lineage carries it") + +local caught = newMon(4) +Catching.stampCaughtData(caught, { + data = DATA, timeOfDay = 1, playerGender = "female", + map = { id = "ROUTE_29", landmark = 1 }, +}) +eq(caught.caughtTime, 2, "a Crystal catch stamps the time") +eq(caught.caughtLevel, 4, "the level") +eq(caught.caughtLocation, 1, "the map's landmark") +eq(caught.caughtByGender, "girl", "and the player's gender") + +-- The Pokecenter 2F substitution: wBackupMapGroup / wBackupMapNumber, so the +-- cable-club floor never lands on LANDMARK_SPECIAL. +eq(Catching.caughtLandmark({ + map = { id = "POKECENTER_2F", landmark = 0 }, + backupMap = { id = "VIOLET_CITY", landmark = 6 }, +}), 6, "POKECENTER_2F reads the map it was entered from") +eq(Catching.caughtLandmark({ map = { id = "POKECENTER_2F", landmark = 0 } }), 0, + "with no backup map recorded it stays unknown") +eq(Catching.caughtLandmark({ map = { id = "ROUTE_29", landmark = 1 } }), 1, + "every other map is a plain GetWorldMapLocation") +eq(Catching.caughtLandmark({}), 0, "and no map at all is unknown") + +-- BugContest_SetCaughtContestMon overwrites the location afterwards, keeping +-- the gender bit. +eq(Catching.caughtLandmark({ data = DATA, bugContest = true, + map = { id = "NATIONAL_PARK_BUG_CONTEST", landmark = 0 } }), 19, + "the Bug Contest catch is LANDMARK_NATIONAL_PARK") +eq(Catching.caughtLandmark({ bugContest = true }), + Catching.LANDMARK_NATIONAL_PARK, + "and the same index without a landmarks table to look it up in") + +local contest = Catching.stampCaughtData(newMon(6), { + data = DATA, bugContest = true, timeOfDay = 1, playerGender = "female", + map = { id = "NATIONAL_PARK_BUG_CONTEST", landmark = 0 }, +}) +eq(contest.caughtLocation, 19, "the contest mon is stamped National Park") +eq(contest.caughtByGender, "girl", "and keeps CAUGHT_GENDER_MASK") + +-- ---- survival across an evolution ----------------------------------------- + +check(Evolution.MON_FIELDS.caughtTime, "MON_FIELDS names caughtTime") +check(Evolution.MON_FIELDS.caughtLocation, "and caughtLocation") +check(Evolution.MON_FIELDS.caughtByGender, "and caughtByGender") + +local evolved = Evolution.apply(DATA, caught, + { method = "EVOLVE_LEVEL", level = 7, into = "METAPOD" }) +eq(evolved.species, "METAPOD", "it evolved") +eq(evolved.caughtTime, 2, "the caught time survived") +eq(evolved.caughtLevel, 4, "the caught level survived") +eq(evolved.caughtLocation, 1, "the caught location survived") +eq(evolved.caughtByGender, "girl", "and so did the OT gender") + +-- ---- survival across the Day-Care, and the egg marker --------------------- + +local function newSave(party) + local save = { version = "crystal", party = party or {}, + player = { name = "CHRIS", id = 1, gender = "female", money = 5000 }, + pokedex = { seen = {}, caught = {} } } + return save +end + +local deposited = newMon(5) +Catching.stampCaughtData(deposited, { + data = DATA, timeOfDay = 2, playerGender = "female", + map = { id = "ROUTE_34", landmark = 3 }, +}) +local save = newSave({}) +local dc = Breeding.dayCare(save) +dc.man.mon = deposited +dc.man.level = 5 +local ok, withdrawn = Breeding.withdraw(DATA, save, "man") +check(ok ~= false, "the mon came back out of the Day-Care") +withdrawn = save.party[1] +eq(withdrawn.caughtTime, 3, "RetrieveBreedmon carried the caught time") +eq(withdrawn.caughtLevel, 5, "the caught level") +eq(withdrawn.caughtLocation, 3, "the caught location") +eq(withdrawn.caughtByGender, "girl", "and the OT gender") + +local egg = newMon(5) +egg.isEgg = true +egg.eggSteps = 0 +egg.level = 5 +save = newSave({ egg }) +local hatched = Breeding.hatch(DATA, save, 1, nil, + { timeOfDay = 0, landmark = 3 }) +check(hatched ~= nil, "the egg hatched") +eq(hatched.caughtLevel, Mon.CAUGHT_EGG_LEVEL, + "SetEggMonCaughtData stamps CAUGHT_EGG_LEVEL, not the hatch level") +eq(hatched.caughtTime, 1, "with the hatch site's time of day") +eq(hatched.caughtLocation, 3, "and its landmark") +eq(hatched.caughtByGender, "girl", "off save.player.gender") +eq(Mon.packCaughtData(hatched) % 0x40, Mon.CAUGHT_EGG_LEVEL, + "so byte 0's level field reads 1") + +GameVersion.set("gold") +local goldEgg = newMon(5) +goldEgg.isEgg = true +goldEgg.eggSteps = 0 +local goldSave = newSave({ goldEgg }) +goldSave.version = "gold" +goldSave.player.gender = nil +local goldHatch = Breeding.hatch(DATA, goldSave, 1) +eq(goldHatch.caughtLevel, 5, "a Gold hatchling keeps the egg's own level") +eq(goldHatch.caughtTime, nil, "and grows no caught-data keys") +eq(goldHatch.caughtLocation, nil, "none of them") +eq(goldHatch.caughtByGender, nil, "at all") +GameVersion.set("crystal") + +-- ---- HAPPINESS_GAINLEVELATHOME -------------------------------------------- + +eq(Happiness.EVENT.GAINLEVELATHOME, 19, "the enum's 19th row") +eq(Happiness.NUM_EVENTS, 19, "and Crystal's table is one longer than Gold's") +eq(#Happiness.CHANGES, 19, "every row is present") +eq(Happiness.delta("GAINLEVELATHOME", 0), 10, "+10 under 100") +eq(Happiness.delta("GAINLEVELATHOME", 100), 6, "+6 under 200") +eq(Happiness.delta("GAINLEVELATHOME", 200), 4, "+4 above it") +check(Happiness.delta("GAINLEVELATHOME", 0) > Happiness.delta("GAINLEVEL", 0), + "and it is worth more than a plain level") + +local home = { happiness = 70, caughtLocation = 3 } +eq(Happiness.levelUpEvent(home, 3), "GAINLEVELATHOME", + "levelling where it was caught") +eq(Happiness.levelUpEvent(home, 4), "GAINLEVEL", "and anywhere else") +eq(Happiness.levelUpEvent({ caughtLocation = 3 }, nil), "GAINLEVEL", + "an unknown current landmark cannot match") +eq(Happiness.levelUpEvent({}, 0), "GAINLEVEL", + "and a mon with no caught data at all never reads ATHOME") +eq(Happiness.levelUpEvent({ caughtLocation = 0 }, 0), "GAINLEVELATHOME", + "though a Crystal mon stamped LANDMARK_SPECIAL does, as the cart's cp does") +-- CAUGHT_LOCATION_MASK: the comparison is against the low seven bits, so the +-- gender bit riding the same byte cannot break the match. +eq(Happiness.levelUpEvent({ caughtLocation = 0x80 + 3 }, 3), "GAINLEVELATHOME", + "the gender bit is masked out of the comparison") + +eq(Happiness.levelUp(home, 3), 80, "levelUp applies the +10") +eq(Happiness.levelUp(home, 9), 85, "and the +5 away from home") + +-- ---- the save layer ------------------------------------------------------- + +eq(Save.defaultPlayerName("gold"), "GOLD", "PlayerNameArray's first row") +eq(Save.defaultPlayerName("silver"), "SILVER", "on Silver") +eq(Save.defaultPlayerName("crystal"), "CHRIS", "and MalePlayerNameArray's") + +local normalized = Save.normalize({ version = "crystal", player = {} }) +eq(normalized.player.gender, "male", "normalize defaults wPlayerGender to 0") +eq(Save.normalize({ version = "crystal", + player = { gender = "female" } }).player.gender, "female", + "and leaves a recorded gender alone") + +-- A Gold save that never catches anything grows no new keys. +GameVersion.set("gold") +local goldParty = { newMon(5) } +local goldFile = Save.normalize({ version = "gold", party = goldParty }) +eq(goldFile.party[1].caughtTime, nil, "no caughtTime on a Gold record") +eq(goldFile.party[1].caughtLocation, nil, "no caughtLocation") +eq(goldFile.party[1].caughtByGender, nil, "no caughtByGender") + +GameVersion.set(priorVersion) + +S.finish() diff --git a/tests/gen2_crystal_extras_test.lua b/tests/gen2_crystal_extras_test.lua new file mode 100644 index 00000000..23fe2ae9 --- /dev/null +++ b/tests/gen2_crystal_extras_test.lua @@ -0,0 +1,559 @@ +-- ../pokecrystal/engine/events/move_tutor.asm:1, engine/events/buena.asm:1 +-- and :64, engine/events/poke_seer.asm:18, mobile/mobile_12_2.asm:191. +-- ROM-free: +-- luajit tests/gen2_crystal_extras_test.lua +package.path = "./?.lua;./?/init.lua;" .. package.path + +-- The same love stub tests/gen2_menus_test.lua installs; nothing here draws. +love = love or {} +love.graphics = love.graphics or { + getColor = function() return 1, 1, 1, 1 end, + setColor = function() end, + rectangle = function() end, + print = function() end, + printf = function() end, + draw = function() end, + newQuad = function() return {} end, + newImage = function() return nil end, + getShader = function() return nil end, + setShader = function() end, + newShader = function() error("no shaders in this harness") end, + getDimensions = function() return 160, 144 end, + push = function() end, pop = function() end, + translate = function() end, scale = function() end, + circle = function() end, clear = function() end, +} +love.math = love.math or { random = function(a, b) return b and a or 1 end } +love.image = love.image or {} +love.filesystem = love.filesystem or { + load = function() return nil end, + getInfo = function() return nil end, + read = function() return nil end, +} +love.timer = love.timer or { getTime = function() return 0 end } + +local S = require("tests.harness").suite("gen2 crystal extras") +local check, eq = S.check, S.eq + +require("src.core.Logger").warn = function() end + +local Events = require("src.world.gen2.Events") +local Mon = require("src.battle.gen2.Mon") +local MoveTutorScreen = require("src.ui.gen2.MoveTutor") +local Save = require("src.core.gen2.Save") +local Specials = require("src.script.gen2.Specials") +local Vm = require("src.script.gen2.Vm") + +local H = Specials.HANDLERS + +-- --------------------------------------------------------------- fixtures + +-- The cache tables the handlers read, plus the landmark registry +-- GetLandmarkName walks (../pokecrystal/engine/overworld/landmarks.asm:16). +local DATA = { + pokemon = { + tutorMoves = { "FLAMETHROWER", "THUNDERBOLT", "ICE_BEAM" }, + TYPHLOSION = { index = 157, name = "TYPHLOSION", + tmhm = { "CUT", "STRENGTH" }, tutorMoves = { "FLAMETHROWER" } }, + GEODUDE = { index = 74, name = "GEODUDE", + tmhm = { "STRENGTH" }, tutorMoves = { "FLAMETHROWER" } }, + LAPRAS = { index = 131, name = "LAPRAS", tmhm = { "SURF" } }, + CYNDAQUIL = { index = 155, name = "CYNDAQUIL" }, + TOTODILE = { index = 158, name = "TOTODILE" }, + CHIKORITA = { index = 152, name = "CHIKORITA" }, + PIKACHU = { index = 25, name = "PIKACHU" }, + RATTATA = { index = 19, name = "RATTATA" }, + HOOTHOOT = { index = 163, name = "HOOTHOOT" }, + SPINARAK = { index = 165, name = "SPINARAK" }, + DROWZEE = { index = 96, name = "DROWZEE" }, + }, + items = { + ULTRA_BALL = { index = 2, name = "ULTRA BALL" }, + FULL_RESTORE = { index = 16, name = "FULL RESTORE" }, + NUGGET = { index = 92, name = "NUGGET" }, + RARE_CANDY = { index = 50, name = "RARE CANDY" }, + PROTEIN = { index = 33, name = "PROTEIN" }, + IRON = { index = 34, name = "IRON" }, + CARBOS = { index = 35, name = "CARBOS" }, + CALCIUM = { index = 37, name = "CALCIUM" }, + HP_UP = { index = 32, name = "HP UP" }, + POTION = { index = 17, name = "POTION" }, + ANTIDOTE = { index = 18, name = "ANTIDOTE" }, + PARLYZ_HEAL = { index = 21, name = "PARLYZ HEAL" }, + FRESH_WATER = { index = 39, name = "FRESH WATER" }, + SODA_POP = { index = 40, name = "SODA POP" }, + LEMONADE = { index = 41, name = "LEMONADE" }, + POKE_BALL = { index = 5, name = "POKé BALL" }, + GREAT_BALL = { index = 1, name = "GREAT BALL" }, + X_ATTACK = { index = 68, name = "X ATTACK" }, + X_DEFEND = { index = 69, name = "X DEFEND" }, + X_SPEED = { index = 67, name = "X SPEED" }, + }, + moves = { + FLAMETHROWER = { name = "FLAMETHROWER" }, + THUNDERBOLT = { name = "THUNDERBOLT" }, + ICE_BEAM = { name = "ICE BEAM" }, + TACKLE = { name = "TACKLE" }, + GROWL = { name = "GROWL" }, + MUD_SLAP = { name = "MUD-SLAP" }, + }, + gen2Landmarks = { + order = {}, + landmarks = { + NEW_BARK_TOWN = { index = 1, name = "NEW BARK\nTOWN" }, + ROUTE_29 = { index = 2, name = "ROUTE 29" }, + }, + }, +} + +local function newSave() + local save = { + version = "crystal", + player = { name = "KRIS", id = 0x1234, money = 0 }, + party = {}, + inventory = {}, + bagOrder = {}, + } + Save.crystalState(save) + return save +end + +-- World:specialHooks, stubbed; every screen answers through its own onDone. +local function newHooks(save, opts) + opts = opts or {} + local vars = opts.vars or {} + local log = { pushed = {}, sfx = {} } + local hooks = { + log = log, + vars = vars, + save = function() return save end, + data = function() return DATA end, + party = function() return save.party end, + itemIndex = function(id) + local def = DATA.items[id] + return def and def.index + end, + hasItem = function(index) return opts.held and opts.held[index] == true end, + playSfxNamed = function(name) log.sfx[#log.sfx + 1] = name end, + selectPartyMon = function(prompt, done) + log.selectPrompt = prompt + done(opts.pickIndex, opts.pickIndex and save.party[opts.pickIndex]) + end, + pushScreen = function(id, screenOpts) + log.pushed[#log.pushed + 1] = { id = id, opts = screenOpts } + if opts.noScreens then return false end + local answer = opts.answers and opts.answers[#log.pushed] + if screenOpts.onDone then screenOpts.onDone(answer) end + return true + end, + } + return hooks +end + +-- The var pair is Vm.readVarFn / Vm.writeVarFn, not a `specials` hook: +-- engine/overworld/variables.asm's rows belong to `readvar` / `writevar`. +local function newVm(hooks, scriptVar) + local vars = hooks.vars + local vm = Vm.new({}, {}, Events.new(), { + specials = hooks, + readVar = function(id) return vars[id] or 0 end, + writeVar = function(id, value) vars[id] = value end, + }) + vm.showTextFn = function() end + vm.scriptVar = scriptVar or 0 + return vm +end + +-- Run one handler out, collecting its pages and feeding its YES/NO answers. +local function run(vm, handler, answers) + answers = answers or {} + local pages, asked = {}, 0 + local co = coroutine.create(function() handler(vm) end) + vm.co = co + local send + while true do + local ok, req = coroutine.resume(co, send) + if not ok then error(req, 0) end + if coroutine.status(co) == "dead" then break end + send = nil + if type(req) == "table" and req.kind == "text" then + pages[#pages + 1] = req.text + elseif type(req) == "table" and req.kind == "yesorno" then + asked = asked + 1 + send = answers[asked] + else + pages.parked = req + break + end + end + return pages +end + +-- ============================================================ the move tutor + +-- .GetMoveTutorMove (engine/events/move_tutor.asm:36-52); anything that is +-- not MOVETUTOR_FLAMETHROWER or _THUNDERBOLT falls through to MT03. +do + local function tutorMoveFor(value, answer) + local save = newSave() + local hooks = newHooks(save, { answers = { answer } }) + local vm = newVm(hooks, value) + run(vm, H.MoveTutor) + local pushed = hooks.log.pushed[1] + return pushed and pushed.opts.move, vm.scriptVar + end + eq(select(1, tutorMoveFor(1, true)), "FLAMETHROWER", "MOVETUTOR_FLAMETHROWER is MT01") + eq(select(1, tutorMoveFor(2, true)), "THUNDERBOLT", "MOVETUTOR_THUNDERBOLT is MT02") + eq(select(1, tutorMoveFor(3, true)), "ICE_BEAM", "MOVETUTOR_ICE_BEAM is MT03") + eq(select(1, tutorMoveFor(0, true)), "ICE_BEAM", + "and anything else falls through to MT03, as the `cp` ladder does") + + local _, learned = tutorMoveFor(1, true) + eq(learned, 0, "a taught move leaves wScriptVar FALSE, which is .TeachMove") + local _, cancelled = tutorMoveFor(1, false) + eq(cancelled, 255, "and a cancel leaves -1, which is .Incompatible") +end + +-- engine/events/move_tutor.asm:29 .cancel, ahead of the script's +-- takecoins 4000 (maps/GoldenrodCity.asm:124). +do + local save = newSave() + local hooks = newHooks(save, { noScreens = true }) + local vm = newVm(hooks, 2) + run(vm, H.MoveTutor) + eq(vm.scriptVar, 255, "no screen is the -1 cancel, not a free lesson") +end + +-- `add_mt` gives the three tutor moves TMHM flags 58-60 +-- (constants/item_constants.asm:295-307), so CanLearnTMHMMove sees them. +do + local view = MoveTutorScreen.speciesView(DATA.pokemon) + check(MoveTutorScreen.canLearn(view.TYPHLOSION, "FLAMETHROWER"), + "TYPHLOSION can be taught FLAMETHROWER") + check(MoveTutorScreen.canLearn(view.TYPHLOSION, "CUT"), + "and its ordinary TM list still answers") + check(not MoveTutorScreen.canLearn(view.LAPRAS, "FLAMETHROWER"), + "LAPRAS cannot") + local merged = view.TYPHLOSION.tmhm + eq(#merged, 3, "the view's tmhm list carries the tutor row too") + eq(merged[3], "FLAMETHROWER", "appended after the TM/HM rows") + check(view.TYPHLOSION == view.TYPHLOSION, "and the view is memoised") + eq(view.MISSINGNO, nil, "a species the cache does not carry stays nil") + eq(#DATA.pokemon.TYPHLOSION.tmhm, 2, "the cache's own record is not touched") +end + +-- ================================================================== Buena + +-- engine/events/buena_menu.asm:1-9: carry (NO or B) is 0, YES is 1. +do + local save = newSave() + local vm = newVm(newHooks(save)) + run(vm, H.AskRememberPassword, { true }) + eq(vm.scriptVar, 1, "YES is 1, which the script reads as `iffalse` not taken") + local vm2 = newVm(newHooks(save)) + run(vm2, H.AskRememberPassword, { false }) + eq(vm2.scriptVar, 0, "and NO is 0, .ForgotPassword") +end + +-- engine/events/buena.asm:19-23: only wBuenasPassword's low nybble is right. +do + local save = newSave() + -- BuenasPassword4's two rejection rolls, pinned + -- (engine/pokegear/radio.asm:1470-1487). + local rolls = { 1, 2 } + local taken = 0 + local realRandom = Specials.random + Specials.random = function() taken = taken + 1 return rolls[taken] end + + local hooks = newHooks(save, { answers = { 1 } }) + local vm = newVm(hooks) + run(vm, H.BuenasPassword) + Specials.random = realRandom + + local pushed = hooks.log.pushed[1] + eq(pushed.id, "Gen2BuenaPassword", "the show opens its own menu") + eq(pushed.opts.mode, "password", "in password mode") + eq(pushed.opts.width, 10, "the box is as wide as the category's points byte") + eq(table.concat(pushed.opts.words, ","), "CYNDAQUIL,TOTODILE,CHIKORITA", + ".PlacePasswordChoices resolves BUENA_MON rows through GetPokemonName") + eq(vm.scriptVar, 1, "picking the low nybble's row is the right answer") + eq(save.crystal.buenaPassword.word, 0x01, + "and the roll is packed group-high, word-low into wBuenasPassword") + eq(hooks.vars[0x19], 0x01, "VAR_BUENASPASSWORD sees the same byte") + + -- DAILYFLAGS2_BUENAS_PASSWORD_F holds the roll for the day + -- (engine/pokegear/radio.asm:1467). + local hooks2 = newHooks(save, { answers = { 0 } }) + local vm2 = newVm(hooks2) + run(vm2, H.BuenasPassword) + eq(vm2.scriptVar, 0, "row 0 is a wrong guess") + eq(save.crystal.buenaPassword.word, 0x01, "and the day's password is unchanged") + eq(table.concat(hooks2.log.pushed[1].opts.words, ","), + "CYNDAQUIL,TOTODILE,CHIKORITA", "so the menu offers the same three words") +end + +-- GetBuenasPassword's four BUENA_* arms (engine/pokegear/radio.asm:1534), +-- and the points byte as the menu width (engine/events/buena.asm:9-12). +do + local save = newSave() + local realRandom = Specials.random + local function pin(group, word) + local rolls, taken = { group + 1, word + 1 }, 0 + Specials.random = function() taken = taken + 1 return rolls[taken] end + save.crystal.buenaPassword.word = nil + save.crystal.buenaPassword.day = nil + local hooks = newHooks(save, { answers = { word } }) + local vm = newVm(hooks) + run(vm, H.BuenasPassword) + return hooks.log.pushed[1].opts, vm.scriptVar + end + local balls = pin(3, 2) + eq(table.concat(balls.words, ","), "POKé BALL,GREAT BALL,ULTRA BALL", + "BUENA_ITEM rows resolve through GetItemName") + eq(balls.width, 12, "and .Balls is worth 12 points") + local towns = pin(6, 0) + eq(table.concat(towns.words, ","), "NEW BARK TOWN,CHERRYGROVE CITY,AZALEA TOWN", + "BUENA_STRING rows are the literals themselves") + eq(towns.width, 16, "the widest category is 16 wide") + local moves = pin(8, 1) + eq(table.concat(moves.words, ","), "TACKLE,GROWL,MUD-SLAP", + "BUENA_MOVE rows resolve through GetMoveName") + local types = pin(7, 0) + eq(types.width, 6, ".Types is the narrowest box at 6") + local _, right = pin(10, 2) + eq(right, 1, "the eleventh category exists and its third row can be picked") + Specials.random = realRandom +end + +-- engine/events/buena.asm:25 .wrong is what a menu-less run has to take. +do + local save = newSave() + local vm = newVm(newHooks(save, { noScreens = true })) + run(vm, H.BuenasPassword) + eq(vm.scriptVar, 0, "no menu means no correct answer") +end + +-- ------------------------------------------------------------ the prizes + +-- data/items/buena_prizes.asm, and the cost / ReceiveItem / subtract order +-- (engine/events/buena.asm:95-119). +do + local save = newSave() + local hooks = newHooks(save, { vars = { [0x18] = 5 }, answers = { 1 } }) + local vm = newVm(hooks) + local pages = run(vm, H.BuenaPrize, { true }) + + local pushed = hooks.log.pushed[1] + eq(pushed.opts.mode, "prize", "the counter opens the prize list") + eq(#pushed.opts.prizes, 9, "NUM_BUENA_PRIZES rows") + eq(pushed.opts.prizes[1].name, "ULTRA BALL", "the first prize") + eq(pushed.opts.prizes[1].cost, 2, "costs 2 points") + eq(pushed.opts.prizes[9].name, "HP UP", "the last prize") + eq(pushed.opts.prizes[9].cost, 5, "costs 5") + eq(pushed.opts.balance, 5, "and the Points box shows wBlueCardBalance") + + eq(save.inventory.ULTRA_BALL, 1, "one ULTRA BALL leaves the counter") + eq(hooks.vars[0x18], 3, "and the two points come off the card") + eq(hooks.log.sfx[1], "Sfx_Transaction", "SFX_TRANSACTION rings the sale") + eq(pages[#pages], "Oh. Please come\nback again!", + ".done prints _BuenaComeAgainText after the loop") +end + +-- engine/events/buena.asm:93 `jr c, .loop`: nothing is spent. +do + local save = newSave() + local hooks = newHooks(save, { vars = { [0x18] = 9 }, answers = { 1, 0 } }) + local vm = newVm(hooks) + run(vm, H.BuenaPrize, { false }) + eq(save.inventory.ULTRA_BALL, nil, "a refused confirmation buys nothing") + eq(hooks.vars[0x18], 9, "and spends no points") + eq(#hooks.log.pushed, 2, "the list reopens once before the B press ends it") +end + +-- .InsufficientBalance and .BagFull (engine/events/buena.asm:121-127). +do + local save = newSave() + local hooks = newHooks(save, { vars = { [0x18] = 1 }, answers = { 1, 0 } }) + local vm = newVm(hooks) + local pages = run(vm, H.BuenaPrize, { true }) + eq(save.inventory.ULTRA_BALL, nil, "one point cannot buy a two point ball") + eq(hooks.vars[0x18], 1, "the balance is untouched") + check(pages[3] == "You don't have\nenough points.", + "_BuenaNotEnoughPointsText is the refusal") + + -- ReceiveItem's own refusal: a stack already at 99. + local full = newSave() + full.inventory.ULTRA_BALL = 99 + local fullHooks = newHooks(full, { vars = { [0x18] = 9 }, answers = { 1, 0 } }) + local fullVm = newVm(fullHooks) + local fullPages = run(fullVm, H.BuenaPrize, { true }) + eq(full.inventory.ULTRA_BALL, 99, "a full stack takes no more") + eq(fullHooks.vars[0x18], 9, "a full bag spends nothing either") + check(fullPages[3] == "You have no room\nfor it.", + "_BuenaNoRoomText is that refusal") +end + +-- ================================================================ the Seer + +local function seerMon(fields) + local mon = { + species = "GEODUDE", nickname = "ROCKY", level = 30, + otId = 0x1234, otName = "KRIS", moves = {}, + } + for key, value in pairs(fields or {}) do mon[key] = value end + return mon +end + +local function runSeer(mon, cancel) + local save = newSave() + save.party = { mon } + local hooks = newHooks(save, { pickIndex = (not cancel) and 1 or nil }) + local vm = newVm(hooks) + return run(vm, H.PokeSeer), hooks +end + +-- engine/events/poke_seer.asm:38 .cancel, SeerDoNothingText. +do + local pages = runSeer(seerMon(), true) + eq(#pages, 2, "the intro and then the refusal") + eq(pages[2], "Fufufu! I saw that\nyou'd do nothing!", "_SeerDoNothingText") +end + +-- engine/events/poke_seer.asm:28-29 `cp EGG / jr z, .egg`. +do + local pages = runSeer(seerMon({ isEgg = true })) + eq(pages[2], "Hey!\fThat's an EGG!\fYou can't say that\nyou've met it yet…", + "_SeerEggText") +end + +-- ReadCaughtData's `.error` (engine/events/poke_seer.asm:104-105, :133). +do + local pages = runSeer(seerMon()) + check(pages[2]:find("Whaaaat", 1, true) ~= nil, + "a mon with no caught data at all gets _SeerCantTellAThingText") +end + +-- SeerAction0 (engine/events/poke_seer.asm:64-70), then SeerAdvice. +do + local mon = seerMon({ caughtTime = 1, caughtLevel = 10, + caughtLocation = 1, caughtByGender = "boy" }) + local pages = runSeer(mon) + eq(pages[2], "Hm… I see you met\nROCKY here:\vNEW BARK TOWN!", + "_SeerNameLocationText, with the town map's line break spent as a space") + eq(pages[3], "The time was\nMorning!\fIts level was 10!\fAm I good or what?", + "_SeerTimeLevelText") + check(pages[4]:find("more confident", 1, true) ~= nil, + "a 20 level gain is SeerMoreConfidentText, the 29 row") +end + +-- SeerAction1, and engine/events/poke_seer.asm:110-119 where the OT id's +-- second `cp` is commented out, so only the HIGH byte decides. +do + local traded = seerMon({ otId = 0x9999, otName = "SILVER", + caughtTime = 3, caughtLevel = 5, caughtLocation = 2 }) + local pages = runSeer(traded) + eq(pages[2], "Hm… ROCKY\ncame from SILVER\vin a trade?\fROUTE 29\n" + .. "was where SILVER\vmet ROCKY!", "_SeerTradeText") + eq(pages[3], "The time was\nNight!\fIts level was 5!\fAm I good or what?", + "the same time/level page follows") + + local sameHigh = seerMon({ otId = 0x12ff, caughtTime = 2, caughtLevel = 5, + caughtLocation = 2 }) + local samePages = runSeer(sameHigh) + check(samePages[2]:find("I see you met", 1, true) ~= nil, + "an OT id that differs only in its LOW byte reads as met, bug and all") +end + +-- GetCaughtLocation's two sentinels (engine/events/poke_seer.asm:239-249). +do + local event = seerMon({ caughtLevel = 20, caughtLocation = Mon.LANDMARK_EVENT }) + local pages = runSeer(event) + check(pages[2]:find("What!? Incredible!", 1, true) ~= nil, + "LANDMARK_EVENT is SEERACTION_LEVEL_ONLY, _SeerNoLocationText") + check(pages[2]:find("was at level 20", 1, true) ~= nil, + "which still prints the level") + check(pages[3] ~= nil, "and still gives the advice page") + + local gift = seerMon({ caughtLevel = 5, caughtLocation = Mon.LANDMARK_GIFT }) + local giftPages = runSeer(gift) + check(giftPages[2]:find("Whaaaat", 1, true) ~= nil, + "LANDMARK_GIFT is SEERACTION_CANT_TELL_2, the same refusal") + eq(#giftPages, 2, "and there is no advice behind it") +end + +-- GetCaughtLevel (engine/events/poke_seer.asm:148-179) and GetCaughtTime's +-- .none arm (:198-201). +do + local unknown = seerMon({ caughtLevel = 0, caughtLocation = 1, caughtTime = 0 }) + -- byte1 is set, so ReadCaughtData's `or [hl]` misses its `.error` arm + -- (engine/events/poke_seer.asm:105). + local pages = runSeer(unknown) + eq(pages[3], "The time was\nUnknown!\fIts level was ???!\fAm I good or what?", + "no time and no level print Unknown and ???") + + local hatched = seerMon({ caughtLevel = 1, caughtLocation = 1, caughtTime = 2, + level = 6 }) + local hatchedPages = runSeer(hatched) + check(hatchedPages[3]:find("level was 5", 1, true) ~= nil, + "CAUGHT_EGG_LEVEL prints EGG_LEVEL, not 1") +end + +-- SeerAdviceTexts (engine/events/poke_seer.asm:357-364), walked in order; +-- `sub c` is one byte, so the 255 row catches an underflow. +do + local function adviceFor(level, caught) + local mon = seerMon({ level = level, caughtLevel = caught, + caughtLocation = 1, caughtTime = 2 }) + return runSeer(mon)[4] + end + check(adviceFor(12, 10):find("little more care", 1, true) ~= nil, + "a 2 level gain is the 9 row") + check(adviceFor(35, 10):find("more confident", 1, true) ~= nil, + "25 is the 29 row") + check(adviceFor(50, 10):find("much strength", 1, true) ~= nil, + "40 is the 59 row") + check(adviceFor(70, 10):find("grown mighty", 1, true) ~= nil, + "60 is the 89 row") + check(adviceFor(95, 5):find("I'm impressed", 1, true) ~= nil, + "90 is the 100 row") + check(adviceFor(5, 10):find("little more care", 1, true) ~= nil, + "and `sub c` underflowing lands on the 255 row, which repeats the first") +end + +-- ================================================ UnusedFindItemInPCOrBag + +-- mobile/mobile_12_2.asm:194 wNumPCItems, then :201 wNumItems. +do + local save = newSave() + save.pcItems = { RARE_CANDY = 1 } + local vm = newVm(newHooks(save), 50) + run(vm, H.UnusedFindItemInPCOrBag) + eq(vm.scriptVar, 1, "an item in the PC answers TRUE") + + local vm2 = newVm(newHooks(save, { held = { [92] = true } }), 92) + run(vm2, H.UnusedFindItemInPCOrBag) + eq(vm2.scriptVar, 1, "an item in the pack answers TRUE too") + + local vm3 = newVm(newHooks(save), 92) + run(vm3, H.UnusedFindItemInPCOrBag) + eq(vm3.scriptVar, 0, "and neither is FALSE") +end + +-- ==================================================== the seam these ride + +do + for _, name in ipairs({ "MoveTutor", "BuenasPassword", "BuenaPrize", + "AskRememberPassword", "PokeSeer", "UnusedFindItemInPCOrBag" }) do + eq(Specials.HANDLER_SOURCE[name], "specials/crystal_extras.lua", + name .. " is owned by this module") + eq(Specials.STUBS[name], nil, name .. " is no longer a stub") + check(Specials.SUPERSEDED_STUBS[name] ~= nil, + "and its old stub reason was retired") + end + local ids = {} + for _, id in ipairs(require("src.ui.Screens").GEN2_IDS) do ids[id] = true end + check(ids.Gen2MoveTutor, "Gen2MoveTutor is a registered screen id") + check(ids.Gen2BuenaPassword, "Gen2BuenaPassword is one too") +end + +S.finish() diff --git a/tests/gen2_crystal_gender_flag_test.lua b/tests/gen2_crystal_gender_flag_test.lua new file mode 100644 index 00000000..60ae6a12 --- /dev/null +++ b/tests/gen2_crystal_gender_flag_test.lua @@ -0,0 +1,144 @@ +-- ENGINE_PLAYER_IS_FEMALE, the Crystal engine flag `checkflag` asks at 14 map +-- sites, and the Crystal-only Surf refusal that shares the wiring. +-- luajit tests/gen2_crystal_gender_flag_test.lua +-- +-- Both come in a Crystal half and a Gold half: Gold declares no such flag and +-- keeps its documented "you can Surf on top of NPCs" bug (CT-10). +-- The cache half SKIPs when no crystal cache is present. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 crystal gender flag") +local check, eq = S.check, S.eq + +love = require("tests.love_stub") + +local FieldMoves = require("src.world.gen2.FieldMoves") +local GameVersion = require("src.core.GameVersion") +local Save = require("src.core.gen2.Save") +local World = require("src.world.gen2.World") + +local priorVersion = GameVersion.get() + +-- ------------------------------------------------- the flag id, by name + +-- constants/engine_flags.asm's const block, with the badge rows at the ids a +-- real crystal cache reports and a hole where parse_const_block met a +-- const_skip. ENGINE_PLAYER_IS_FEMALE is row 100, so flag id 99. +local CRYSTAL_ORDER = {} +CRYSTAL_ORDER[27] = "ENGINE_ZEPHYRBADGE" +CRYSTAL_ORDER[28] = "ENGINE_HIVEBADGE" +CRYSTAL_ORDER[29] = "ENGINE_PLAINBADGE" +CRYSTAL_ORDER[30] = "ENGINE_FOGBADGE" +CRYSTAL_ORDER[31] = "ENGINE_MINERALBADGE" +CRYSTAL_ORDER[32] = "ENGINE_STORMBADGE" +CRYSTAL_ORDER[33] = "ENGINE_GLACIERBADGE" +CRYSTAL_ORDER[34] = "ENGINE_RISINGBADGE" +CRYSTAL_ORDER[35] = "ENGINE_BOULDERBADGE" +CRYSTAL_ORDER[42] = "ENGINE_EARTHBADGE" +-- the hole: nothing at 43..99, which is where ipairs would stop +CRYSTAL_ORDER[100] = "ENGINE_PLAYER_IS_FEMALE" + +FieldMoves.bindEngineFlags(CRYSTAL_ORDER) +eq(FieldMoves.FEMALE_FLAG, 99, "ENGINE_PLAYER_IS_FEMALE is flag 99 by NAME") +eq(FieldMoves.BADGE_FLAG[26].name, "ZEPHYR", + "and the badge block walks past the const_skip hole") +eq(FieldMoves.BADGE_FLAG[41].name, "EARTH", "including the last Kanto row") + +FieldMoves.bindEngineFlags(nil) +eq(FieldMoves.FEMALE_FLAG, nil, "Gold declares no such flag at all") +eq(FieldMoves.BADGE_FLAG[26].name, "ZEPHYR", "and keeps its own badge ids") + +-- ------------------------------------------------- the flag, through World + +local function flagWorld(version, gender, order) + local game = { + data = {}, + save = { + version = version, + player = { name = "CHRIS", id = 1, gender = gender, badges = {} }, + engineFlags = {}, + }, + } + FieldMoves.bindEngineFlags(order) + local world = World.new(game) + world.constants = {} + return world, game +end + +do + local world, game = flagWorld("crystal", "female", CRYSTAL_ORDER) + check(world:engineFlag(99), "Kris answers checkflag ENGINE_PLAYER_IS_FEMALE") + check(not (game.save.engineFlags or {})[99], + "and it is NOT a second copy in save.engineFlags") + game.save.player.gender = "male" + check(not world:engineFlag(99), "Chris answers the same flag false") + -- the write half exists only so a stray setflag cannot open a second store + world:setEngineFlag(99, true) + eq(game.save.player.gender, "female", "setflag writes the gender byte") + check(not (game.save.engineFlags or {})[99], "still one store") + world:setEngineFlag(99, false) + eq(game.save.player.gender, "male", "and clearflag writes it back") +end + +do + local world, game = flagWorld("gold", "male", nil) + check(not world:engineFlag(99), "Gold's flag 99 is an ordinary flag") + world:setEngineFlag(99, true) + check(world:engineFlag(99), "which reads back out of save.engineFlags") + check((game.save.engineFlags or {})[99] == true, "where Gold keeps it") + eq(game.save.player.gender, "male", "and the gender byte is untouched") +end + +-- ------------------------------------------------- Save.isFemale, the seam + +check(Save.isFemale({ player = { gender = "female" } }), "isFemale reads Kris") +check(not Save.isFemale({ player = { gender = "male" } }), "and not Chris") +check(not Save.isFemale({ player = {} }), "a genderless save is Chris") +check(not Save.isFemale(nil), "and so is no save at all") + +-- ------------------------------------------------- Surf onto an NPC (CT-10) + +local WATER = 0x29 -- pokecrystal constants/collision_constants.asm + +local function surfCtx(facingObject) + return { + save = { player = { badges = { FOG = true } } }, + mon = { species = "LAPRAS" }, + facing = "down", + facingColl = WATER, + playerColl = 0x00, + playerState = FieldMoves.PLAYER_NORMAL, + facingObject = facingObject, + } +end + +GameVersion.set("crystal") +check(FieldMoves.surfFromMenu(surfCtx(nil)).ok, + "Crystal surfs onto open water") +check(not FieldMoves.surfFromMenu(surfCtx({ id = "npc" })).ok, + "and refuses onto a facing object (CheckFacingObject)") +eq(FieldMoves.surfFromMenu(surfCtx({ id = "npc" })).text, + FieldMoves.TEXT.CANT_SURF, "with .cannotsurf's own line") + +GameVersion.set("gold") +check(FieldMoves.surfFromMenu(surfCtx(nil)).ok, "Gold surfs onto open water") +check(FieldMoves.surfFromMenu(surfCtx({ id = "npc" })).ok, + "and KEEPS the cart's bug: Surf right on top of the NPC") + +GameVersion.set("silver") +check(FieldMoves.surfFromMenu(surfCtx({ id = "npc" })).ok, + "Silver keeps it too") + +-- TrySurfOW is byte-identical in both trees, so the OW path never gained the +-- check (pokecrystal engine/events/overworld.asm:484-514 vs pokegold :469-499). +GameVersion.set("crystal") +do + local ctx = surfCtx({ id = "npc" }) + ctx.party = { { species = "LAPRAS", moves = { { id = "SURF" } } } } + check(FieldMoves.trySurfOW(ctx).ok, + "the A-press path is unchanged by the menu fix, even on Crystal") +end + +GameVersion.set(priorVersion) +FieldMoves.bindEngineFlags(nil) +S.finish() diff --git a/tests/gen2_crystal_gender_test.lua b/tests/gen2_crystal_gender_test.lua new file mode 100644 index 00000000..868322cd --- /dev/null +++ b/tests/gen2_crystal_gender_test.lua @@ -0,0 +1,334 @@ +-- Crystal's player gender: the InitGender screen, the byte it writes, and the +-- eight places that read it back. +-- luajit tests/gen2_crystal_gender_test.lua +-- +-- Gold and Silver have no Kris to extract, so every assertion comes in a +-- Crystal half and a Gold half: Gold must still boot straight into Oak with no +-- gender beat in the speech and Chris on the field. +-- The cache half SKIPs when no crystal cache is present. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 crystal gender") +local check, eq = S.check, S.eq + +love = require("tests.love_stub") + +local FieldMoves = require("src.world.gen2.FieldMoves") +local GameVersion = require("src.core.GameVersion") +local GenderSelect = require("src.ui.gen2.GenderSelect") +local NamePick = require("src.ui.gen2.NamePick") +local NamingScreen = require("src.ui.gen2.NamingScreen") +local OakSpeech = require("src.ui.gen2.OakSpeech") +local Pokegear = require("src.ui.gen2.Pokegear") +local Save = require("src.core.gen2.Save") +local Screens = require("src.ui.Screens") +local TrainerCard = require("src.ui.gen2.TrainerCard") + +local priorVersion = GameVersion.get() + +-- ------------------------------------------------- the two sprite tables + +-- data/sprites/player_sprites.asm +eq(FieldMoves.stateSprite("normal", "male"), "SPRITE_CHRIS", "Chris on foot") +eq(FieldMoves.stateSprite("bike", "male"), "SPRITE_CHRIS_BIKE", "Chris biking") +eq(FieldMoves.stateSprite("normal", "female"), "SPRITE_KRIS", "Kris on foot") +eq(FieldMoves.stateSprite("bike", "female"), "SPRITE_KRIS_BIKE", "Kris biking") +eq(FieldMoves.stateSprite("surf", "female"), "SPRITE_SURF", + "both share the Lapras") +eq(FieldMoves.stateSprite("surf_pika", "female"), "SPRITE_SURFING_PIKACHU", + "and the surfing PIKACHU") +eq(FieldMoves.stateSprite("normal", nil), "SPRITE_CHRIS", + "no recorded gender is PLAYERGENDER_MALE") +eq(FieldMoves.stateSprite("skate", "female"), "SPRITE_KRIS", + "a state with no row falls back to PLAYER_NORMAL's") +eq(FieldMoves.playerSprite("female"), "SPRITE_KRIS", "playerSprite is the same") +check(FieldMoves.isFemale("female"), "female reads as PLAYERGENDER_FEMALE_F") +check(not FieldMoves.isFemale("male"), "male does not") +check(not FieldMoves.isFemale(nil), "and neither does an absent byte") + +check(FieldMoves.hasGenderChoice({ SPRITE_KRIS = {} }), + "a cache with SPRITE_KRIS offers the choice") +check(not FieldMoves.hasGenderChoice({ SPRITE_CHRIS = {} }), + "a Gold cache does not") +check(not FieldMoves.hasGenderChoice(nil), "and neither does no cache at all") + +-- ------------------------------------------------- the save field (CT-5) + +eq(Save.defaultPlayerName("crystal", "male"), "CHRIS", "MalePlayerNameArray[0]") +eq(Save.defaultPlayerName("crystal", "female"), "KRIS", + "FemalePlayerNameArray[0]") +eq(Save.defaultPlayerName("gold", "female"), "GOLD", + "Gold has no female array to fall into") +eq(Save.defaultPlayerName("silver", "female"), "SILVER", "nor does Silver") +eq(Save.defaultPlayerName("crystal"), "CHRIS", "and no gender is Chris") + +check(Save.isFemale({ player = { gender = "female" } }), "isFemale on a save") +check(not Save.isFemale({ player = { gender = "male" } }), "and not on Chris") +check(not Save.isFemale({}), "nor on a save with no player block") + +GameVersion.set("crystal") +local kris = Save.newGame({ gender = "female" }) +eq(kris.player.gender, "female", "newGame records the answer") +eq(kris.player.name, "KRIS", "and lays down .Kris as the default name") +local chris = Save.newGame({}) +eq(chris.player.gender, "male", "InitCrystalData zeroes the byte") +eq(chris.player.name, "CHRIS", "so a fresh save is Chris") +eq(Save.newGame({ gender = "female", playerName = "ZZ" }).player.name, "ZZ", + "an explicit name still wins") + +local normalized = Save.normalize({ version = "crystal", + player = { gender = "female" } }) +eq(normalized.player.name, "KRIS", "normalize fills the gendered default") +eq(Save.normalize({ version = "crystal", player = {} }).player.name, "CHRIS", + "and the male one with no byte") + +-- ------------------------------------------------- the name presets + +GameVersion.set("crystal") +eq(NamePick.presetsFor("male")[1], "CHRIS", "ChrisNameMenuHeader row 1") +eq(NamePick.presetsFor("male")[4], "JON", "and row 4") +eq(NamePick.presetsFor("female")[1], "KRIS", "KrisNameMenuHeader row 1") +eq(NamePick.presetsFor("female")[4], "JODI", "and row 4") +eq(NamePick.presetsFor()[1], "CHRIS", "no gender is the male header") +GameVersion.set("gold") +eq(NamePick.presetsFor("female")[1], "GOLD", + "Gold keeps PlayerNameArray whatever is asked for") +GameVersion.set("silver") +eq(NamePick.presetsFor("female")[1], "SILVER", "and so does Silver") +GameVersion.set("crystal") + +eq(NamingScreen.playerSprite("female"), "SPRITE_KRIS", + "GetPlayerIcon's female sheet") +eq(NamingScreen.playerSprite("male"), "SPRITE_CHRIS", "and its male one") + +-- ------------------------------------------------- the screen itself + +check(Screens.GEN2_IDS ~= nil, "the registry lists the Gen 2 screens") +local registered = {} +for _, id in ipairs(Screens.GEN2_IDS) do registered[id] = true end +check(registered.Gen2GenderSelect, "Gen2GenderSelect has an id") +eq(Screens.get({ data = {} }, "Gen2GenderSelect"), GenderSelect, + "and resolves to the builtin") + +eq(GenderSelect.OPTIONS[1].label, "Boy", ".MenuData item 1") +eq(GenderSelect.OPTIONS[1].gender, "male", "writes wPlayerGender 0") +eq(GenderSelect.OPTIONS[2].label, "Girl", ".MenuData item 2") +eq(GenderSelect.OPTIONS[2].gender, "female", "writes wPlayerGender 1") + +-- A joypad that answers one press then goes quiet, the way the harness fakes +-- input for every other screen suite here. +local function fakeInput(button) + local pressed = button + return { + wasPressed = function(_, name) + if pressed and name == pressed then + pressed = nil + return true + end + return false + end, + } +end + +local function screenGame(save, button) + return { save = save, data = {}, input = fakeInput(button) } +end + +local save = Save.newGame({}) +local answered +local game = screenGame(save, "a") +local screen = GenderSelect.new(game, { onDone = function(g) answered = g end }) +eq(screen.cursor, 1, "`db 1 ; default option` opens on Boy") +screen:update(0) +eq(save.player.gender, "male", "A on Boy writes PLAYERGENDER_MALE") +eq(answered, nil, "and the DelayFrames 10 has not run out yet") +for _ = 1, 10 do screen:update(0) end +eq(answered, "male", "onDone fires once the ten frames are gone") + +save = Save.newGame({}) +game = screenGame(save, "down") +screen = GenderSelect.new(game, { onDone = function() end }) +screen:update(0) +eq(screen.cursor, 2, "down walks to Girl") +game.input = fakeInput("down") +screen:update(0) +eq(screen.cursor, 1, "STATICMENU_WRAP wraps at the bottom") +game.input = fakeInput("up") +screen:update(0) +eq(screen.cursor, 2, "and at the top") +game.input = fakeInput("b") +screen:update(0) +eq(save.player.gender, "male", "STATICMENU_DISABLE_B: B answers nothing") +game.input = fakeInput("a") +screen:update(0) +eq(save.player.gender, "female", "A on Girl writes PLAYERGENDER_FEMALE") + +check(type(screen.text) == "string" and screen.text:find("boy"), + "the prompt is _AreYouABoyOrAreYouAGirlText") + +-- ------------------------------------------------- the beat in Oak's speech + +local function speechFor(sprites) + return { game = { data = { gen2Sprites = sprites } } } +end + +local crystalSteps = OakSpeech.defaultSteps(speechFor({ SPRITE_KRIS = {} })) +eq(crystalSteps[1].id, "gender_select", + "PlayerProfileSetup's InitGender runs before OakSpeech") +eq(crystalSteps[1].kind, "gender", "as its own step kind") +eq(crystalSteps[2].id, "init_clock", "and the clock follows it") + +local goldSteps = OakSpeech.defaultSteps(speechFor({ SPRITE_CHRIS = {} })) +eq(goldSteps[1].id, "init_clock", "Gold opens on the clock, as it always did") +for _, step in ipairs(goldSteps) do + check(step.kind ~= "gender", "and never grows a gender beat") +end +eq(OakSpeech.defaultSteps()[1].id, "init_clock", + "a bare call (no speech) is the Gold list") +eq(#goldSteps + 1, #crystalSteps, "the Crystal list is the Gold list plus one") + +-- ------------------------------------------------- the trainer card + +local CARD_GFX = { + card = "card.png", cardFemale = "card_f.png", cardTilesWide = 16, + leaderClasses = { "FALKNER" }, + -- the male attrmap the extractor writes: portrait on $0, corner on $1 + paletteZones = { { 14, 1, 5, 7, 1 }, { 18, 1, 1, 1, 2 } }, +} + +local function cardFor(gender) + return TrainerCard.new({ data = {} }, { + save = { player = { gender = gender, badges = {}, kantoBadges = {} } }, + menuGfx = { trainerCard = CARD_GFX }, + palettes = { trainers = { PLAYER = { { 1, 1, 1 }, { 2, 2, 2 } }, + FALKNER = { { 3, 3, 3 }, { 4, 4, 4 } } } }, + }) +end + +local male = cardFor("male") +check(not male.female, "Chris keeps ChrisCardPic") +eq(male.zoneDefault, 2, "and the border wears Falkner's row") +eq(male.zone[1 * 20 + 14], 1, "with the portrait on the player's") + +local female = cardFor("female") +check(female.female, "Kris takes KrisCardPic") +eq(female.zoneDefault, 1, "the border swaps to Chris's row") +eq(female.zone[1 * 20 + 14], 2, "the portrait to Falkner's, which is Kris's") +eq(female.zone[1 * 20 + 18], 1, "the top-right corner follows the border") +eq(female.zone[14 * 20 + 14], 2, "and Clair borrows Kris's palette") +eq(female.zone[14 * 20 + 10], nil, "Pryce's box is untouched") + +local noFemaleArt = TrainerCard.new({ data = {} }, { + save = { player = { gender = "female" } }, + menuGfx = { trainerCard = { card = "card.png", paletteZones = {} } }, +}) +check(not noFemaleArt.female, + "a Gold cache with no cardFemale draws the card it has") + +-- ------------------------------------------------- the Pokegear + +local GEAR_GFX = { + tiles = "gear.png", + palettes = { { { 1, 1, 1 } } }, + palettesFemale = { { { 9, 9, 9 } } }, +} + +local function gearFor(gender) + return Pokegear.new({ data = {} }, { + save = { player = { gender = gender }, pokegearFlags = {} }, + menuGfx = { pokegear = GEAR_GFX }, + }) +end + +eq(gearFor("male"):pals(), GEAR_GFX.palettes, "MalePokegearPals for Chris") +eq(gearFor("female"):pals(), GEAR_GFX.palettesFemale, + "FemalePokegearPals for Kris") +local noFemalePals = Pokegear.new({ data = {} }, { + save = { player = { gender = "female" }, pokegearFlags = {} }, + menuGfx = { pokegear = { tiles = "gear.png", palettes = GEAR_GFX.palettes } }, +}) +eq(noFemalePals:pals(), GEAR_GFX.palettes, + "a Gold cache has one set and uses it") + +-- ------------------------------------------------- the magnet train + +local MagnetTrainRide = require("src.ui.gen2.MagnetTrainRide") +local SPRITES = { SPRITE_CHRIS = { image = "chris.png" }, + SPRITE_KRIS = { image = "kris.png" } } +eq(MagnetTrainRide.playerSpriteDef({ data = { gen2Sprites = SPRITES }, + gender = "female" }), SPRITES.SPRITE_KRIS, "Kris rides MAGNET_TRAIN_BLUE") +eq(MagnetTrainRide.playerSpriteDef({ data = { gen2Sprites = SPRITES }, + gender = "male" }), SPRITES.SPRITE_CHRIS, "Chris rides MAGNET_TRAIN_RED") +eq(MagnetTrainRide.playerSpriteDef({ + data = { gen2Sprites = { SPRITE_CHRIS = SPRITES.SPRITE_CHRIS } }, + gender = "female" }), SPRITES.SPRITE_CHRIS, + "a Gold cache has only the one sheet") + +-- ------------------------------------------------------------ cache-gated + +local cache = os.getenv("CRYSTAL_CACHE") +if not cache then + local home = os.getenv("HOME") or "" + cache = home .. "/Library/Application Support/LOVE/crystal-dev/crystal" +end + +local function loadLua(rel) + local chunk = loadfile(cache .. "/" .. rel) + if not chunk then return nil end + local ok, value = pcall(chunk) + return ok and value or nil +end + +local function exists(rel) + local f = io.open(cache .. "/" .. rel, "rb") + if not f then return false end + f:close() + return true +end + +local sprites = loadLua("data/generated/sprites.lua") +if not sprites then + check(true, "crystal cache absent : SKIP") + GameVersion.set(priorVersion) + S.finish() + return +end + +check(sprites.SPRITE_KRIS ~= nil, "the cache carries SPRITE_KRIS") +check(sprites.SPRITE_KRIS_BIKE ~= nil, "and SPRITE_KRIS_BIKE") +eq(sprites.SPRITE_KRIS.palette, "PAL_OW_BLUE", "Kris is PAL_NPC_BLUE") +eq(sprites.SPRITE_CHRIS.palette, "PAL_OW_RED", "Chris is PAL_NPC_RED") +check(FieldMoves.hasGenderChoice(sprites), + "so a real Crystal cache offers the choice") + +for _, rel in ipairs({ + "assets/generated/intro/chris.png", + "assets/generated/intro/kris.png", + "assets/generated/trainer_card/card.png", + "assets/generated/trainer_card/card_f.png", + "assets/generated/sprites/kris.png", + "assets/generated/sprites/kris_bike.png", +}) do + check(exists(rel), "the cache carries " .. rel) +end + +local menuGfx = loadLua("data/generated/menu_gfx.lua") or {} +check(menuGfx.trainerCard and menuGfx.trainerCard.cardFemale ~= nil, + "menu_gfx.trainerCard.cardFemale is extracted") +check(menuGfx.pokegear and menuGfx.pokegear.palettesFemale ~= nil, + "menu_gfx.pokegear.palettesFemale is extracted") +check(menuGfx.pack and menuGfx.pack.palettesFemale ~= nil, + "menu_gfx.pack.palettesFemale is extracted") + +local palettes = loadLua("data/generated/palettes.lua") or {} +check(palettes.trainers and palettes.trainers.PLAYER ~= nil, + "PlayerPalette is row 0") +check(palettes.trainers and palettes.trainers.FALKNER ~= nil, + "and KrisPalette is Falkner's") + +local romText = loadLua("data/generated/rom_text.lua") or {} +check(romText._AreYouABoyOrAreYouAGirlText ~= nil, + "the prompt is in the extracted text") + +GameVersion.set(priorVersion) +S.finish() diff --git a/tests/gen2_crystal_mobile_gfx_test.lua b/tests/gen2_crystal_mobile_gfx_test.lua new file mode 100644 index 00000000..f4cf94d3 --- /dev/null +++ b/tests/gen2_crystal_mobile_gfx_test.lua @@ -0,0 +1,463 @@ +-- Crystal's Kris battle/Hall-of-Fame art and the Mobile System GB sheets. +-- luajit tests/gen2_crystal_mobile_gfx_test.lua +-- +-- Two imports that had no consumer at the time they landed, so nothing else +-- would notice them rotting: Kris's backpic and KRIS trainer-class pic (wave A +-- shipped the female protagonist but battles and the Hall of Fame still drew +-- Chris), and the whole gfx/mobile + gfx/mystery_gift set, imported ahead of +-- the phase that uses it so Crystal never has to re-import for it. +-- +-- What is held down: the stage exists and run() calls it, it is gated on the +-- crystal edition so Gold and Silver emit nothing, every label it reads is in +-- the generated Crystal manifest at the address pokecrystal.sym gives, and the +-- blobs it wrote are ../pokecrystal/gfx/mobile byte for byte. +-- +-- ROM-free. The decomp half SKIPs with no ../pokecrystal beside the repo, the +-- cache half with no Crystal cache. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 crystal mobile gfx") +local check, eq = S.check, S.eq + +love = require("tests.love_stub") + +local function readFile(path) + local f = io.open(path, "rb") + if not f then return nil end + local body = f:read("*a") + f:close() + return body +end + +-- ------------------------------------------------------- the stage itself + +local extractorSource = assert(readFile("src/import/RomExtractorGen2.lua")) +local RomExtractorGen2 = require("src.import.RomExtractorGen2") + +check(type(RomExtractorGen2.extractMobileGfx) == "function", + "RomExtractorGen2:extractMobileGfx exists") +check(extractorSource:find("results.mobileGfx = self:extractMobileGfx()", + 1, true) ~= nil, "and RomExtractorGen2:run calls it") +check(extractorSource:find( + 'if self.edition ~= "crystal" then return nil end', 1, true) ~= nil, + "gated on the edition, so Gold and Silver write no mobile art") +-- The two suites that pin the progress bar read this same literal; a new +-- stage must not move it (tests/gen2_diploma_test.lua:45). +check(extractorSource:find("local STAGE_COUNT = 27", 1, true) ~= nil, + "and it did not disturb STAGE_COUNT") + +-- ------------------------------------------------------------ the symbols + +-- ../pokecrystal-symbols/pokecrystal.sym, international v1.0. Every one of +-- these resolves in that build: the Japanese-only part of Mobile System GB is +-- the text and the menu entries that reach it, not the art. +local SYMBOLS = { + AsciiFontGFX = { 0x5c, 0x5db1 }, + PichuAnimatedMobileGFX = { 0x5c, 0x4d16 }, + ElectroBallMobileGFX = { 0x5c, 0x55a4 }, + PichuBorderMobileGFX = { 0x5c, 0x5848 }, + Stadium2N64GFX = { 0x5c, 0x6f1f }, + Stadium2N64Tilemap = { 0x5c, 0x73af }, + Stadium2N64Attrmap = { 0x5c, 0x7517 }, + PasswordTopTilemap = { 0x5c, 0x6491 }, + PasswordBottomTilemap = { 0x5c, 0x651d }, + PasswordShiftTilemap = { 0x5c, 0x65f9 }, + ChooseMobileCenterTilemap = { 0x5c, 0x6685 }, + MobilePasswordAttrmap = { 0x5c, 0x67ed }, + ChooseMobileCenterAttrmap = { 0x5c, 0x6955 }, + MobilePasswordPalettes = { 0x5c, 0x5d71 }, + MobileCardGFX = { 0x5e, 0x59ef }, + ChrisSilhouetteGFX = { 0x5e, 0x5bef }, + KrisSilhouetteGFX = { 0x5e, 0x5e1f }, + MobileCard2GFX = { 0x5e, 0x604f }, + CardLargeSpriteAndFolderGFX = { 0x5e, 0x61bf }, + CardSpriteGFX = { 0x5e, 0x664f }, + DialpadTilemap = { 0x5e, 0x6cd5 }, + DialpadAttrmap = { 0x5e, 0x6e3d }, + DialpadGFX = { 0x5e, 0x6fa5 }, + DialpadCursorGFX = { 0x5e, 0x7465 }, + MobileCardListGFX = { 0x5e, 0x74b9 }, + HaveWantGFX = { 0x5f, 0x4083 }, + MobileSelectGFX = { 0x5f, 0x4983 }, + HaveWantMap = { 0x5f, 0x4b83 }, + PokemonNewsGFX = { 0x5f, 0x66fe }, + PokemonNewsTileAttrmap = { 0x5f, 0x6b8e }, + PokemonNewsPalettes = { 0x5f, 0x6ff6 }, + ["MobileSystemSplashScreen_InitGFX.Tiles"] = { 0x5b, 0x4173 }, + ["MobileSystemSplashScreen_InitGFX.Tilemap"] = { 0x5b, 0x4633 }, + ["MobileSystemSplashScreen_InitGFX.Attrmap"] = { 0x5b, 0x479b }, + MobileSplashScreenPalettes = { 0x5b, 0x4903 }, + MobileAdapterCheckGFX = { 0x5b, 0x4ca3 }, + MobileTradeSpritesGFX = { 0x42, 0x4d27 }, + MobileTradeGFX = { 0x42, 0x4da7 }, + MobileTradeTilemapLZ = { 0x42, 0x4fe7 }, + MobileTradeAttrmapLZ = { 0x42, 0x50a7 }, + MobileCable1GFX = { 0x42, 0x51c7 }, + MobileCable2GFX = { 0x42, 0x52c7 }, + UnusedMobilePulsePalettes = { 0x42, 0x50f7 }, + MobileTradeBGPalettes = { 0x42, 0x5107 }, + MobileTradeOB1Palettes = { 0x42, 0x5147 }, + MobileTradeOB2Palettes = { 0x42, 0x5187 }, + MobileAdapterPalettes = { 0x42, 0x53c7 }, + MobileTradeLightsGFX = { 0x40, 0x72a2 }, + MobileTradeLightsPalettes = { 0x40, 0x72e2 }, + PichuBorderMobileOBPalettes = { 0x45, 0x730e }, + PichuBorderMobileBGPalettes = { 0x45, 0x734e }, + PichuBorderMobileTilemapAttrmap = { 0x45, 0x7356 }, + MobileDialingGFX = { 0x45, 0x601a }, + MobileUpArrowGFX = { 0x12, 0x48c3 }, + MobileDownArrowGFX = { 0x12, 0x48cb }, + EZChatCursorGFX = { 0x22, 0x540b }, + MobileDialingFrameGFX = { 0x41, 0x6514 }, + SelectStartGFX = { 0x47, 0x567e }, + MobileMenuGFX = { 0x12, 0x5c0c }, + MobilePhoneTilesGFX = { 0x3e, 0x5234 }, + MysteryGiftGFX = { 0x41, 0x5258 }, + CardTradeGFX = { 0x41, 0x5930 }, + CardTradeSpriteGFX = { 0x41, 0x5d30 }, +} + +-- Kris's own two pics. KrisBackpic needs no delta row: embedded_symbols in +-- make_gold_manifest.py adds every unqualified *Backpic label on sight. +local KRIS_SYMBOLS = { + KrisBackpic = { 0x22, 0x4ed6 }, + KrisPic = { 0x22, 0x4bb9 }, + ChrisPic = { 0x22, 0x48a9 }, +} + +do + local deltas = assert(readFile("tools/crystal_symbol_deltas.py")) + for label in pairs(SYMBOLS) do + check(deltas:find('"' .. label .. '"', 1, true) ~= nil, + label .. " is in crystal_symbol_deltas.MOBILE_SYMBOLS") + end +end + +local Json = require("src.link.Json") +local function manifestSymbols(path) + local body = readFile(path) + if not body then return nil end + return (Json.decode(body) or {}).symbols or {} +end + +do + local symbols = manifestSymbols("tools/rom_manifest_crystal.json") + if not symbols then + check(true, "no tools/rom_manifest_crystal.json (SKIP)") + else + local wrong = {} + for label, at in pairs(SYMBOLS) do + local got = symbols[label] + if not got or got[1] ~= at[1] or got[2] ~= at[2] then + wrong[#wrong + 1] = label + end + end + check(#wrong == 0, #wrong == 0 + and "the Crystal manifest carries all 63 mobile symbols where " + .. "pokecrystal.sym puts them" + or ("mobile symbols missing or misplaced: " .. table.concat(wrong, ", "))) + for label, at in pairs(KRIS_SYMBOLS) do + local got = symbols[label] + if not got then + check(false, "the Crystal manifest carries " .. label) + else + eq(got[1], at[1], label .. " is in the bank pokecrystal.sym says") + eq(got[2], at[2], label .. " is at the address it says") + end + end + end +end + +do + -- Gold has no mobile banks; a delta that leaked into the shared set would + -- break the Gold import rather than show up here as a missing sheet. + local symbols = manifestSymbols("tools/rom_manifest_gold.json") + if not symbols then + check(true, "no tools/rom_manifest_gold.json (SKIP)") + else + local leaked = {} + for label in pairs(SYMBOLS) do + if symbols[label] then leaked[#leaked + 1] = label end + end + check(#leaked == 0, #leaked == 0 + and "and the Gold manifest carries none of them" + or ("mobile symbols leaked into Gold: " .. table.concat(leaked, ", "))) + check(symbols.KrisBackpic == nil, "nor Gold's non-existent KrisBackpic") + end +end + +-- --------------------------------------------------------------- the cache + +local cache = os.getenv("CRYSTAL_CACHE") +if not cache then + local home = os.getenv("HOME") or "" + cache = home .. "/Library/Application Support/LOVE/crystal-dev/crystal" +end + +local function loadCache(rel) + local chunk = loadfile(cache .. "/data/generated/" .. rel .. ".lua") + if not chunk then return nil end + local ok, value = pcall(chunk) + return ok and value or nil +end + +-- PNG IHDR: width and height are big-endian at bytes 17 and 21. +local function pngSize(relative) + local png = readFile(cache .. "/" .. relative) + if not png then return nil end + local function be32(s, i) + local a, b, c, d = s:byte(i, i + 3) + return ((a * 256 + b) * 256 + c) * 256 + d + end + return be32(png, 17), be32(png, 21) +end + +-- ../pokecrystal/gfx/player/kris_back.png is 48x48 and kris.png 56x56, the +-- same six and seven tiles square Chris uses. +local BACK_PIC_PX = 48 +local TRAINER_PIC_PX = 56 + +-- Kris's art and the mobile sheets ride one import, so mobile_gfx.lua doubles +-- as the marker for both: a cache built before this unit has neither, and the +-- whole cache half stands down rather than failing on an old sandbox. +local mobile = loadCache("mobile_gfx") +local menuGfx = mobile and loadCache("menu_gfx") or nil +if not menuGfx then + check(true, "no Crystal cache with mobile_gfx.lua at " .. cache + .. " : re-import for gfx/mobile (SKIP)") +else + local hud = menuGfx.battleHud or {} + eq(hud.playerBackFemale, "assets/generated/battle/player_back_female.png", + "menu_gfx points battles and the Hall of Fame at Kris's backpic") + local w, h = pngSize("assets/generated/battle/player_back_female.png") + eq(w, BACK_PIC_PX, "GetKrisBackpic's pic is 48px wide") + eq(h, BACK_PIC_PX, "and 48 tall, not the 7*7 tiles of VRAM it asks for") + check(hud.playerBack ~= hud.playerBackFemale, + "and it is not the same file Chris gets") + + local pics = hud.trainerPics or {} + eq(pics.KRIS, "assets/generated/battle/trainers/kris.png", + "HOF_LoadTrainerFrontpic's KRIS class has a pic of its own") + eq(pics.CHRIS, "assets/generated/battle/trainers/chris.png", + "as does CHRIS, which TrainerPicPointers has no row for") + for _, key in ipairs({ "kris", "chris" }) do + local pw, ph = pngSize("assets/generated/battle/trainers/" .. key .. ".png") + eq(pw, TRAINER_PIC_PX, key .. ".png is 56px wide") + eq(ph, TRAINER_PIC_PX, "and 56 tall, like every other trainer pic") + end +end + +local oakSpeech = mobile and loadCache("oak_speech") or nil +if not oakSpeech then + check(true, "no oak_speech.lua beside a mobile_gfx.lua cache (SKIP)") +else + eq(oakSpeech.playerPicFemale, "assets/generated/intro/kris.png", + "and DrawIntroPlayerPic's female arm is named in oak_speech.lua") +end + +-- Sheets whose tile count fills the pret PNG's grid exactly once padded, so +-- the cache PNG must come out at the source PNG's own size. The three left +-- out (mobile_splash, electro_ball, phone_tiles) are built with +-- --remove-duplicates / --remove-whitespace, so the ROM holds fewer tiles than +-- the PNG does and only the tilemap puts them back (../pokecrystal/Makefile: +-- 343,344,348). +local SHEETS = { + { "asciiFont", "mobile/ascii_font.png", 110, 16, "gfx/mobile/ascii_font.png" }, + { "card", "mobile/card.png", 32, 16, "gfx/mobile/card.png" }, + { "card2", "mobile/card_2.png", 23, 12, "gfx/mobile/card_2.png" }, + { "cardLargeSprite", "mobile/card_large_sprite.png", 8, 4, + "gfx/mobile/card_large_sprite.png" }, + { "cardFolder", "mobile/card_folder.png", 65, 6, + "gfx/mobile/card_folder.png" }, + { "cardList", "mobile/card_list.png", 24, 12, "gfx/mobile/card_list.png" }, + { "cardSprite", "mobile/card_sprite.png", 4, 2, "gfx/mobile/card_sprite.png" }, + { "chrisSilhouette", "mobile/chris_silhouette.png", 35, 5, + "gfx/mobile/chris_silhouette.png" }, + { "krisSilhouette", "mobile/kris_silhouette.png", 35, 5, + "gfx/mobile/kris_silhouette.png" }, + { "dialing", "mobile/dialing.png", 20, 2, "gfx/mobile/dialing.png" }, + { "dialingFrame", "mobile/dialing_frame.png", 8, 2, + "gfx/mobile/dialing_frame.png" }, + { "dialpad", "mobile/dialpad.png", 76, 16, "gfx/mobile/dialpad.png" }, + { "dialpadCursor", "mobile/dialpad_cursor.png", 5, 2, + "gfx/mobile/dialpad_cursor.png" }, + { "ezChatCursor", "mobile/ez_chat_cursor.png", 2, 1, + "gfx/mobile/ez_chat_cursor.png" }, + { "haveWant", "mobile/havewant.png", 144, 16, "gfx/mobile/havewant.png" }, + { "select", "mobile/select.png", 32, 4, "gfx/mobile/select.png" }, + { "selectStart", "mobile/select_start.png", 6, 3, + "gfx/mobile/select_start.png" }, + { "cable1", "mobile/mobile_cable_1.png", 16, 4, + "gfx/mobile/mobile_cable_1.png" }, + { "cable2", "mobile/mobile_cable_2.png", 16, 4, + "gfx/mobile/mobile_cable_2.png" }, + { "menu", "mobile/mobile_menu.png", 13, 13, "gfx/mobile/mobile_menu.png" }, + { "adapterCheck", "mobile/mobile_splash_check.png", 48, 16, + "gfx/mobile/mobile_splash_check.png" }, + { "tradeLights", "mobile/mobile_trade_lights.png", 4, 2, + "gfx/mobile/mobile_trade_lights.png" }, + { "pichuBorder", "mobile/pichu_border.png", 24, 4, + "gfx/mobile/pichu_border.png" }, + { "pokemonNews", "mobile/pokemon_news.png", 72, 8, + "gfx/mobile/pokemon_news.png" }, + { "stadium2N64", "mobile/stadium2_n64.png", 73, 11, + "gfx/mobile/stadium2_n64.png" }, + { "pichuAnimated", "mobile/pichu_animated.png", 193, 16, + "gfx/mobile/pichu_animated.png" }, + { "trade", "mobile/mobile_trade.png", 128, 8, "gfx/mobile/mobile_trade.png" }, + { "tradeSprites", "mobile/mobile_trade_sprites.png", 16, 4, + "gfx/mobile/mobile_trade_sprites.png" }, + { "upArrow", "mobile/up_arrow.png", 1, 1, "gfx/mobile/up_arrow.png" }, + { "downArrow", "mobile/down_arrow.png", 1, 1, "gfx/mobile/down_arrow.png" }, + { "mysteryGift", "mobile/mystery_gift/mystery_gift.png", 67, 16, + "gfx/mystery_gift/mystery_gift.png" }, + { "cardTrade", "mobile/mystery_gift/card_trade.png", 64, 16, + "gfx/mystery_gift/card_trade.png" }, + { "cardTradeSprite", "mobile/mystery_gift/card_sprite.png", 8, 4, + "gfx/mystery_gift/card_sprite.png" }, +} + +-- The three the dedupe passes shrink: only the declared tile count is checked. +local DEDUPED = { + { "splash", "mobile/mobile_splash.png", 76, 14 }, + { "electroBall", "mobile/electro_ball.png", 83, 16 }, + { "phoneTiles", "mobile/phone_tiles.png", 17, 2 }, +} + +-- key, cache file, byte length, ../pokecrystal source it must equal. +local MAPS = { + { "passwordTop", "password_top", 140, "gfx/mobile/password_top.tilemap" }, + { "passwordBottom", "password_bottom", 220, + "gfx/mobile/password_bottom.tilemap" }, + { "passwordShift", "password_shift", 140, + "gfx/mobile/password_shift.tilemap" }, + { "passwordAttrmap", "password_attrmap", 360, + "gfx/mobile/password.attrmap" }, + { "centerTilemap", "mobile_center_tilemap", 360, + "gfx/mobile/mobile_center.tilemap" }, + { "centerAttrmap", "mobile_center_attrmap", 360, + "gfx/mobile/mobile_center.attrmap" }, + { "stadium2N64Tilemap", "stadium2_n64_tilemap", 360, + "gfx/mobile/stadium2_n64.tilemap" }, + { "stadium2N64Attrmap", "stadium2_n64_attrmap", 360, + "gfx/mobile/stadium2_n64.attrmap" }, + { "dialpadTilemap", "dialpad_tilemap", 360, "gfx/mobile/dialpad.tilemap" }, + { "dialpadAttrmap", "dialpad_attrmap", 360, "gfx/mobile/dialpad.attrmap" }, + { "haveWantMap", "havewant_map", 1136, "gfx/mobile/havewant_map.bin" }, + { "newsAttrmap", "pokemon_news_attrmap", 1128, + "gfx/mobile/pokemon_news.bin" }, + { "splashTilemap", "mobile_splash_tilemap", 360, + "gfx/mobile/mobile_splash.tilemap" }, + { "splashAttrmap", "mobile_splash_attrmap", 360, + "gfx/mobile/mobile_splash.attrmap" }, + { "pichuBorderTilemap", "pichu_border_tilemap", 384, + "gfx/mobile/pichu_border.tilemap" }, + { "pichuBorderAttrmap", "pichu_border_attrmap", 384, + "gfx/mobile/pichu_border.attrmap" }, + { "tradeTilemap", "mobile_trade_tilemap", 1024, + "gfx/mobile/mobile_trade.tilemap" }, + { "tradeAttrmap", "mobile_trade_attrmap", 1024, + "gfx/mobile/mobile_trade.attrmap" }, +} + +local PALETTE_COLORS = { + splash = 32, password = 32, pokemonNews = 32, pichuBorderOB = 32, + pichuBorderBG = 4, tradeBG = 32, tradeOB1 = 32, tradeOB2 = 32, + tradeLights = 16, adapters = 8, unusedPulses = 8, +} + +local pokecrystal = "../pokecrystal" +local havePret = readFile(pokecrystal .. "/gfx/mobile/ascii_font.2bpp") ~= nil + +if not mobile then + check(true, "no mobile_gfx.lua in the Crystal cache (SKIP)") +else + eq(mobile.generation, 2, "mobile_gfx.lua is a Gen 2 table") + local sheets = mobile.sheets or {} + local maps = mobile.maps or {} + local palettes = mobile.palettes or {} + + local function checkSheet(row, pretPng) + local key, rel, tiles, wide = row[1], row[2], row[3], row[4] + local entry = sheets[key] + if type(entry) ~= "table" then + check(false, "mobile_gfx carries the " .. key .. " sheet") + return + end + eq(entry.tiles, tiles, key .. " is the tile count the ROM block holds") + eq(entry.tilesWide, wide, "and is written " .. wide .. " tiles across") + eq(entry.path, "assets/generated/" .. rel, "at the path it advertises") + local w, h = pngSize("assets/generated/" .. rel) + eq(w, wide * 8, key .. ".png is " .. (wide * 8) .. "px wide") + eq(h, math.ceil(tiles / wide) * 8, "and tall enough for every tile") + if pretPng and havePret then + local png = readFile(pokecrystal .. "/" .. pretPng) + if png then + local function be32(s, i) + local a, b, c, d = s:byte(i, i + 3) + return ((a * 256 + b) * 256 + c) * 256 + d + end + eq(w, be32(png, 17), "which is " .. pretPng .. "'s own width") + eq(h, be32(png, 21), "and its own height") + end + end + end + + for _, row in ipairs(SHEETS) do checkSheet(row, row[5]) end + for _, row in ipairs(DEDUPED) do checkSheet(row, nil) end + + for _, row in ipairs(MAPS) do + local key, file, bytes, source = row[1], row[2], row[3], row[4] + local entry = maps[key] + local rel = "assets/generated/mobile/" .. file .. ".bin" + if type(entry) ~= "table" then + check(false, "mobile_gfx carries the " .. key .. " map") + else + eq(entry.bytes, bytes, key .. " is " .. bytes .. " bytes") + eq(entry.path, rel, "at the path it advertises") + local blob = readFile(cache .. "/" .. rel) + if not blob then + check(false, rel .. " was written") + else + eq(#blob, bytes, "and the file on disk is that long") + local want = havePret and readFile(pokecrystal .. "/" .. source) or nil + if not want then + check(true, "no ../pokecrystal: " .. source .. " not diffed (SKIP)") + else + check(blob == want:sub(1, bytes), + "it is ../pokecrystal/" .. source .. " byte for byte") + end + end + end + end + + for key, colors in pairs(PALETTE_COLORS) do + local pal = palettes[key] + if type(pal) ~= "table" then + check(false, "mobile_gfx carries the " .. key .. " palette block") + else + eq(#pal, colors, key .. " is " .. colors .. " RGB words") + eq(#(pal[1] or {}), 3, "each one decoded to an r,g,b triple") + end + end +end + +-- A Gold cache must have gone nowhere near any of this. +local goldCache = os.getenv("GOLD_CACHE") +if not goldCache then + local home = os.getenv("HOME") or "" + goldCache = home .. "/Library/Application Support/LOVE/gold-dev/gold" +end +if not loadfile(goldCache .. "/data/generated/menu_gfx.lua") then + check(true, "no Gold cache at " .. goldCache .. " (SKIP)") +else + check(loadfile(goldCache .. "/data/generated/mobile_gfx.lua") == nil, + "a Gold cache has no mobile_gfx.lua") + local chunk = loadfile(goldCache .. "/data/generated/menu_gfx.lua") + local ok, gold = pcall(chunk) + local hud = (ok and gold or {}).battleHud or {} + check(hud.playerBackFemale == nil, "and no Kris backpic") + check((hud.trainerPics or {}).KRIS == nil, "and no KRIS trainer pic") +end + +S.finish() diff --git a/tests/gen2_crystal_story_test.lua b/tests/gen2_crystal_story_test.lua new file mode 100644 index 00000000..0ac3267e --- /dev/null +++ b/tests/gen2_crystal_story_test.lua @@ -0,0 +1,555 @@ +-- The Crystal story specials -- BeastsCheck, GiveDratini, GiveOddEgg and the +-- Ilex Forest shrine pair -- plus the roamer roster difference that makes the +-- Tin Tower Suicune catchable. +-- Self-contained: `luajit tests/gen2_crystal_story_test.lua`; also dofile'd by +-- tests/run_tests.lua. The cache half SKIPs when no crystal cache is present. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 crystal story") +local check, eq = S.check, S.eq + +love = require("tests.love_stub") + +local GameVersion = require("src.core.GameVersion") +local Mon = require("src.battle.gen2.Mon") +local Roamers = require("src.core.gen2.Roamers") +local Save = require("src.core.gen2.Save") +local Specials = require("src.script.gen2.Specials") + +local H = Specials.HANDLERS +local priorVersion = GameVersion.get() +local priorRandom = Specials.random + +-- ---- fixtures ------------------------------------------------------------- + +-- pokecrystal/data/pokemon/base_stats/*.asm, the seven Odd Egg species plus +-- the Dragon Shrine's Dratini. +local BASE = { + PICHU = { 20, 40, 15, 60, 35, 35, "GROWTH_MEDIUM_FAST" }, + CLEFFA = { 50, 25, 28, 15, 45, 55, "GROWTH_FAST" }, + IGGLYBUFF = { 90, 30, 15, 15, 40, 20, "GROWTH_FAST" }, + SMOOCHUM = { 45, 30, 15, 65, 85, 65, "GROWTH_MEDIUM_FAST" }, + MAGBY = { 45, 75, 37, 83, 70, 55, "GROWTH_MEDIUM_FAST" }, + ELEKID = { 45, 63, 37, 95, 65, 55, "GROWTH_MEDIUM_FAST" }, + TYROGUE = { 35, 35, 35, 35, 35, 35, "GROWTH_MEDIUM_FAST" }, + DRATINI = { 41, 64, 45, 50, 50, 50, "GROWTH_SLOW" }, + RAIKOU = { 90, 85, 75, 115, 115, 100, "GROWTH_SLOW" }, + ENTEI = { 115, 115, 85, 100, 90, 75, "GROWTH_SLOW" }, + SUICUNE = { 100, 75, 115, 85, 90, 115, "GROWTH_SLOW" }, +} + +-- pokecrystal/data/moves/moves.asm, the PP byte of every move these two +-- routines hand out. +local MOVE_PP = { + THUNDERSHOCK = 30, CHARM = 20, DIZZY_PUNCH = 10, POUND = 35, SING = 15, + LICK = 30, EMBER = 25, QUICK_ATTACK = 30, LEER = 30, TACKLE = 35, + WRAP = 20, THUNDER_WAVE = 20, TWISTER = 20, EXTREMESPEED = 5, +} + +-- pokecrystal/data/growth_rates.asm MEDIUM_FAST, FAST and SLOW. +local GROWTH = { + GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0, + linear = 0, constant = 0 }, + GROWTH_FAST = { numerator = 4, denominator = 5, squared = 0, linear = 0, + constant = 0 }, + GROWTH_SLOW = { numerator = 5, denominator = 4, squared = 0, linear = 0, + constant = 0 }, +} + +local INDEX = { PICHU = 172, CLEFFA = 173, IGGLYBUFF = 174, SMOOCHUM = 238, + MAGBY = 240, ELEKID = 239, TYROGUE = 236, DRATINI = 147, RAIKOU = 243, + ENTEI = 244, SUICUNE = 245 } + +local DATA = { pokemon = { growthRates = GROWTH }, moves = {} } +for id, row in pairs(BASE) do + DATA.pokemon[id] = { + id = id, index = INDEX[id], name = id, + baseStats = { hp = row[1], attack = row[2], defense = row[3], + speed = row[4], specialAttack = row[5], specialDefense = row[6] }, + types = { "NORMAL", "NORMAL" }, + growthRate = row[7], + genderRatio = 127, + eggGroups = { "EGG_GROUND", "EGG_GROUND" }, + eggSteps = 20, + evolutions = {}, + levelMoves = { { level = 1, move = "TACKLE" } }, + } +end +for id, pp in pairs(MOVE_PP) do DATA.moves[id] = { id = id, pp = pp } end + +local ITEM_INDEX = { EGG_TICKET = 129, GS_BALL = 115 } + +-- The World hook table a handler sees, with only the rows these five need. +local function makeVm(opts) + opts = opts or {} + local record = opts.save + local tossed = {} + local vars = opts.vars or {} + local vm = { + scriptVar = opts.scriptVar or 0, + battleOutcome = opts.battleOutcome, + tossed = tossed, + vars = vars, + readVarFn = function(id) return vars[id] or 0 end, + writeVarFn = function(id, value) vars[id] = value end, + specials = { + save = function() return record end, + data = function() return DATA end, + party = function() return (record and record.party) or {} end, + monIndex = function(species) + local def = DATA.pokemon[species] + return def and def.index or nil + end, + monName = function(index) + for id, def in pairs(DATA.pokemon) do + if def.index == index then return def.name end + end + return nil + end, + itemIndex = function(id) return ITEM_INDEX[id] end, + takeItem = function(index, qty) + tossed[#tossed + 1] = { index = index, qty = qty } + return true + end, + }, + } + return vm +end + +local function newSave() + return { version = "crystal", player = { name = "CHRIS", id = 30000 }, + party = {}, boxes = {} } +end + +-- ---- BeastsCheck ---------------------------------------------------------- +-- pokecrystal/engine/pokemon/search_owned.asm:1 + +do + local record = newSave() + local function beast(species, opts) + opts = opts or {} + return { species = species, level = 40, + ot = opts.ot or "CHRIS", otId = opts.otId or 30000 } + end + + local vm = makeVm({ save = record }) + H.BeastsCheck(vm) + eq(vm.scriptVar, 0, "BeastsCheck is FALSE with an empty party") + + record.party = { beast("RAIKOU"), beast("ENTEI") } + H.BeastsCheck(vm) + eq(vm.scriptVar, 0, "two of three is still FALSE") + + -- CheckOwnMonAnywhere walks the PC after the party (:80 onward). + record.boxes = { box1 = { beast("SUICUNE") } } + H.BeastsCheck(vm) + eq(vm.scriptVar, 1, "the third one counts from a PC box") + + record.boxes.box1[1].otId = 9999 + H.BeastsCheck(vm) + eq(vm.scriptVar, 0, "a traded beast fails the trainer-ID check") + + record.boxes.box1[1].otId = 30000 + record.boxes.box1[1].ot = "ETHAN" + H.BeastsCheck(vm) + eq(vm.scriptVar, 0, "and a traded beast fails the OT-name check") + + record.boxes.box1[1].ot = "CHRIS" + H.BeastsCheck(vm) + eq(vm.scriptVar, 1, "all three owned answers TRUE") + eq(vm.scriptVar, 1, "and re-running it is stable") +end + +-- ---- GiveDratini ---------------------------------------------------------- +-- pokecrystal/engine/events/dratini.asm:1 + +local function dratini(nickname) + local mon = Mon.new(DATA, "DRATINI", 15, {}) + mon.nickname = nickname + mon.moves = { + { id = "WRAP", pp = 20, maxPp = 20 }, + { id = "LEER", pp = 30, maxPp = 30 }, + { id = "THUNDER_WAVE", pp = 20, maxPp = 20 }, + { id = "TWISTER", pp = 20, maxPp = 20 }, + } + return mon +end + +local function moveIds(mon) + local out = {} + for _, move in ipairs(mon.moves or {}) do out[#out + 1] = move.id end + return table.concat(out, ",") +end + +do + local record = newSave() + record.party = { { species = "CHIKORITA" }, dratini("FIRST"), + dratini("LAST") } + local vm = makeVm({ save = record, scriptVar = 0 }) + H.GiveDratini(vm) + eq(moveIds(record.party[3]), "WRAP,THUNDER_WAVE,TWISTER,EXTREMESPEED", + "scriptVar 0 gives .Moveset0, Extremespeed and all") + eq(moveIds(record.party[2]), "WRAP,LEER,THUNDER_WAVE,TWISTER", + "and only the LAST Dratini in the party is touched") + eq(record.party[3].moves[4].pp, 5, + "the new move's PP comes out of the move table") + eq(record.party[3].moves[4].maxPp, 5, "and so does its max PP") + + local plain = newSave() + plain.party = { dratini("ONLY") } + local vm2 = makeVm({ save = plain, scriptVar = 1 }) + H.GiveDratini(vm2) + eq(moveIds(plain.party[1]), "WRAP,LEER,THUNDER_WAVE,TWISTER", + "scriptVar 1 gives .Moveset1, the plain level 15 set") + + local untouched = newSave() + untouched.party = { dratini("ONLY") } + local vm3 = makeVm({ save = untouched, scriptVar = 2 }) + H.GiveDratini(vm3) + eq(moveIds(untouched.party[1]), "WRAP,LEER,THUNDER_WAVE,TWISTER", + "`cp $2 / ret nc` leaves the party alone") + + local none = newSave() + none.party = { { species = "CHIKORITA", moves = {} } } + local vm4 = makeVm({ save = none, scriptVar = 0 }) + H.GiveDratini(vm4) + eq(#none.party[1].moves, 0, "a party with no Dratini is left alone") +end + +-- ---- GiveOddEgg ----------------------------------------------------------- +-- pokecrystal/engine/events/odd_egg.asm:1 + +-- pokecrystal/data/events/odd_eggs.asm:14-33 run through the macro at :5: +-- cumulative percent * $ffff / 100. +local ODD_EGG_PROBABILITIES = { 5242, 5898, 16383, 18349, 28835, 30801, 39976, + 41287, 47840, 49151, 57015, 58326, 64879, 65535 } + +-- pokecrystal/data/events/odd_eggs.asm:37 OddEggs, row by row. +local ODD_EGG_ROWS = { + { "PICHU", 2048, { 0, 0, 0, 0 }, { "THUNDERSHOCK", "CHARM", "DIZZY_PUNCH" }, + { 17, 9, 6, 11, 8, 8 } }, + { "PICHU", 256, { 2, 10, 10, 10 }, { "THUNDERSHOCK", "CHARM", "DIZZY_PUNCH" }, + { 17, 9, 7, 12, 9, 9 } }, + { "CLEFFA", 4096, { 0, 0, 0, 0 }, { "POUND", "CHARM", "DIZZY_PUNCH" }, + { 20, 7, 7, 6, 9, 10 } }, + { "CLEFFA", 768, { 2, 10, 10, 10 }, { "POUND", "CHARM", "DIZZY_PUNCH" }, + { 20, 7, 8, 7, 10, 11 } }, + { "IGGLYBUFF", 4096, { 0, 0, 0, 0 }, { "SING", "CHARM", "DIZZY_PUNCH" }, + { 24, 8, 6, 6, 9, 7 } }, + { "IGGLYBUFF", 768, { 2, 10, 10, 10 }, { "SING", "CHARM", "DIZZY_PUNCH" }, + { 24, 8, 7, 7, 10, 8 } }, + { "SMOOCHUM", 3584, { 0, 0, 0, 0 }, { "POUND", "LICK", "DIZZY_PUNCH" }, + { 19, 8, 6, 11, 13, 11 } }, + { "SMOOCHUM", 512, { 2, 10, 10, 10 }, { "POUND", "LICK", "DIZZY_PUNCH" }, + { 19, 8, 7, 12, 14, 12 } }, + { "MAGBY", 2560, { 0, 0, 0, 0 }, { "EMBER", "DIZZY_PUNCH" }, + { 19, 12, 8, 13, 12, 10 } }, + { "MAGBY", 512, { 2, 10, 10, 10 }, { "EMBER", "DIZZY_PUNCH" }, + { 19, 12, 9, 14, 13, 11 } }, + { "ELEKID", 3072, { 0, 0, 0, 0 }, { "QUICK_ATTACK", "LEER", "DIZZY_PUNCH" }, + { 19, 11, 8, 14, 11, 10 } }, + { "ELEKID", 512, { 2, 10, 10, 10 }, { "QUICK_ATTACK", "LEER", "DIZZY_PUNCH" }, + { 19, 11, 9, 15, 12, 11 } }, + { "TYROGUE", 2560, { 0, 0, 0, 0 }, { "TACKLE", "DIZZY_PUNCH" }, + { 18, 8, 8, 8, 8, 8 } }, + { "TYROGUE", 256, { 2, 10, 10, 10 }, { "TACKLE", "DIZZY_PUNCH" }, + { 18, 8, 9, 9, 9, 9 } }, +} + +-- Specials.random(n) is 1..n, so `roll` is the Random WORD the routine reads. +local function pinRoll(roll) + Specials.random = function(n) + if n == 0x10000 then return roll + 1 end + return priorRandom(n) + end +end + +do + local wrong = {} + for index, row in ipairs(ODD_EGG_ROWS) do + local record = newSave() + -- The lowest roll that lands in this bucket: one past the previous + -- cumulative probability. + local roll = (index == 1) and 0 or (ODD_EGG_PROBABILITIES[index - 1] + 1) + pinRoll(roll) + local vm = makeVm({ save = record }) + H.GiveOddEgg(vm) + local egg = record.party[1] + local label = "row " .. index .. " (" .. row[1] .. ")" + if not egg then + wrong[#wrong + 1] = label .. ": no egg" + else + if egg.species ~= row[1] then + wrong[#wrong + 1] = label .. ": species " .. tostring(egg.species) + end + if egg.otId ~= row[2] then + wrong[#wrong + 1] = label .. ": otId " .. tostring(egg.otId) + end + local dvs = egg.dvs or {} + if dvs.attack ~= row[3][1] or dvs.defense ~= row[3][2] + or dvs.speed ~= row[3][3] or dvs.special ~= row[3][4] then + wrong[#wrong + 1] = label .. ": DVs" + end + if moveIds(egg) ~= table.concat(row[4], ",") then + wrong[#wrong + 1] = label .. ": moves " .. moveIds(egg) + end + for slot, id in ipairs(row[4]) do + if egg.moves[slot].pp ~= MOVE_PP[id] then + wrong[#wrong + 1] = label .. ": pp " .. id + end + end + local stats = egg.stats or {} + local want = row[5] + if stats.hp ~= want[1] or stats.attack ~= want[2] + or stats.defense ~= want[3] or stats.speed ~= want[4] + or stats.specialAttack ~= want[5] + or stats.specialDefense ~= want[6] then + wrong[#wrong + 1] = label .. ": stats" + end + -- The struct's stat words are the Gen 2 formula's own output; if these + -- two ever disagree the transcription above is wrong, not the formula. + local computed = Mon.stats(DATA.pokemon[row[1]].baseStats, egg.dvs, 5, {}) + if computed.hp ~= want[1] or computed.attack ~= want[2] + or computed.defense ~= want[3] or computed.speed ~= want[4] + or computed.specialAttack ~= want[5] + or computed.specialDefense ~= want[6] then + wrong[#wrong + 1] = label .. ": Mon.stats disagrees with the table" + end + end + end + eq(table.concat(wrong, " | "), "", + "all 14 OddEggs rows come out byte for byte") + + -- The bucket boundary is inclusive: `cp e / jr z, .done`. + pinRoll(ODD_EGG_PROBABILITIES[1]) + local edge = newSave() + H.GiveOddEgg(makeVm({ save = edge })) + eq(edge.party[1].otId, 2048, "a roll EQUAL to the probability stays in row 1") + pinRoll(ODD_EGG_PROBABILITIES[1] + 1) + local over = newSave() + H.GiveOddEgg(makeVm({ save = over })) + eq(over.party[1].otId, 256, "and one past it falls into row 2") + + -- The last entry is $ffff, which is the loop's own break: every roll above + -- the 99% mark lands there. + pinRoll(0xffff) + local last = newSave() + H.GiveOddEgg(makeVm({ save = last })) + eq(last.party[1].species, "TYROGUE", "the $ffff row takes the top of the range") + eq(last.party[1].otId, 256, "and it is the shiny Tyrogue") +end + +do + pinRoll(0) + local record = newSave() + local vm = makeVm({ save = record }) + H.GiveOddEgg(vm) + local egg = record.party[1] + eq(egg.isEgg, true, "the Odd Egg arrives as an EGG") + eq(egg.nickname, "EGG", "wOddEggName is its nickname") + eq(egg.ot, "ODD", "and wTempOddEggNickname is its OT name") + eq(egg.otName, "ODD", "on both OT fields") + eq(egg.otId, 2048, "the OT ID is the struct's, not the player's") + eq(egg.eggSteps, 20, "20 step cycles to hatch") + eq(egg.hp, 0, "an egg is carried at zero HP") + eq(egg.level, 5, "at level 5") + eq(egg.experience, 125, + "and with the struct's 125 exp, not the curve's own value") + eq(#vm.tossed, 1, "the EGG TICKET is tossed") + eq(vm.tossed[1].index, 129, "by its item index") + eq(vm.tossed[1].qty, 1, "one of them") + + -- CLEFFA is GROWTH_FAST, so 125 is more than level 5 costs: the copied + -- struct really does overshoot the curve. + pinRoll(ODD_EGG_PROBABILITIES[2] + 1) + local cleffa = newSave() + H.GiveOddEgg(makeVm({ save = cleffa })) + eq(cleffa.party[1].species, "CLEFFA", "the Cleffa bucket") + eq(cleffa.party[1].experience, 125, "carries 125 exp too") + eq(Mon.experienceForLevel(GROWTH.GROWTH_FAST, 5), 100, + "even though its own curve wants 100") + + -- The second row of every pair is the classic shiny DV set. + pinRoll(ODD_EGG_PROBABILITIES[1] + 1) + local shiny = newSave() + H.GiveOddEgg(makeVm({ save = shiny })) + eq(shiny.party[1].shiny, true, "the 2/10/10/10 rows are shiny") + + -- pokecrystal/maps/DayCare.asm:31-32 is the party-full guard, so a full + -- party must not grow a seventh slot here. + local full = newSave() + for i = 1, 6 do full.party[i] = { species = "CHIKORITA" } end + local vmFull = makeVm({ save = full }) + H.GiveOddEgg(vmFull) + eq(#full.party, 6, "a full party gets no Odd Egg") + eq(#vmFull.tossed, 0, "and keeps its EGG TICKET") +end + +Specials.random = priorRandom + +-- ---- the Ilex Forest shrine ----------------------------------------------- +-- pokecrystal/engine/events/celebi.asm:9 and :301 + +do + local record = newSave() + local vm = makeVm({ save = record }) + H.CelebiShrineEvent(vm) + eq(vm.vars[0x03], 11, + "CelebiShrineEvent leaves BATTLETYPE_CELEBI in VAR_BATTLETYPE") + eq(vm.celebiArmed, true, "and arms the result bit for the battle to come") + + vm.battleOutcome = "caught" + H.CheckCaughtCelebi(vm) + eq(vm.scriptVar, 1, "catching it in that battle answers TRUE") + eq(Save.crystalState(record).celebiCaught, true, "and is recorded") + eq(vm.celebiArmed, nil, "the arming is one battle long") + + local ran = newSave() + local vm2 = makeVm({ save = ran, battleOutcome = "run" }) + H.CelebiShrineEvent(vm2) + H.CheckCaughtCelebi(vm2) + eq(vm2.scriptVar, 0, "running from it answers FALSE") + eq(Save.crystalState(ran).celebiCaught, false, "and records nothing") + + local won = newSave() + local vm3 = makeVm({ save = won, battleOutcome = "win" }) + H.CelebiShrineEvent(vm3) + H.CheckCaughtCelebi(vm3) + eq(vm3.scriptVar, 0, "and knocking it out answers FALSE") + + -- pokecrystal/engine/items/item_effects.asm:542 gates the bit on the battle + -- type, so an ordinary catch never sets it. + local other = newSave() + local vm4 = makeVm({ save = other, battleOutcome = "caught" }) + H.CheckCaughtCelebi(vm4) + eq(vm4.scriptVar, 0, "a catch in a NORMAL battle does not count") + eq(Save.crystalState(other).celebiCaught, false, "and records nothing") +end + +-- ---- the beasts that roam ------------------------------------------------- +-- pokegold/data/wild/flee_mons.asm:34 against pokecrystal's:34 + +do + GameVersion.set("gold") + eq(Roamers.ALWAYS_FLEE.RAIKOU, true, "Gold: Raikou always flees") + eq(Roamers.ALWAYS_FLEE.ENTEI, true, "Gold: Entei always flees") + eq(Roamers.ALWAYS_FLEE.SUICUNE, true, "Gold: Suicune always flees") + + GameVersion.set("silver") + eq(Roamers.ALWAYS_FLEE.SUICUNE, true, "Silver rides the same list") + + GameVersion.set("crystal") + eq(Roamers.ALWAYS_FLEE.RAIKOU, true, "Crystal keeps Raikou on the list") + eq(Roamers.ALWAYS_FLEE.ENTEI, true, "and Entei") + eq(Roamers.ALWAYS_FLEE.SUICUNE, nil, + "but drops Suicune, so the Tin Tower battle can be fought") + eq(Roamers.ALWAYS_FLEE.PIDGEY, nil, "a mon on no list still never flees") + + eq(Roamers.alwaysFleeMons("gold").SUICUNE, true, + "the accessor answers per version id") + eq(Roamers.alwaysFleeMons("crystal").SUICUNE, nil, "independently of the boot") + GameVersion.set(priorVersion) +end + +-- Crystal seeds two roam structs, so the third slot the encounter roll can +-- pick is always empty -- pokecrystal/engine/overworld/wildmons.asm:493. +do + local twoRow = { roamMons = { + { species = "RAIKOU", level = 40, map = "ROUTE_42" }, + { species = "ENTEI", level = 40, map = "ROUTE_37" }, + } } + local record = { version = "crystal" } + Roamers.init(record, { encounters = twoRow }) + eq(#record.roamers, 2, "a Crystal cache seeds two roamers") + eq(record.roamers[1].species, "RAIKOU", "Raikou in slot 1") + eq(record.roamers[2].species, "ENTEI", "Entei in slot 2") + eq(record.roamers[3], nil, "and nothing in Suicune's old slot") + + -- CheckEncounterRoamMon's `and %11 / jr z / dec a` picks slot 1, 2 or 3. + local hits = { 0, 0, 0 } + for value = 0, 99 do + local hit = Roamers.checkEncounter(record, "ROUTE_42", false, + function() return value end) + if hit then hits[hit.index] = hits[hit.index] + 1 end + end + check(hits[1] > 0, "slot 1 is still reachable on its own route") + eq(hits[3], 0, "slot 3 never produces an encounter on Crystal") +end + +-- ------------------------------------------------------------ cache-gated + +local cache = os.getenv("CRYSTAL_CACHE") +if not cache then + local home = os.getenv("HOME") or "" + for _, dir in ipairs({ "crystal-dev", "lead-card" }) do + local candidate = home .. "/Library/Application Support/LOVE/" .. dir + .. "/crystal" + local probe = io.open(candidate .. "/data/generated/constants.lua", "r") + if probe then + probe:close() + cache = candidate + break + end + end +end + +local constantsFile = cache and io.open(cache .. "/data/generated/constants.lua", + "r") +if not constantsFile then + check(true, "crystal cache absent : SKIP") + S.finish() + return +end +constantsFile:close() + +local function loadLua(rel) + local chunk = loadfile(cache .. "/" .. rel) + if not chunk then return nil end + local ok, value = pcall(chunk) + return ok and value or nil +end + +local constants = loadLua("data/generated/constants.lua") +local scripts = loadLua("data/generated/scripts.lua") +local encounters = loadLua("data/generated/encounters.lua") +check(constants ~= nil and scripts ~= nil, "the crystal cache loads") + +-- Every handler above has to be a name the cache's own SpecialsPointers row +-- resolves to, and one an extracted script really calls. +local order = constants.specialOrder or {} +local calls = {} +for _, list in pairs(scripts) do + if type(list) == "table" then + for _, row in ipairs(list) do + if type(row) == "table" and row.op == "special" then + local name = order[(row.id or 0) + 1] + if name then calls[name] = (calls[name] or 0) + 1 end + end + end + end +end + +for _, name in ipairs({ "BeastsCheck", "GiveDratini", "GiveOddEgg", + "CelebiShrineEvent", "CheckCaughtCelebi" }) do + check(Specials.HANDLERS[name] ~= nil, name .. " is a handler, not a stub") + check((calls[name] or 0) > 0, + name .. " is called by an extracted Crystal script") +end + +-- pokecrystal/mobile/mobile_46.asm:6793: TradeCornerHoldMon lives in the +-- Mobile System GB bank and no map script reaches it. +eq(calls.TradeCornerHoldMon, nil, + "TradeCornerHoldMon is never called from a script") +check(Specials.STUBS.TradeCornerHoldMon ~= nil, + "so it stays a documented stub") + +eq(#(encounters and encounters.roamMons or {}), 2, + "the Crystal cache carries two roamMons rows") +local roamNames = {} +for _, row in ipairs((encounters and encounters.roamMons) or {}) do + roamNames[#roamNames + 1] = row.species +end +eq(table.concat(roamNames, ","), "RAIKOU,ENTEI", + "Raikou and Entei, with Suicune off the roam list") + +S.finish() diff --git a/tests/gen2_crystal_townmap_test.lua b/tests/gen2_crystal_townmap_test.lua new file mode 100644 index 00000000..d1e9b812 --- /dev/null +++ b/tests/gen2_crystal_townmap_test.lua @@ -0,0 +1,421 @@ +-- The wall map, the printed diploma and the memory game: the three specials +-- both Gen 2 carts carry that had no port half (engine/events/specials.asm:100 +-- OverworldTownMap, :448 PrintDiploma, :207 UnusedMemoryGame). +-- +-- OverworldTownMap has two callers. TownMapScript +-- (engine/events/std_scripts.asm:145) is registered but no map jumpstd's it on +-- either cart; DecorationDesc_TownMapPoster +-- (engine/overworld/decorations.asm:1005) is the live one, and InitDecorations +-- (engine/overworld/decorations.asm:1, run from +-- engine/menus/intro_menu.asm:133) hangs DECO_TOWN_MAP on the bedroom wall at +-- new game, so a silent no-op here is a dead end in the room the player starts +-- in. +-- luajit tests/gen2_crystal_townmap_test.lua +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 crystal town map") +local check, eq = S.check, S.eq + +love = require("tests.love_stub") +require("src.core.Logger").warn = function() end + +local Events = require("src.world.gen2.Events") +local Pokegear = require("src.ui.gen2.Pokegear") +local Specials = require("src.script.gen2.Specials") +local Vm = require("src.script.gen2.Vm") + +local H = Specials.HANDLERS + +local function fakeInput() + local pressed = {} + return { + press = function(_self, button) pressed[button] = true end, + wasPressed = function(_self, button) + if pressed[button] then + pressed[button] = nil + return true + end + return false + end, + } +end + +-- landmarks.lua's shape. ../pokecrystal/constants/landmark_constants.asm:34 +-- inserts LANDMARK_BATTLE_TOWER, so every index from ROUTE_40 up is one higher +-- than pokegold/constants/landmark_constants.asm gives it; both index spaces +-- are built here because the two limit registers are read out of this table. +local function landmarkTable(rows) + local out = { landmarks = {}, order = {} } + for id, index in pairs(rows) do + out.landmarks[id] = { + id = id, index = index, + name = id:gsub("^LANDMARK_", ""):gsub("_", " "), x = 8, y = 8, + } + out.order[index + 1] = id + end + return out +end + +local GOLD_LANDMARKS = landmarkTable({ + LANDMARK_NEW_BARK_TOWN = 0x01, + LANDMARK_ROUTE_29 = 0x02, + LANDMARK_SILVER_CAVE = 0x2d, + LANDMARK_PALLET_TOWN = 0x2e, + LANDMARK_VICTORY_ROAD = 0x57, + LANDMARK_ROUTE_28 = 0x5d, + LANDMARK_FAST_SHIP = 0x5e, +}) + +local CRYSTAL_LANDMARKS = landmarkTable({ + LANDMARK_NEW_BARK_TOWN = 0x01, + LANDMARK_ROUTE_29 = 0x02, + LANDMARK_BATTLE_TOWER = 0x1d, + LANDMARK_SILVER_CAVE = 0x2e, + LANDMARK_PALLET_TOWN = 0x2f, + LANDMARK_VICTORY_ROAD = 0x58, + LANDMARK_ROUTE_28 = 0x5e, + LANDMARK_FAST_SHIP = 0x5f, +}) + +local function townMapScreen(opts) + opts = opts or {} + local input = fakeInput() + local save = opts.save or {} + local popped = 0 + local game = { + input = input, + save = save, + stack = { pop = function() popped = popped + 1 end }, + } + local closed = 0 + local screen = Pokegear.new(game, { + save = save, + landmarks = opts.landmarks or CRYSTAL_LANDMARKS, + currentLandmark = opts.currentLandmark or "LANDMARK_NEW_BARK_TOWN", + townMap = true, + onClose = function() closed = closed + 1 end, + }) + return screen, input, + function() return closed end, function() return popped end +end + +-- ================================================= 1. the screen's own mode +-- +-- _TownMap (engine/pokegear/pokegear.asm:1709) reuses PokegearMap for the art +-- and nothing else off the card: no strip, no ENGINE_MAP_CARD test. +do + local screen = townMapScreen() + eq(#screen.cards, 1, "the poster is one screen, not the card strip") + eq(screen.mode, "card", "and it opens straight onto the map") + eq(screen:card().id, "map", "on the MAP card's own art") + eq(screen.fly, nil, "and it is not the fly picker") +end + +-- The card gate is _FlyMap's absence, not the gear's: a player who has never +-- met the Guide Gent still reads the poster in their bedroom. +do + local screen = townMapScreen({ save = { engineFlags = {} } }) + eq(#screen.cards, 1, "no ENGINE_MAP_CARD is needed to open it") +end + +-- ================================================= 2. the joypad +-- +-- `.loop` (engine/pokegear/pokegear.asm:1783): PAD_B returns, PAD_UP and +-- PAD_DOWN walk wTownMapCursorLandmark, and nothing else is read at all. +do + local screen, input, closed, popped = townMapScreen() + local first = CRYSTAL_LANDMARKS.landmarks.LANDMARK_NEW_BARK_TOWN.index + local last = CRYSTAL_LANDMARKS.landmarks.LANDMARK_SILVER_CAVE.index + eq(screen:mapCursorIndex(), first, "the cursor starts on the player's own") + + input:press("up") + screen:update(0) + eq(screen:mapCursorIndex(), first + 1, "UP steps to the next landmark") + input:press("down") + screen:update(0) + eq(screen:mapCursorIndex(), first, "DOWN steps back") + + -- `cp e / jr nz, .wrap_around_down`: only the first landmark wraps. + input:press("down") + screen:update(0) + eq(screen:mapCursorIndex(), last, "and wraps off the first onto the last") + -- `cp d / jr c, .wrap_around_up`. + input:press("up") + screen:update(0) + eq(screen:mapCursorIndex(), first, "and off the last back onto the first") + + -- Left, right and A are never read by _TownMap's loop. + local held = screen:mapCursorIndex() + for _, button in ipairs({ "left", "right", "a", "start", "select" }) do + input:press(button) + screen:update(0) + end + eq(screen:mapCursorIndex(), held, "no other button moves the cursor") + eq(closed(), 0, "and none of them leaves the map") + + input:press("b") + screen:update(0) + eq(closed(), 1, "B returns from the map") + eq(popped(), 1, "and the screen the special pushed comes back off the stack") + + -- The pop already happened; a second B must not take the overworld with it. + input:press("b") + screen:update(0) + eq(popped(), 1, "a second B pops nothing") +end + +-- ================================================= 3. the limit registers +-- +-- `ld d, KANTO_LANDMARK - 1 / ld e, 1` for Johto and +-- TownMap_GetKantoLandmarkLimits (engine/pokegear/pokegear.asm:714) for Kanto. +-- Both are LANDMARK indices, and Crystal's are not Gold's. +do + local crystal = townMapScreen({ landmarks = CRYSTAL_LANDMARKS }) + local last, first = crystal:cursorLimits() + eq(first, 0x01, "Johto's e is NEW BARK TOWN either way round") + eq(last, 0x2e, "and its d is Crystal's own SILVER CAVE, not Gold's $2d") + + local gold = townMapScreen({ landmarks = GOLD_LANDMARKS }) + local goldLast = gold:cursorLimits() + eq(goldLast, 0x2d, "a Gold landmark table still answers $2d") + + -- A dataset with no landmark records at all keeps the pokegold numbers. + local bare = townMapScreen({ landmarks = { landmarks = {}, order = {} } }) + local bareLast, bareFirst = bare:cursorLimits() + eq(bareLast, 0x2d, "and so does a dataset with no records") + eq(bareFirst, 0x01, "with NEW BARK TOWN still the floor") +end + +-- `cp KANTO_LANDMARK` is the region test, and KANTO_LANDMARK is PALLET_TOWN's +-- own index: $2e on Gold, $2f on Crystal. The byte $2e therefore reads as +-- Kanto on one cart and Johto (SILVER CAVE) on the other. +do + local silver = townMapScreen({ + landmarks = CRYSTAL_LANDMARKS, currentLandmark = "LANDMARK_SILVER_CAVE", + }) + eq(silver:region(), "johto", "Crystal's $2e is SILVER CAVE, so Johto") + local pallet = townMapScreen({ + landmarks = CRYSTAL_LANDMARKS, currentLandmark = "LANDMARK_PALLET_TOWN", + }) + eq(pallet:region(), "kanto", "and its $2f is PALLET TOWN, so Kanto") + local goldPallet = townMapScreen({ + landmarks = GOLD_LANDMARKS, currentLandmark = "LANDMARK_PALLET_TOWN", + }) + eq(goldPallet:region(), "kanto", "Gold's PALLET TOWN is Kanto at $2e") + -- LANDMARK_FAST_SHIP sits past every Kanto landmark and is still Johto. + local ship = townMapScreen({ + landmarks = CRYSTAL_LANDMARKS, currentLandmark = "LANDMARK_FAST_SHIP", + }) + eq(ship:region(), "johto", "and the S.S. Aqua stays Johto at $5f") +end + +-- Kanto's pair, with and without the Hall of Fame on the record. +do + local before = townMapScreen({ + landmarks = CRYSTAL_LANDMARKS, currentLandmark = "LANDMARK_PALLET_TOWN", + save = { flags = {} }, + }) + local lastB, firstB = before:cursorLimits() + eq(firstB, 0x58, "before the Hall of Fame Kanto starts at VICTORY ROAD") + eq(lastB, 0x5e, "and ends at ROUTE 28") + local after = townMapScreen({ + landmarks = CRYSTAL_LANDMARKS, currentLandmark = "LANDMARK_PALLET_TOWN", + save = { flags = { HALL_OF_FAME = true } }, + }) + local _, firstA = after:cursorLimits() + eq(firstA, 0x2f, "and afterwards the whole region opens at PALLET TOWN") +end + +-- ================================================= 4. the tilemap frame +-- +-- _TownMap.InitTilemap (engine/pokegear/pokegear.asm:1843): the rule turns down +-- at (7,0) and runs back out along row 2, which is what boxes the name plate +-- into the top right corner with no card strip above it. +do + local screen = townMapScreen() + local laid = {} + screen.tile = function(_self, id, tx, ty) + laid[tx .. "," .. ty] = id + end + screen:drawTownMapRule() + + eq(laid["0,0"], 0x06, "$06 caps the rule at (0,0)") + local run = true + for x = 1, 6 do run = run and laid[x .. ",0"] == 0x07 end + check(run, "$07 runs (1,0) to (6,0), the `ld bc, 6` ByteFill") + eq(laid["7,0"], 0x17, "$17 turns it down at (7,0)") + eq(laid["7,1"], 0x16, "$16 carries it through (7,1)") + eq(laid["7,2"], 0x26, "$26 turns it back out at (7,2)") + local tail = true + for x = 8, 18 do tail = tail and laid[x .. ",2"] == 0x07 end + check(tail, "$07 runs (8,2) to (18,2), the NAME_LENGTH ByteFill") + eq(laid["19,2"], 0x17, "$17 caps it at (19,2)") + eq(laid["8,1"], nil, "and nothing is laid inside the name plate") + + -- The MAP card's own rule is the other shape: a full-width bar under the + -- two-row card strip. The poster must not draw that. + local card = Pokegear.new({ input = fakeInput(), save = {} }, { + save = { pokegearFlags = { map = true } }, + landmarks = CRYSTAL_LANDMARKS, + currentLandmark = "LANDMARK_NEW_BARK_TOWN", + }) + eq(card.townMap, nil, "the POKeGEAR card is not in town-map mode") + check(#card.cards > 1, "and it keeps its card strip") +end + +-- Drawing with no town-map art at all: a crash check on the unstyled fallback. +do + local screen = townMapScreen() + check(not screen:styled(), "this harness has no gear sheet") + check(pcall(function() screen:draw() end), "the poster still draws") +end + +-- ================================================= 5. the specials +-- +-- World:specialHooks, stubbed down to what these two reach for. +local function newHooks(opts) + opts = opts or {} + local log = { pushed = {}, diploma = 0 } + local hooks = { + log = log, + save = function() return opts.save or {} end, + data = function() return {} end, + party = function() return {} end, + } + if not opts.noScreens then + hooks.pushScreen = function(id, screenOpts) + log.pushed[#log.pushed + 1] = { id = id, opts = screenOpts } + if opts.pushFails then return false end + log.pending = screenOpts.onClose + if not opts.hold and screenOpts.onClose then screenOpts.onClose() end + return true + end + hooks.showDiploma = function(onDone) + log.diploma = log.diploma + 1 + log.pending = onDone + if not opts.hold and onDone then onDone() end + end + end + return hooks +end + +local function newVm(hooks, scriptVar) + local vm = Vm.new({}, {}, Events.new(), { specials = hooks }) + vm.showTextFn = function() end + vm.scriptVar = scriptVar or 0 + return vm +end + +-- Run a handler to its first park (or to the end), the way the VM runs it. +local function run(vm, handler) + local co = coroutine.create(function() handler(vm) end) + vm.co = co + local ok, req = coroutine.resume(co) + if not ok then error(req, 0) end + return co, req +end + +-- ---- OverworldTownMap ------------------------------------------------------ + +check(H.OverworldTownMap ~= nil, "OverworldTownMap has a handler") +eq(Specials.STUBS.OverworldTownMap, nil, "and is no longer a stub") +check(Specials.SUPERSEDED_STUBS.OverworldTownMap ~= nil, + "its old reason moved to the superseded ledger") + +do + local hooks = newHooks() + local vm = newVm(hooks, 7) + local co = run(vm, H.OverworldTownMap) + eq(coroutine.status(co), "dead", "an answering screen runs it straight out") + eq(#hooks.log.pushed, 1, "one screen is pushed") + eq(hooks.log.pushed[1].id, "Gen2Pokegear", "which is the POKeGEAR's own map") + eq(hooks.log.pushed[1].opts.townMap, true, "in _TownMap's chrome-free mode") + -- FadeToMenu / farcall _TownMap / ExitAllMenus writes no wScriptVar, and the + -- callers' next op is a bare `closetext`. + eq(vm.scriptVar, 7, "and wScriptVar is left exactly as `special` found it") +end + +-- The block is real: the coroutine parks until the screen answers. +do + local hooks = newHooks({ hold = true }) + local vm = newVm(hooks, 0) + local co = run(vm, H.OverworldTownMap) + eq(coroutine.status(co), "suspended", "the script waits on the map") + hooks.log.pending() + eq(coroutine.status(co), "dead", "and only B off the map resumes it") +end + +-- A run with no screen to push (a headless probe) must still not fall through +-- into the next command with a stale wScriptVar. +do + local hooks = newHooks({ pushFails = true }) + local vm = newVm(hooks, 3) + local co = run(vm, H.OverworldTownMap) + eq(coroutine.status(co), "dead", "a refused push does not hang the script") + eq(vm.scriptVar, 3, "and still leaves wScriptVar alone") +end +do + local hooks = newHooks({ noScreens = true }) + local vm = newVm(hooks, 3) + local co = run(vm, H.OverworldTownMap) + eq(coroutine.status(co), "dead", "and neither does no pushScreen hook") + eq(vm.scriptVar, 3, "with wScriptVar untouched") +end + +-- ---- PrintDiploma ---------------------------------------------------------- + +check(H.PrintDiploma ~= nil, "PrintDiploma has a handler") +eq(Specials.STUBS.PrintDiploma, nil, "and is no longer a stub") +check(Specials.SUPERSEDED_STUBS.PrintDiploma ~= nil, + "its printer reason moved to the superseded ledger") + +do + local hooks = newHooks() + local vm = newVm(hooks, 5) + local co = run(vm, H.PrintDiploma) + eq(coroutine.status(co), "dead", "the page runs out") + eq(hooks.log.diploma, 1, "PlaceDiplomaOnScreen is the routine's first act") + eq(vm.scriptVar, 5, "and _PrintDiploma writes no wScriptVar either") +end + +do + local hooks = newHooks({ hold = true }) + local vm = newVm(hooks, 0) + local co = run(vm, H.PrintDiploma) + eq(coroutine.status(co), "suspended", "the script waits on the page") + hooks.log.pending() + eq(coroutine.status(co), "dead", "and the dismissal resumes it") +end + +do + local hooks = newHooks({ noScreens = true }) + local vm = newVm(hooks, 4) + local co = run(vm, H.PrintDiploma) + eq(coroutine.status(co), "dead", "no diploma hook is not a hang") + eq(vm.scriptVar, 4, "and wScriptVar survives") +end + +-- ---- UnusedMemoryGame ------------------------------------------------------ +-- +-- DELIBERATELY UNREACHABLE, and not a Crystal gap: `add_special +-- UnusedMemoryGame ; unused` is the row in BOTH carts +-- (pokegold data/events/special_pointers.asm:63, +-- ../pokecrystal/data/events/special_pointers.asm:58) and no script bytecode +-- in either ROM points at it. _MemoryGame (engine/games/memory_game.asm) is a +-- 590-line Game Corner screen with its own board, cursor sprite anim and coin +-- payout, so porting it would be a whole new screen reachable by nobody. +-- UnusedDummySpecial next to it is a bare `ret` and IS ported, because that +-- costs one line; this one is not, and stays a stub with its reason recorded. +check(Specials.STUBS.UnusedMemoryGame ~= nil, + "UnusedMemoryGame stays a stub: no cart script reaches it") +check(type(Specials.STUB_REASONS.UnusedMemoryGame) == "string", + "and it still says why") +-- engine/events/specials.asm:207 is CheckCoinsAndCoinCase then +-- StartGameCornerGame, neither of which writes wScriptVar. +do + local vm = newVm(newHooks(), 9) + Specials.STUBS.UnusedMemoryGame(vm) + eq(vm.scriptVar, 9, "and leaves wScriptVar alone, as the routine does") +end + +S.finish() diff --git a/tests/gen2_crystal_vars_test.lua b/tests/gen2_crystal_vars_test.lua new file mode 100644 index 00000000..9944a0b5 --- /dev/null +++ b/tests/gen2_crystal_vars_test.lua @@ -0,0 +1,401 @@ +-- The six .VarActionTable rows Crystal appends past VAR_SPECIALPHONECALL, and +-- TryQuickSave -- the save Link_SaveGame runs behind the Battle Tower desk. +-- ROM-free: +-- luajit tests/gen2_crystal_vars_test.lua +-- +-- ../pokecrystal/constants/script_constants.asm:69-74, +-- ../pokecrystal/engine/overworld/variables.asm:62-67, +-- engine/link/link.asm:2356 and engine/menus/save.asm:63. +-- +-- The point of the World half is that these reads and writes land in the SAVE. +-- World.scriptVars is rebuilt empty on every map load (src/world/gen2/World.lua +-- :579), so a var parked there is a var Buena's Blue Card loses the moment the +-- player walks out of the Radio Tower. +package.path = "./?.lua;./?/init.lua;" .. package.path + +love = love or {} +love.graphics = love.graphics or { + getColor = function() return 1, 1, 1, 1 end, + setColor = function() end, + rectangle = function() end, + print = function() end, + printf = function() end, + draw = function() end, + newQuad = function() return {} end, + newImage = function() return nil end, + getShader = function() return nil end, + setShader = function() end, + newShader = function() error("no shaders in this harness") end, + getDimensions = function() return 160, 144 end, + push = function() end, pop = function() end, + translate = function() end, scale = function() end, + circle = function() end, clear = function() end, +} +love.math = love.math or { random = function(a, b) return b and a or 1 end } +love.image = love.image or {} +love.filesystem = love.filesystem or { + load = function() return nil end, + getInfo = function() return nil end, + read = function() return nil end, +} +love.timer = love.timer or { getTime = function() return 0 end } + +local S = require("tests.harness").suite("gen2 crystal vars") +local check, eq = S.check, S.eq + +require("src.core.Logger").warn = function() end + +local Apricorns = require("src.core.gen2.Apricorns") +local Events = require("src.world.gen2.Events") +local Save = require("src.core.gen2.Save") +local SaveSerializer = require("src.core.SaveSerializer") +local Specials = require("src.script.gen2.Specials") +local Vm = require("src.script.gen2.Vm") +local World = require("src.world.gen2.World") + +local H = Specials.HANDLERS + +-- ../pokecrystal/constants/script_constants.asm:69-74. +local VAR_BT_WIN_STREAK = 0x15 +local VAR_KURT_APRICORNS = 0x16 +local VAR_CALLERID = 0x17 +local VAR_BLUECARDBALANCE = 0x18 +local VAR_BUENASPASSWORD = 0x19 +local VAR_KENJI_BREAK = 0x1a + +-- ---------------------------------------------------------------- fixtures + +local DATA = { + audio = {}, + items = { + LEVEL_BALL = { index = 5, name = "LEVEL BALL" }, + RED_APRICORN = { index = 78, name = "RED APRICORN" }, + BLU_APRICORN = { index = 79, name = "BLU APRICORN" }, + }, + pokemon = {}, + moves = {}, +} + +local function newSave() + local save = { + version = "crystal", + player = { name = "KRIS", id = 0x1234, money = 0 }, + party = {}, + inventory = {}, + bagOrder = {}, + boxes = {}, + } + Save.crystalState(save) + Save.battleTowerState(save) + return save +end + +local function newWorld(save) + local game = { data = DATA, save = save, stack = nil } + local world = World.new(game) + world.vm = { curPhoneCaller = 0 } + return world, game +end + +-- ============================================ the ids, against the cart's own +do + local save = newSave() + local world = newWorld(save) + -- .VarActionTable is walked by index, so a row in the wrong slot reads a + -- different variable. Pin the six by writing a distinct value into each + -- store and reading it back through the id the script byte carries. + Save.battleTowerState(save).streak = 4 + save.kurtApricornQuantity = 3 + world.vm.curPhoneCaller = 11 + Save.crystalState(save).buenaPassword.balance = 17 + Save.crystalState(save).buenaPassword.word = 0x52 + Save.crystalState(save).kenjiBreak = 5 + + eq(world:readVar(VAR_BT_WIN_STREAK), 4, "$15 is wNrOfBeatenBattleTowerTrainers") + eq(world:readVar(VAR_KURT_APRICORNS), 3, "$16 is wKurtApricornQuantity") + eq(world:readVar(VAR_CALLERID), 11, "$17 is wCurCaller") + eq(world:readVar(VAR_BLUECARDBALANCE), 17, "$18 is wBlueCardBalance") + eq(world:readVar(VAR_BUENASPASSWORD), 0x52, "$19 is wBuenasPassword") + eq(world:readVar(VAR_KENJI_BREAK), 5, "$1a is wKenjiBreakTimer") + -- NUM_VARS' own guard: nothing above the table answers with a neighbour. + eq(world:readVar(0x1b), 0, "$1b is past the table and reads 0") +end + +-- ================================================= the three writable rows +-- +-- ../pokecrystal/engine/overworld/variables.asm:21-25: only a RETVAR_ADDR_DE +-- row hands `Script_writevar` the variable's own address. The other three +-- rows write wStringBuffer2, which nothing reads back. +do + local save = newSave() + local world = newWorld(save) + + world:writeVar(VAR_BLUECARDBALANCE, 6) + eq(Save.crystalState(save).buenaPassword.balance, 6, + "writevar VAR_BLUECARDBALANCE lands in the save") + eq(world:readVar(VAR_BLUECARDBALANCE), 6, "and reads straight back") + + world:writeVar(VAR_BUENASPASSWORD, 0x31) + eq(Save.crystalState(save).buenaPassword.word, 0x31, + "writevar VAR_BUENASPASSWORD lands in the save") + + world:writeVar(VAR_CALLERID, 9) + eq(world.vm.curPhoneCaller, 9, "loadvar VAR_CALLERID parks wCurCaller") + eq(world:readVar(VAR_CALLERID), 9, "and the callee scripts read it back") + + Save.battleTowerState(save).streak = 2 + world:writeVar(VAR_BT_WIN_STREAK, 99) + eq(Save.battleTowerState(save).streak, 2, + "a RETVAR_STRBUF2 row is not writable") + eq(world:readVar(VAR_BT_WIN_STREAK), 2, "so the streak is unchanged") + + -- One byte wide, `ld [de], a`. + world:writeVar(VAR_BLUECARDBALANCE, 300) + eq(world:readVar(VAR_BLUECARDBALANCE), 300 % 256, "the store is one byte") +end + +-- =========================================== they survive a save and a reload +-- +-- The whole bug: scriptVars is rebuilt empty at World.lua:579 and never +-- serialized, so a balance parked there is gone by the next map. +do + local save = newSave() + local world = newWorld(save) + world:writeVar(VAR_BLUECARDBALANCE, 12) + world:writeVar(VAR_BUENASPASSWORD, 0x24) + + eq(world.scriptVars[VAR_BLUECARDBALANCE], 12, + "the scratch mirror still holds it in this session") + world.scriptVars = {} + eq(world:readVar(VAR_BLUECARDBALANCE), 12, + "and a map load that clears the mirror does not lose it") + + local reloaded = SaveSerializer.decode(SaveSerializer.encode(save)) + Save.normalize(reloaded) + local second = newWorld(reloaded) + eq(second:readVar(VAR_BLUECARDBALANCE), 12, "a reload still has the balance") + eq(second:readVar(VAR_BUENASPASSWORD), 0x24, "and the day's password") +end + +-- ============================ ../pokecrystal/maps/RadioTower2F.asm:144-146 +-- +-- readvar VAR_BLUECARDBALANCE / addval 1 / writevar VAR_BLUECARDBALANCE +-- +-- run through the VM against a real World, which is the round trip the gate +-- caught reading `blue card balance: 0`. +do + local save = newSave() + local world = newWorld(save) + local vm = Vm.new({}, {}, Events.new(), { + readVar = function(id) return world:readVar(id) end, + writeVar = function(id, value) world:writeVar(id, value) end, + }) + local function awardPoint() + vm:start({ + { op = "readvar", var = VAR_BLUECARDBALANCE }, + { op = "addval", args = { 1 } }, + { op = "writevar", var = VAR_BLUECARDBALANCE }, + }) + local guard = 0 + while vm:running() and guard < 64 do + vm:update() + guard = guard + 1 + end + check(not vm:running(), "the three rows ran to the end") + end + awardPoint() + eq(world:readVar(VAR_BLUECARDBALANCE), 1, "a correct password is worth 1") + awardPoint() + awardPoint() + eq(Save.crystalState(save).buenaPassword.balance, 3, + "three shows is three points, in the save") +end + +-- ======================================= Kurt's quantity, the other dead var +-- +-- ../pokecrystal/maps/KurtsHouse.asm:197 is +-- `verbosegiveitemvar LEVEL_BALL, VAR_KURT_APRICORNS`, so a var that reads 0 +-- hands over nothing at all. +do + local save = newSave() + save.inventory = { RED_APRICORN = 1 } + local world = newWorld(save) + local quantities = {} + local hooks = { + save = function() return save end, + data = function() return DATA end, + itemName = function(id) return (DATA.items[id] or {}).name or id end, + itemIndex = function(id) return (DATA.items[id] or {}).index end, + setKurtApricornQuantity = function(n) + quantities[#quantities + 1] = n + world:setKurtApricornQuantity(n) + end, + scriptMenu = function(_header, onChoose) onChoose(1) end, + } + local vm = Vm.new({}, {}, Events.new(), { specials = hooks }) + vm.showTextFn = function() end + vm.co = coroutine.create(function() H.SelectApricornForKurt(vm) end) + local ok, err = coroutine.resume(vm.co) + check(ok, "SelectApricornForKurt ran: " .. tostring(err)) + eq(quantities[1], 0, "kurt.asm:24 clears the byte on the way in") + eq(quantities[2], 1, "and kurt.asm:45 records the apricorn it tossed") + eq(world:readVar(VAR_KURT_APRICORNS), 1, + "so verbosegiveitemvar hands over one ball, not none") + eq(save.inventory.RED_APRICORN, nil, "the apricorn left the pack") + -- .GaveKurtApricorns' own setevent, which the map script runs, not this + -- handler: the count must not be conditioned on it. + check(Apricorns.pending(save) == nil, "the event is still the script's to set") + + -- The cancel arm leaves the byte at zero, which is the `xor a` at :24. + local cancelled = {} + hooks.setKurtApricornQuantity = function(n) cancelled[#cancelled + 1] = n end + hooks.scriptMenu = function(header, onChoose) onChoose(#header.items) end + local vm2 = Vm.new({}, {}, Events.new(), { specials = hooks }) + vm2.showTextFn = function() end + vm2.co = coroutine.create(function() H.SelectApricornForKurt(vm2) end) + coroutine.resume(vm2.co) + eq(#cancelled, 1, "a cancel writes the byte once") + eq(cancelled[1], 0, "and leaves it at zero") +end + +-- ========================== SampleKenjiBreakCountdown, the KENJI_BREAK writer +-- +-- ../pokecrystal/engine/overworld/time.asm:136: `Random / and %11 / add 3`. +do + eq(Specials.STUB_REASONS.SampleKenjiBreakCountdown, nil, + "SampleKenjiBreakCountdown is no longer a stub") + local save = newSave() + local world = newWorld(save) + local hooks = { setKenjiBreak = function(days) world:setKenjiBreak(days) end } + local vm = Vm.new({}, {}, Events.new(), { specials = hooks }) + local seen = {} + local priorRandom = Specials.random + for roll = 1, 4 do + Specials.random = function() return roll end + H.SampleKenjiBreakCountdown(vm) + seen[#seen + 1] = world:readVar(VAR_KENJI_BREAK) + end + Specials.random = priorRandom + eq(table.concat(seen, ","), "3,4,5,6", "three to six days, inclusive") + eq(Save.crystalState(save).kenjiBreak, 6, "and the countdown is in the save") +end + +-- ================================================================ TryQuickSave +-- +-- engine/link/link.asm:2356 -> engine/menus/save.asm:63 Link_SaveGame. +-- ../pokecrystal/maps/BattleTower1F.asm:84-85 is `special TryQuickSave / +-- iffalse Script_Menu_ChallengeExplanationCancel`, so a FALSE here is a challenge that +-- never starts. +do + eq(Specials.STUB_REASONS.TryQuickSave, nil, "TryQuickSave is no longer a stub") + eq(Specials.HANDLER_SOURCE.TryQuickSave, "Specials.lua", + "and Specials.lua owns the handler") +end + +local function runQuickSave(opts) + local save = newSave() + local log = { writes = 0, sfx = {} } + local hooks = { + save = function() return save end, + data = function() return DATA end, + saveFileState = function() return opts.exists, opts.sameId end, + writeSave = function() + log.writes = log.writes + 1 + return opts.writeOk ~= false + end, + playSfxNamed = function(name) log.sfx[#log.sfx + 1] = name end, + } + local vm = Vm.new({}, {}, Events.new(), { specials = hooks }) + vm.showTextFn = function() end + local pages, holds, asked = {}, {}, 0 + local co = coroutine.create(function() H.TryQuickSave(vm) end) + vm.co = co + local send + while true do + local ok, req = coroutine.resume(co, send) + if not ok then error(req, 0) end + if coroutine.status(co) == "dead" then break end + send = nil + if type(req) == "table" and req.kind == "text" then + pages[#pages + 1] = req.text + holds[#holds + 1] = req.hold or 0 + elseif type(req) == "table" and req.kind == "yesorno" then + asked = asked + 1 + send = opts.answers and opts.answers[asked] + else + error("TryQuickSave parked on " .. tostring(req and req.kind), 0) + end + end + log.pages, log.holds, log.asked = pages, holds, asked + log.scriptVar = vm.scriptVar + return log +end + +-- .yoursavefile: an existing file with this player's ID. +do + local log = runQuickSave({ exists = true, sameId = true, answers = { true } }) + eq(log.asked, 1, "AskOverwriteSaveFile asks once") + check(log.pages[1]:find("already a", 1, true) ~= nil, + "AlreadyASaveFileText is the question: " .. tostring(log.pages[1])) + eq(log.writes, 1, "_SaveGameData ran") + eq(log.scriptVar, 1, "and wScriptVar is TRUE") + check(log.pages[2]:find("SAVING", 1, true) ~= nil, "SAVING page printed") + eq(log.holds[2], 16 + 32, "held for save.asm:247 + :251") + check(log.pages[3]:find("KRIS saved", 1, true) ~= nil, + "SavedTheGameText names the player: " .. tostring(log.pages[3])) + eq(log.holds[3], 30 + 30, "held for save.asm:269 + link.asm:2367") + eq(log.sfx[1], "Sfx_Save", "SFX_SAVE rang") +end + +-- .refused: `jr nz, .refused / scf`, which TryQuickSave turns into FALSE. +do + local log = runQuickSave({ exists = true, sameId = true, answers = { false } }) + eq(log.asked, 1, "the question was asked") + eq(log.writes, 0, "NO writes nothing") + eq(log.scriptVar, 0, "and wScriptVar is FALSE") + eq(#log.pages, 1, "no SAVING page follows a refusal") +end + +-- The other ID: AnotherSaveFileText, and .erase rather than .ok. +do + local log = runQuickSave({ exists = true, sameId = false, answers = { true } }) + check(log.pages[1]:find("another", 1, true) ~= nil, + "AnotherSaveFileText: " .. tostring(log.pages[1])) + eq(log.writes, 1, "and it still saves") + eq(log.scriptVar, 1, "TRUE") +end + +-- `ld a, [wSaveFileExists] / and a / jr z, .erase`: no file, no question. +do + local log = runQuickSave({ exists = false, sameId = false }) + eq(log.asked, 0, "a first save is not asked to overwrite anything") + eq(log.writes, 1, "it just writes") + eq(log.scriptVar, 1, "TRUE") +end + +-- The one refusal the cart has no equivalent for: a mod vetoing save.write. +do + local log = runQuickSave({ exists = false, writeOk = false }) + eq(log.writes, 1, "the write was attempted") + eq(log.scriptVar, 0, "a refused write is the same FALSE as a refused prompt") +end + +-- ====================================== the World half of the two save hooks +do + local save = newSave() + local world, game = newWorld(save) + local wrote = 0 + game.writeSave = function() wrote = wrote + 1 return true end + check(world:writeSave() == true, "World:writeSave goes through Game2:writeSave") + eq(wrote, 1, "exactly once") + game.writeSave = function() return false end + check(world:writeSave() == false, "and a vetoed write reports false") + game.writeSave = nil + check(world:writeSave() == false, "with no game there is nothing to write") + + local hooks = world:specialHooks() + check(type(hooks.writeSave) == "function", "the writeSave hook is threaded") + check(type(hooks.saveFileState) == "function", "so is saveFileState") +end + +S.finish() diff --git a/tests/gen2_cycling_road_test.lua b/tests/gen2_cycling_road_test.lua new file mode 100644 index 00000000..ef159cf7 --- /dev/null +++ b/tests/gen2_cycling_road_test.lua @@ -0,0 +1,279 @@ +-- The Cycling Road: the START poll the forced roll used to swallow (#1718) and +-- the ALWAYS_ON_BIKE flag SURF never saw (#1749). +-- +-- luajit tests/gen2_cycling_road_test.lua +-- +-- Route17AlwaysOnBikeCallback sets ENGINE_ALWAYS_ON_BIKE and ENGINE_DOWNHILL on +-- MAPCALLBACK_NEWMAP (maps/Route17.asm:13-16), and DOWNHILL is what makes +-- .GetDPad read an empty pad as DOWN: the player is stepping on every single +-- frame of that road. +-- +-- #1718: the port gated START on "not moving", which on Route 17 is never -- +-- stepBody lands the step and queues the next one inside the SAME tick. The +-- cart refuses only on PLAYERMOVEMENT_CONTINUE; a QUEUED step answers +-- PLAYERMOVEMENT_FINISH (player_movement.asm:459-461), whose arm is `xor a / +-- ld c, a / ret` (events.asm:761-779), so CheckMenuOW still runs there +-- (events.asm:485-498). That landing frame comes round once in eight, so the +-- other half of the fix is Game2's joypad latch (events.asm:193-199, :215-231); +-- it needs a real Input and is driven by +-- tests/drivers/gold_cycling_road_bug1718_1749_test.lua. +-- +-- #1749: both surf entry points already refused on ctx.alwaysOnBike +-- (engine/events/overworld.asm:350-352, :513-515), but World:fieldContext never +-- set the field, so neither refusal was reachable. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 cycling road") +local check, eq = S.check, S.eq + +local Bike = require("src.world.gen2.Bike") +local FieldMoves = require("src.world.gen2.FieldMoves") +local Permissions = require("src.world.gen2.Permissions") +local Player = require("src.world.gen2.Player") +local World = require("src.world.gen2.World") + +-- constants/collision_constants.asm +local COLL_LAND, COLL_WATER = 0x00, 0x29 + +check(Permissions.isLand(COLL_LAND), "COLL_FLOOR $00 is a LAND_TILE") +check(Permissions.isWater(COLL_WATER), "COLL_WATER $29 is a WATER_TILE") + +-- ---- the gate: World:acceptsMenuInput, OWPlayerInput's preamble (#1718) ---- +-- +-- The shipped methods, over a world carrying only the fields they read. +local function gate(w) + w.busy = World.busy + w.playerCollision = World.playerCollision + return World.acceptsMenuInput(w) +end + +eq(gate({ player = { moving = false } }), true, + "standing still: PLAYERMOVEMENT_FINISH, so CheckMenuOW runs") +eq(gate({ player = { moving = true }, stepFinished = false }), + false, "mid-step: PLAYERMOVEMENT_CONTINUE, and OWPlayerInput returns") +eq(gate({ player = { moving = true }, stepFinished = true }), + true, "the frame the step lands reads it again, whatever was queued on top") + +-- stepFinished opens ONE arm and must not reach past it. +eq(gate({ player = { moving = true }, stepFinished = true, battleActive = true }), + false, "a battle still refuses on the landing frame") +eq(gate({ player = { moving = true }, stepFinished = true, textbox = {} }), + false, "an open text box still refuses on the landing frame") +eq(gate({ player = { moving = true }, stepFinished = true, mapSetup = {} }), + false, "and so does a map setup script") +eq(gate({ player = { moving = true }, stepFinished = true, fieldMove = {} }), + false, "and a field move's tail") +eq(gate({ player = { moving = false }, + vm = { running = function() return true end } }), + false, "wScriptRunning refuses even standing still (events.asm:238-243)") + +-- CheckStandingOnIce's carry, the arm that sits below the movement one. +do + local COLL_ICE = 0x23 + check(Permissions.isIce(COLL_ICE), "COLL_ICE $23") + local iced = { + player = { moving = false, cellX = 1, cellY = 1 }, + stepFinished = true, + turningDirection = "down", + map = { cellCollision = function() return COLL_ICE end }, + } + eq(gate(iced), false, "a latched slide on ice refuses") + iced.turningDirection = nil + eq(gate(iced), true, "and lets go once the latch clears") +end + +-- ---- .GetDPad and .DoStep on a DOWNHILL map -------------------------------- + +eq(Bike.forcedDirection(nil, true), "down", + "no direction held on the Cycling Road is a step DOWN") +eq(Bike.forcedDirection("up", true), "up", "a held direction still wins") +eq(Bike.forcedDirection(nil, false), nil, "and off the road, nothing") +eq(Bike.stepFrames(FieldMoves.PLAYER_BIKE, "down", true, Player.STEP_FRAMES), 8, + "the roll is a STEP_BIKE cell: half a walk") +eq(Bike.stepFrames(FieldMoves.PLAYER_BIKE, "left", true, Player.STEP_FRAMES), 16, + "and every other direction gets the walking duration back") + +-- ---- the roll: 24 ticks of Route 17, #1718's premise ----------------------- +-- +-- One iteration of Game2's fixed step over a real Player. + +local openRoad = { + inBounds = function() return true end, + isWalkable = function() return true end, +} + +local function tick(w) + -- Game2's fixed step asks first, with last tick's stepFinished still up. + local accepts = gate(w) + -- What the port asked before the fix, counted off the same run. + local standing = not w.player.moving + local p = w.player + w.stepFinished = false + w.stepFinished = p:update() + if not p.moving then + local dir = Bike.forcedDirection(nil, true) + p.stepFrames = Bike.stepFrames( + FieldMoves.PLAYER_BIKE, dir, true, Player.STEP_FRAMES) + p:tryMove(dir, openRoad, nil) + end + return accepts, standing +end + +do + local w = { player = Player.new(9, 36, "down") } + local polled, idle, gaps, run = {}, 0, 0, 0 + for n = 1, 24 do + local accepts, standing = tick(w) + if accepts then + polled[#polled + 1] = n + if run > gaps then gaps = run end + run = 0 + else + run = run + 1 + end + if standing then idle = idle + 1 end + end + + -- The direct proof of the premise. + eq(idle, 1, "the forced roll leaves the player standing on tick 1 and never again") + eq(w.player.moving, true, "and it is still rolling 24 ticks later") + eq(w.player.cellY > 36, true, "having actually travelled down the road") + + eq(#polled, 3, "the landing frame comes round three times in 24 ticks") + eq(table.concat(polled, ","), "1,10,18", + "tick 1, then one poll per 8-frame bike step") + eq(gaps, 8, "eight refused ticks between polls -- one press in eight lands") +end + +-- ---- ALWAYS_ON_BIKE reaches the field-move context (#1749) ----------------- +-- +-- The bug is the wiring, so the flag comes through the shipped +-- World:alwaysOnBike -> World:engineFlag pair off a real save table. + +local function surfMon() + return { species = "LAPRAS", moves = { "SURF" } } +end + +-- A road cell facing water: with the flag clear this ctx surfs. +local function fieldSelf(onBike, badge) + local coll = {} + local map = { + width = 8, height = 8, + def = { blocks = {}, environment = "ROUTE", tileset = "TILESET_KANTO" }, + cellCollision = function(_, cx, cy) return coll[cy * 16 + cx] or COLL_LAND end, + } + for i = 1, 64 do map.def.blocks[i] = 1 end + -- water in the cell the player faces, land under their feet + coll[36 * 16 + 10] = COLL_WATER + local save = { + party = { surfMon() }, + player = { badges = { FOG = badge ~= false or nil } }, + engineFlags = { [Bike.ENGINE_ALWAYS_ON_BIKE] = onBike or nil }, + } + return { + player = Player.new(9, 36, "right"), + map = map, + playerState = FieldMoves.PLAYER_BIKE, + game = { save = save }, + engineFlags = World.engineFlags, + engineFlag = World.engineFlag, + alwaysOnBike = World.alwaysOnBike, + blockIndexAt = World.blockIndexAt, + escapeRopeTarget = function() return nil end, + facingObject = function() return nil end, + hour = function() return 12 end, + } +end + +eq(World.fieldContext(fieldSelf(true)).alwaysOnBike, true, + "fieldContext carries ALWAYS_ON_BIKE onto the road") +eq(World.fieldContext(fieldSelf(false)).alwaysOnBike, false, + "and reports it clear everywhere else") + +do + -- The ctx really is surfable, or the refusals below pass for the wrong reason. + local ctx = World.fieldContext(fieldSelf(false), surfMon()) + eq(ctx.facing, "right", "facing the water") + check(Permissions.isWater(ctx.facingColl), "and the faced cell is water") + local menu = FieldMoves.surfFromMenu(ctx) + eq(menu.ok, true, "flag clear: the PACK's SURF goes through") + local ow = FieldMoves.trySurfOW(ctx) + eq(ow.ok, true, "flag clear: the A press offers to SURF") + eq(ow.ask, FieldMoves.TEXT.ASK_SURF, "with the usual prompt") +end + +do + local ctx = World.fieldContext(fieldSelf(true), surfMon()) + -- .FailSurf: MenuTextboxBackup on CantSurfText, so the menu refusal TALKS. + local menu = FieldMoves.surfFromMenu(ctx) + eq(menu.ok, false, "on the road the menu refuses") + eq(menu.text, FieldMoves.TEXT.CANT_SURF, "with CantSurfText") + -- TrySurfOW's arm is `.quit`: xor a, no script and no text at all. + local ow = FieldMoves.trySurfOW(ctx) + eq(ow.ok, false, "and the A press refuses") + eq(ow.took, nil, "silently -- .quit queues no script") + eq(ow.text, nil, "and prints nothing") +end + +do + -- CheckBadge runs ABOVE the ALWAYS_ON_BIKE test. + local ctx = World.fieldContext(fieldSelf(true, false), surfMon()) + local menu = FieldMoves.surfFromMenu(ctx) + eq(menu.text, FieldMoves.TEXT.BADGE_REQUIRED, + "no FOGBADGE still outranks the road (engine/events/overworld.asm:347-352)") +end + +-- ---- the real Route 17, when a cache is around ----------------------------- +-- +-- Pins the coordinates the driver walks to, so a map edit fails here instead +-- of parking a human at a wall. + +local cache = os.getenv("GOLD_CACHE") +if not cache then + cache = (os.getenv("HOME") or "") + .. "/Library/Application Support/LOVE/gold-dev/gold" +end +local mapsPath = cache .. "/data/generated/maps.lua" +local mf = io.open(mapsPath, "r") +if not mf then + check(true, "gold cache absent : fixture checks only (SKIP cache facts)") + S.finish() + return +end +mf:close() + +local Map = require("src.world.gen2.Map") +local maps = assert(loadfile(mapsPath))() +local tilesets = assert(loadfile(cache .. "/data/generated/tilesets.lua"))() +local scripts = assert(loadfile(cache .. "/data/generated/scripts.lua"))() + +do + local def = maps.ROUTE_17 + check(def ~= nil, "the cache carries ROUTE_17") + local ops = {} + for _, cb in ipairs(def.callbacks or {}) do + if cb.callback == "MAPCALLBACK_NEWMAP" then + for _, cmd in ipairs(scripts[cb.scriptKey] or {}) do + ops[#ops + 1] = cmd.op + end + end + end + eq(table.concat(ops, ","), "setflag,setflag,endcallback", + "Route17AlwaysOnBikeCallback is the two unconditional setflags") + + local map = Map.new(def, tilesets[def.tileset]) + -- The lane the driver rides: x=9 is road, x=10 is the water beside it, and + -- the four bikers stand at (4,17) (16,32) (3,53) (6,80), well clear of it. + for cy = 36, 48 do + check(Permissions.isLand(map:cellCollision(9, cy)), + "ROUTE_17 (9," .. cy .. ") is road") + check(Permissions.isWater(map:cellCollision(10, cy)), + "ROUTE_17 (10," .. cy .. ") is water") + end + for _, obj in ipairs(def.objects or {}) do + check(not (obj.x == 9 and obj.y >= 34 and obj.y <= 50), + "no object parked in the driver's lane") + end +end + +S.finish() diff --git a/tests/gen2_dex_gift_test.lua b/tests/gen2_dex_gift_test.lua new file mode 100644 index 00000000..73c4eca9 --- /dev/null +++ b/tests/gen2_dex_gift_test.lua @@ -0,0 +1,152 @@ +-- A script-gifted mon registers in the #DEX. GiveShuckle +-- (engine/events/shuckle.asm:14) hands its mon to TryAddMonToParty, whose +-- PARTYMON arm falls into .registerpokedex / SetSeenAndCaughtMon +-- (engine/pokemon/move_mon.asm:179-197), so Mania's SHUCKIE arrives already +-- ticked off. #1719. ROM-free: +-- luajit tests/gen2_dex_gift_test.lua +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 dex gift") +local check, eq, same = S.check, S.eq, S.same + +love = require("tests.love_stub") + +local Vm = require("src.script.gen2.Vm") +local Events = require("src.world.gen2.Events") +local Specials = require("src.script.gen2.Specials") +local Breeding = require("src.core.gen2.Breeding") +local Save = require("src.core.gen2.Save") + +-- constants/pokemon_constants.asm: SHUCKLE is 213. +local SHUCKLE_INDEX = 213 + +-- Just enough of data.pokemon / data.moves for Mon.new to build a SHUCKLE, +-- plus one other species so the "index, not name" mistake has something to +-- collide with. +local function fixture() + return { + pokemon = { + SHUCKLE = { name = "SHUCKLE", index = SHUCKLE_INDEX, + growthRate = "MEDIUM_FAST", + types = { "BUG", "ROCK" }, + baseStats = { hp = 20, attack = 10, defense = 230, speed = 5, + specialAttack = 10, specialDefense = 230 }, + levelMoves = { { level = 1, move = "CONSTRICT" } } }, + TOTODILE = { name = "TOTODILE", index = 158, + growthRate = "MEDIUM_SLOW", + types = { "WATER", "WATER" }, + baseStats = { hp = 50, attack = 65, defense = 64, speed = 43, + specialAttack = 44, specialDefense = 48 }, + levelMoves = { { level = 1, move = "SCRATCH" } } }, + }, + moves = { + CONSTRICT = { name = "CONSTRICT", pp = 35 }, + SCRATCH = { name = "SCRATCH", pp = 35 }, + }, + items = {}, + } +end + +-- The three hooks GiveShuckle reads out of World:specialHooks: the party it +-- appends to, the save it flags the dex on, and the data Mon.new builds from. +local function giftVm(record) + record.party = record.party or {} + return Vm.new({}, {}, Events.new(), { specials = { + party = function() return record.party end, + save = function() return record end, + data = fixture, + } }) +end + +-- ---- the gift itself ------------------------------------------------------ +local giftedDex +do + local record = { party = {} } + Specials.HANDLERS.GiveShuckle(giftVm(record)) + + eq(#record.party, 1, "Mania's SHUCKIE joins the party") + local mon = record.party[1] + eq(mon.nickname, "SHUCKIE", "under its own nickname") + + local dex = record.pokedex or {} + eq((dex.seen or {})[mon.species], true, "and it is SEEN in the #DEX") + eq((dex.caught or {})[mon.species], true, + "and CAUGHT: SetSeenAndCaughtMon sets both flags, not just the one") + eq((dex.caught or {})[SHUCKLE_INDEX], nil, + "keyed by the species name PokedexMenu:rebuild looks up, not by dex number") + local summary = Save.summary(record) + eq(summary and summary.caught, 1, + "so the CONTINUE panel's own count agrees (Save.lua:910-913)") + giftedDex = record.pokedex +end + +-- The handler must leave wScriptVar TRUE, because ManiasHouse.asm branches on +-- it to pick the "took it" text over the "your party is full" one. +do + local record = { party = {} } + local vm = giftVm(record) + Specials.HANDLERS.GiveShuckle(vm) + eq(vm.scriptVar, 1, "GiveShuckle answers TRUE when it hands one over") +end + +-- ---- a dex that already has entries in it --------------------------------- +do + local record = { party = {}, + pokedex = { seen = { TOTODILE = true }, caught = { TOTODILE = true } } } + Specials.HANDLERS.GiveShuckle(giftVm(record)) + eq(record.pokedex.caught.TOTODILE, true, "the starter's entry survives") + eq(record.pokedex.caught.SHUCKLE, true, "and SHUCKIE joins it") + eq(Save.summary(record).caught, 2, "two owned species, counted once each") +end + +-- The shape a loaded save actually has, rather than a hand-built one: +-- Save.normalize seeds pokedex.seen/caught, so the handler is writing into +-- tables that already exist. +do + local record = Save.normalize({ party = {} }) or { party = {} } + record.party = record.party or {} + Specials.HANDLERS.GiveShuckle(giftVm(record)) + eq(record.pokedex.caught.SHUCKLE, true, "a normalized save takes the flag too") +end + +-- ---- the arms that must NOT flag the dex ---------------------------------- +-- .full returns before _AddPartyMon runs, so nothing reaches .registerpokedex +-- and the player who never received the mon has no entry for it. +do + local full = {} + for i = 1, Breeding.PARTY_SIZE do full[i] = { species = "TOTODILE", level = i } end + local record = { party = full } + local vm = giftVm(record) + Specials.HANDLERS.GiveShuckle(vm) + eq(vm.scriptVar, 0, "a full party refuses the gift") + eq(#record.party, Breeding.PARTY_SIZE, "and nothing is appended") + eq(record.pokedex, nil, "a refused gift leaves no #DEX entry behind") +end + +-- Mon.new failing (no such species in the cache) is the other early out. +do + local record = { party = {} } + local vm = Vm.new({}, {}, Events.new(), { specials = { + party = function() return record.party end, + save = function() return record end, + data = function() return { pokemon = {}, moves = {} } end, + } }) + Specials.HANDLERS.GiveShuckle(vm) + eq(vm.scriptVar, 0, "a mon that cannot be built is refused") + eq(record.pokedex, nil, "and still flags nothing") +end + +-- ---- parity with the engine's other SetSeenAndCaughtMon ports ------------- +-- Breeding.markPokedex is the shared port of the same routine the hatch, the +-- NPC trade and the givepoke opcode all run. A gift that flags the dex its +-- own way instead of matching that state is drift, and drift is what leaves +-- one screen showing the entry and another not. +do + local sibling = { party = {} } + check(Breeding.markPokedex(sibling, "SHUCKLE"), + "the shared SetSeenAndCaughtMon port still takes a species") + same(giftedDex, sibling.pokedex, + "GiveShuckle leaves the identical #DEX state the shared port does") +end + +S.finish() diff --git a/tests/gen2_field_anim_test.lua b/tests/gen2_field_anim_test.lua index 3513c46b..50b77d6e 100644 --- a/tests/gen2_field_anim_test.lua +++ b/tests/gen2_field_anim_test.lua @@ -71,9 +71,14 @@ check("$30 jump_step DOWN is a jump", Movement.decodeByte(0x30).kind, "jump") check("and it keeps its facing", Movement.decodeByte(0x33).dir, "right") check("$2c slow_jump_step is one too", Movement.decodeByte(0x2c).kind, "jump") check("$34 fast_jump_step as well", Movement.decodeByte(0x34).kind, "jump") --- The neighbouring families are still plain steps / turns. -check("$28 turn_waterfall is untouched", - Movement.decodeByte(0x28).kind, "turn") +-- $20 turn_away / $24 turn_in / $28 turn_waterfall all `jp TurningStep` +-- (engine/overworld/movement.asm:483-513), which is InitStep with +-- OBJECT_ACTION_SPIN (:693-715): one cell crossed, spinning, not a facing +-- change. Script_ForcedMovement's bounce out of a whirlpool is that step. +check("$28 turn_waterfall is a spinning step", + Movement.decodeByte(0x28).kind, "step") +check("$24 turn_in is one too", Movement.decodeByte(0x24).kind, "step") +check("and it carries the spin", Movement.decodeByte(0x24).spin, true) check("$10 big_step is untouched", Movement.decodeByte(0x10).kind, "step") -- ------------------------------------------ sliding, fixed facing, tree shake diff --git a/tests/gen2_font_ui_test.lua b/tests/gen2_font_ui_test.lua index af499a5d..7e51e150 100644 --- a/tests/gen2_font_ui_test.lua +++ b/tests/gen2_font_ui_test.lua @@ -158,10 +158,17 @@ else if path:find("font%.png$") then images[path].getDimensions = function() return 128, 64 end end + -- One row per frame style, all eight (pokegold/gfx/font.asm:10). + if path:find("frames%.png$") then + images[path].getDimensions = function() return 48, 64 end + end return images[path] end Font.load({ font = fontDef }) require("src.render.Assets").image = realImage + -- currentFrame is a Font module-local that outlives a suite, and this file + -- is also dofile'd into tests/run_tests.lua after screens that cycle it. + Font.setFrame(1) -- Which sheet a code lands on, by the image drawCode reaches for. local drawn @@ -186,11 +193,18 @@ else "the swap runs to the last of the 25 tiles it loads") -- `jr LoadFrame` at the end of _LoadFontsBattleExtra: $79-$7e are the -- textbox frame on a battle-sheet screen too, so a box drawn there is the - -- same box as everywhere else. - for code, name in pairs({ [0x79] = "tl", [0x7a] = "h", [0x7b] = "tr", - [0x7c] = "v", [0x7d] = "bl", [0x7e] = "br" }) do - eq(sheetOf(code), "font_extra.png", - ("LoadFrame keeps the %s border glyph off the battle sheet"):format(name)) + -- same box as everywhere else. LoadFrame reads the `Frames` label, not + -- FontExtra (pokegold/engine/gfx/load_font.asm:29-39). + -- A cache built before the extractor split Frames out has no imageFrames + -- row, and its $79-$7e fall back to the extra sheet's baked-in frame 1. + if not fontDef.imageFrames then + check(true, "cache predates the frames sheet (SKIP the border routing)") + else + for code, name in pairs({ [0x79] = "tl", [0x7a] = "h", [0x7b] = "tr", + [0x7c] = "v", [0x7d] = "bl", [0x7e] = "br" }) do + eq(sheetOf(code), "frames.png", + ("LoadFrame keeps the %s border glyph off the battle sheet"):format(name)) + end end Font.useBattleExtra(false) love.graphics.draw = realDraw @@ -369,11 +383,16 @@ stopRecording() local dexLine = findPrint("№.") check(dexLine ~= nil, "the roster line places the No ligature") -check(dexLine and dexLine.battleExtra == true, - "with the battle sheet in the $60 slot, where that glyph lives") local idLine = findPrint("№/") -check(idLine ~= nil and idLine.battleExtra == true, - "and so does the trainer id line") +-- Which sheet a glyph resolves against needs the cache's real font rows. +if not fontDef then + check(true, "no Gold cache (SKIP the battle-sheet routing)") +else + check(dexLine and dexLine.battleExtra == true, + "with the battle sheet in the $60 slot, where that glyph lives") + check(idLine ~= nil and idLine.battleExtra == true, + "and so does the trainer id line") +end eq(Font.battleExtraActive(), false, "the sheet the caller had is put back when the screen is done") @@ -392,10 +411,14 @@ photo:draw() stopRecording() local photoDex = findPrint("№.") -check(photoDex ~= nil and photoDex.battleExtra == true, - "the photo card's No ligature resolves against the battle sheet") local photoId = findPrint("№") -check(photoId ~= nil and photoId.battleExtra == true, "and its does too") +if not fontDef then + check(true, "no Gold cache (SKIP the photo card's battle-sheet routing)") +else + check(photoDex ~= nil and photoDex.battleExtra == true, + "the photo card's No ligature resolves against the battle sheet") + check(photoId ~= nil and photoId.battleExtra == true, "and its does too") +end eq(Font.battleExtraActive(), false, "and the card puts the sheet back") S.finish() diff --git a/tests/gen2_ledge_hop_test.lua b/tests/gen2_ledge_hop_test.lua new file mode 100644 index 00000000..408b3b17 --- /dev/null +++ b/tests/gen2_ledge_hop_test.lua @@ -0,0 +1,270 @@ +-- A ledge hop advances every logic frame, the way every other step does. +-- +-- luajit tests/gen2_ledge_hop_test.lua +-- +-- A hop is the engine's only two-cell move (World:tryLedgeJump, STEP_LEDGE). +-- On the cart it is jump_step, i.e. STEP_WALK (pokegold/engine/overworld/ +-- movement.asm:595-597), so GetStepVector hands StepFunction_PlayerJump the +-- `db 0, 2, 8, 2` row (map_objects.asm:365-381) and .stepjump / .stepland run +-- it as two 8-frame beats: 16 frames, 2px EVERY frame, and UpdateJumpPosition +-- (:1796-1817) adds the speed to OBJECT_JUMP_HEIGHT and indexes its 16-entry +-- arc at height/2, one entry per frame. +-- +-- We render Gen 2 at twice that temporal resolution (Player.STEP_FRAMES = 16 +-- for one cell), so the hop is 32 frames and has to move 1px and half an arc +-- entry on every one of them. It used to compute the sub-cell offset per CELL +-- and then scale it by the cell delta, so half the hop's frames moved nothing +-- and the other half jumped 2px -- and since the camera follows px/py, the +-- whole map scrolled at 30Hz. The arc had the matching quantum with the +-- opposite sign, which dragged the sprite back UP the screen five times. #1713 +-- +-- Self-contained: no cache, no love. tests/drivers/gold_ledge_hop_bug1713_ +-- test.lua is the half a person watches. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 ledge hop") +local check, eq = S.check, S.eq + +local Player = require("src.world.gen2.Player") +local Permissions = require("src.world.gen2.Permissions") +local World = require("src.world.gen2.World") + +local DELTA = { up = { 0, -1 }, down = { 0, 1 }, + left = { -1, 0 }, right = { 1, 0 } } + +-- ---- one move, frame by frame ---------------------------------------------- +-- +-- What the renderer reads each frame: the position the camera follows, and the +-- OBJECT_SPRITE_Y_OFFSET added on top of it. Row 1 is the standing frame +-- before the move starts, so row i+1 is frame i. +local function playMove(dir, opts) + opts = opts or {} + local p = Player.new(5, 5, dir) + local d = DELTA[dir] + local cells = opts.jump and 2 or 1 + p.targetX, p.targetY = p.cellX + d[1] * cells, p.cellY + d[2] * cells + p.moving = true + p.progress = 0 + p.jumping = opts.jump or nil + p.stepFrames = opts.stepFrames + or (opts.jump and Player.STEP_FRAMES * 2 or Player.STEP_FRAMES) + local rows = { { px = p.px, py = p.py, off = 0 } } + local frames = 0 + while p.moving and frames < 200 do + p:update() + frames = frames + 1 + rows[#rows + 1] = { px = p.px, py = p.py, off = p.spriteYOffset or 0 } + end + return p, rows, frames +end + +-- Per-frame change in one recorded column. +local function steps(rows, key) + local out = {} + for i = 2, #rows do out[i - 1] = rows[i][key] - rows[i - 1][key] end + return out +end + +local function countIf(list, fn) + local n = 0 + for _, v in ipairs(list) do if fn(v) then n = n + 1 end end + return n +end + +-- ---- the control: an ordinary one-cell walk -------------------------------- +-- +-- The hop's fix must not have been bought by moving this. +do + local p, rows, frames = playMove("down") + eq(frames, Player.STEP_FRAMES, "a walk is 16 logic frames") + local dy = steps(rows, "py") + eq(countIf(dy, function(v) return v ~= 1 end), 0, + "and every one of them moves the player exactly one pixel down") + eq(countIf(steps(rows, "px"), function(v) return v ~= 0 end), 0, + "with no sideways drift") + eq(p.cellY, 6, "it lands one cell on") + eq(p.py, 6 * 16, "on the pixel the cell says") + + -- .DoStep's STEP_BIKE arm: the same one cell in half the frames, so the + -- per-frame advance doubles. This is what a span applied to the wrong + -- denominator breaks first. + local bike, bikeRows, bikeFrames = + playMove("right", { stepFrames = Player.STEP_FRAMES / 2 }) + eq(bikeFrames, 8, "a bike step is 8 logic frames") + eq(countIf(steps(bikeRows, "px"), function(v) return v ~= 2 end), 0, + "each moving two pixels, so a bike still covers one cell") + eq(bike.cellX, 6, "and lands one cell on, not two") +end + +-- ---- the hop --------------------------------------------------------------- +-- +-- The bug, stated as numbers: half these frames used to move nothing and the +-- other half used to move two pixels. +local hopP, hopRows, hopFrames = playMove("down", { jump = true }) +eq(hopFrames, Player.STEP_FRAMES * 2, "a ledge hop is 32 logic frames") +local hopDy = steps(hopRows, "py") +eq(countIf(hopDy, function(v) return v ~= 1 end), 0, + "every frame of a ledge hop moves the player a pixel") +eq(countIf(hopDy, function(v) return math.abs(v) >= 2 end), 0, + "and no frame of it moves two") +eq(countIf(steps(hopRows, "px"), function(v) return v ~= 0 end), 0, + "a hop straight down never moves sideways") +-- The near miss: span math applied without the matching frames scaling covers +-- one cell in 32 frames instead of two. +eq(hopRows[#hopRows].py - hopRows[1].py, 32, + "the 32 frames add up to 32 pixels, which is two cells") +eq(hopP.cellY, 7, "and the grid agrees the player crossed two cells") +eq(hopP.py, 7 * 16, "landing on the landing cell's own pixel") +eq(countIf(hopRows, function(r) + return r.px % 1 ~= 0 or r.py % 1 ~= 0 +end), 0, "and no frame leaves the player on a half pixel") + +-- ---- the arc --------------------------------------------------------------- +-- +-- UpdateJumpPosition's .y_offsets: -4 up to -12 and back to 0, one entry per +-- cart frame, so across our doubled step it is half an entry per frame. +do + local off = {} + for i = 2, #hopRows do off[#off + 1] = hopRows[i].off end + local peak = 0 + for i = 1, #off do if off[i] < peak then peak = off[i] end end + -- The arc rises, then falls, and changes its mind exactly once: any extra + -- sign change in the offsets is the sprite wobbling. + local moves = {} + for i = 2, #off do + local v = off[i] - off[i - 1] + if v ~= 0 then moves[#moves + 1] = v end + end + local turns = 0 + for i = 2, #moves do + if (moves[i] > 0) ~= (moves[i - 1] > 0) then turns = turns + 1 end + end + eq(off[1], -4, "the arc opens on the table's first entry") + eq(peak, -12, "peaks 12 pixels up, the table's own peak") + eq(off[#off], 0, "and is back on the ground for the landing frame") + eq(turns, 1, "rising then falling, once, with no wobble in between") + eq(countIf(off, function(v) return v > 0 or v < -12 end), 0, + "and never leaves the range the table covers") + + -- The composed screen position, which is what an eye actually tracks: the + -- camera-following py plus the sprite offset. The first frame lifts the + -- sprite -- that is the take-off -- and after it a hop DOWN may never go + -- backwards. Before the fix it did so on five frames. + local screen, back = {}, 0 + for i = 2, #hopRows do + screen[#screen + 1] = hopRows[i].py + hopRows[i].off + end + for i = 2, #screen do + if screen[i] < screen[i - 1] then back = back + 1 end + end + eq(back, 0, "the arc never drags the sprite back up the screen") + check(screen[1] < hopRows[1].py + hopRows[1].off, + "the one time it does rise is the take-off, on the first frame") + eq(countIf(steps(hopRows, "py"), function(v) return v ~= 1 end), 0, + "while the map underneath scrolls one pixel a frame throughout") +end + +-- A sideways hop is the same move on the other axis: Gold's $a0 is HOP_RIGHT. +for _, dir in ipairs({ "left", "right" }) do + local p, rows, frames = playMove(dir, { jump = true }) + local want = DELTA[dir][1] + eq(frames, 32, ("a %s hop is 32 logic frames too"):format(dir)) + eq(countIf(steps(rows, "px"), function(v) return v ~= want end), 0, + ("and moves one pixel %s on every one of them"):format(dir)) + eq(countIf(steps(rows, "py"), function(v) return v ~= 0 end), 0, + ("a %s hop never slides the player up or down a row"):format(dir)) + eq(p.cellX, 5 + want * 2, ("it lands two cells %s"):format(dir)) +end + +-- The step ends clean: nothing about the jump is left set for the next walk. +eq(hopP.moving, false, "the hop ends the step") +eq(hopP.jumping, nil, "and clears the jump") +eq(hopP.spriteYOffset, 0, "and puts the sprite back on its feet") + +-- ---- the same hop, through World ------------------------------------------- +-- +-- Player alone cannot prove the wiring: World:tryLedgeJump is what picks the +-- two-cell target and the doubled duration (World.lua's STEP_LEDGE arm), and a +-- fix that only touched one of the two would still stutter in the game. +local COLL_FLOOR, COLL_WALL, COLL_HOP_DOWN = 0x00, 0x07, 0xa3 + +local function ledgeWorld(px, py, dir, coll) + local game = { + data = { items = {}, moves = {}, pokemon = {} }, + save = { player = { name = "GOLD", badges = {} }, party = {}, + inventory = {} }, + } + local world = World.new(game) + game.world = world + local cells = {} + cells[py * 100 + px] = coll or COLL_HOP_DOWN + local d = DELTA[dir] + -- The refused single step, which is what turns the walk into a jump. + cells[(py + d[2]) * 100 + (px + d[1])] = COLL_WALL + local map + map = { + id = "ROUTE_29", + width = 10, height = 10, + def = { objects = {}, bgEvents = {}, environment = "ROUTE", + tileset = "TILESET_JOHTO", width = 10, height = 10 }, + cellCollision = function(_, x, y) + return cells[y * 100 + x] or COLL_FLOOR + end, + inBounds = function(_, x, y) + return x >= 0 and y >= 0 and x < 20 and y < 20 + end, + isWalkable = function(_, x, y) + return map:inBounds(x, y) + and Permissions.isWalkable(map:cellCollision(x, y)) + end, + warpAt = function() return nil end, + connection = function() return nil end, + } + world.map = map + world.maps = { ROUTE_29 = map.def } + world.player = Player.new(px, py, dir) + world.player.turnArmed = false + world.entities = { world.player } + world.npcs = {} + world.encounters = {} + world.noWildEncounters = true + world.pollTimeOfDay = function() end + world.updatePeople = function() end + return world +end + +check(Permissions.isLedge(COLL_HOP_DOWN), "$a3 is a ledge") +check((Permissions.ledgeFacings(COLL_HOP_DOWN) or {}).down, + "and its row hops DOWN") + +do + local world = ledgeWorld(4, 5, "down") + local p = world.player + local rows, airborne = {}, false + for _ = 1, 120 do + world.heldDir = (not airborne) and "down" or nil + local wasAirborne = airborne + world:step() + if p.jumping then airborne = true end + if airborne then + rows[#rows + 1] = { px = p.px, py = p.py, off = p.spriteYOffset or 0 } + end + if wasAirborne and not p.moving then break end + end + check(#rows > 0, "holding down on a ledge starts a hop") + eq(#rows - 1, 32, "which runs the doubled 32-frame duration") + eq(p.cellY, 7, "and lands two cells down, on the far side of the wall") + local dy = steps(rows, "py") + eq(countIf(dy, function(v) return v ~= 1 end), 0, + "every frame of the in-game hop scrolls the map exactly one pixel") + eq(rows[#rows].py - rows[1].py, 32, + "so the whole hop is 32 pixels of travel, not 16") + local screen, back = {}, 0 + for i = 2, #rows do screen[#screen + 1] = rows[i].py + rows[i].off end + for i = 2, #screen do + if screen[i] < screen[i - 1] then back = back + 1 end + end + eq(back, 0, "and after the take-off the sprite never reverses") +end + +S.finish() diff --git a/tests/gen2_menus_test.lua b/tests/gen2_menus_test.lua index 6b175588..8843026d 100644 --- a/tests/gen2_menus_test.lua +++ b/tests/gen2_menus_test.lua @@ -356,9 +356,11 @@ local optionsGame, optionsInput = newGame(Save.newGame()) local options = OptionsMenu.new(optionsGame, { options = Save.defaultOptions(), }) --- The cart's seven rows, then the port's: CONTROLS, audio, speed, display, --- video mode, the mobile-gated touch three (buildRows), MAX FPS and CANCEL. -check("twenty-three rows", #OptionsMenu.ROWS, 23) +-- The cart's seven rows, then the port's: CONTROLS, audio, PERFORMANCE, +-- speed, display, SHADER FX + SHADER FX 2 (the second slot added alongside +-- the dual-shader feature), video mode, screen position, the mobile-gated +-- touch three (buildRows), MAX FPS, BATTLE BG and CANCEL. +check("twenty-seven rows", #OptionsMenu.ROWS, 27) check("the cart's rows come first", OptionsMenu.ROWS[7].key, "frame") check("then the rebind screen", OptionsMenu.ROWS[8].id, "controls") check("then the port's audio group", OptionsMenu.ROWS[9].key, "musicVol") @@ -954,18 +956,24 @@ check("GBC leaves a palette alone", 1)[1], 1) check("and has no present pass", GbcPalette.presentColors(), nil) -local zoomIndex, gbcfxIndex +local zoomIndex, tiltIndex for i, row in ipairs(OptionsMenu.ROWS) do if row.label == "ZOOM" then zoomIndex = i end - if row.label == "GBC FX" then gbcfxIndex = i end + if row.label == "TILT" then tiltIndex = i end end check("VOID FILL follows ZOOM", OptionsMenu.ROWS[zoomIndex + 1].label, "VOID FILL") check("and TILT follows VOID FILL", OptionsMenu.ROWS[zoomIndex + 2].label, "TILT") -check("VIDEO MODE follows GBC FX", OptionsMenu.ROWS[gbcfxIndex + 1].label, +check("SHADER FX follows COLOR follows TILT", OptionsMenu.ROWS[tiltIndex + 2].label, + "SHADER FX") +check("SHADER FX 2 follows SHADER FX", OptionsMenu.ROWS[tiltIndex + 3].label, + "SHADER FX 2") +check("VIDEO MODE follows SHADER FX 2", OptionsMenu.ROWS[tiltIndex + 4].label, "VIDEO MODE") -check("and TOUCH PAD follows it", OptionsMenu.ROWS[gbcfxIndex + 2].label, +check("and SCREEN POS follows it", OptionsMenu.ROWS[tiltIndex + 5].label, + "SCREEN POS") +check("and TOUCH PAD follows that", OptionsMenu.ROWS[tiltIndex + 6].label, "TOUCH PAD") local videoRow = select(2, rowNamed("VIDEO MODE")) diff --git a/tests/gen2_obedience_test.lua b/tests/gen2_obedience_test.lua index c256a726..e027b8cc 100644 --- a/tests/gen2_obedience_test.lua +++ b/tests/gen2_obedience_test.lua @@ -180,7 +180,7 @@ do random = rolls({ 39, 39, 5, 2 }) }) eq(battle:checkObedience("ROCK_THROW"), true, "napped instead") eq(mon.status, "sleep", "BeganToNap writes sleep straight in") - eq(mon.statusTurns, 3, "1-7 turns, from the same roll shape as sleep") + eq(mon.statusTurns, 3, "1-7 turns, written straight into the status byte") check(findText(battle.events, "ROCKY began to nap!"), "with its own line") -- Self-hit: the margin roll in the second band. diff --git a/tests/gen2_ow_bounce_test.lua b/tests/gen2_ow_bounce_test.lua new file mode 100644 index 00000000..211e56fc --- /dev/null +++ b/tests/gen2_ow_bounce_test.lua @@ -0,0 +1,311 @@ +-- SPRITEMOVEDATA_POKEMON ($16), the row every overworld mon object stands on. +-- +-- luajit tests/gen2_ow_bounce_test.lua (ROM-free; the cache section SKIPs +-- without a gold cache) +-- +-- data/sprites/map_objects.asm:181-187 gives the row SPRITEMOVEFN_BOUNCE and +-- OBJECT_ACTION_BOUNCE. SetFacingBounce increments OBJECT_STEP_FRAME once a +-- frame, masks it to four bits and reads bit 3: set means FACING_STEP_UP_0, +-- clear falls into SetFacingFreezeBounce and FACING_STEP_DOWN_0 +-- (engine/overworld/map_object_action.asm:184-202). Those are tiles $00..$03 +-- and $04..$07 of the mon's menu icon (data/sprites/facings.asm:43-72), i.e. +-- the two 16x16 halves of the 16x32 sheet extractIcons already writes. +-- +-- Both halves of #1748 are pinned here: the animation itself, and the sheet +-- being two frames deep so the animation has a second pose to reach. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 ow bounce") +local check, eq = S.check, S.eq + +love = require("tests.love_stub") + +local NPC = require("src.world.gen2.Npc") +local SpriteRenderer = require("src.render.SpriteRenderer") +local World = require("src.world.gen2.World") + +-- constants/map_object_constants.asm:135-151 +local SPRITEMOVEDATA_STILL = 0x01 +local SPRITEMOVEDATA_STANDING_DOWN = 0x06 +local SPRITEMOVEDATA_POKEMON = 0x16 + +-- trueColor short-circuits SpriteRenderer past the OBP bake, so :draw here +-- needs no canvases (the same trick tests/gen2_big_object_test.lua uses). +local MON_SHEET = { + id = "SPRITE_JIGGLYPUFF", frames = 2, trueColor = true, + spriteType = "POKEMON_SPRITE", walker = false, + image = "assets/generated/icons/gen2/jigglypuff.png", +} +local DOLL_SHEET = { + id = "SPRITE_FAIRY", frames = 6, trueColor = true, + spriteType = "WALKING_SPRITE", walker = true, + image = "assets/generated/sprites/fairy.png", +} + +local function build(movement, sheet) + return NPC.new("TEST_MAP", + { index = 1, movement = movement, x = 4, y = 4 }, sheet or MON_SHEET) +end + +-- ---- the movement byte ----------------------------------------------------- +eq(NPC.MOVE.POKEMON, SPRITEMOVEDATA_POKEMON, + "SPRITEMOVEDATA_POKEMON is $16 in map_object_constants.asm") + +-- Without the seam every block below it raises rather than reporting, which +-- turns a whole suite of answers into one traceback. +local HAS_BOUNCE = check(type(NPC.bounceFrame) == "function", + "NPC:bounceFrame is the seam OBJECT_ACTION_BOUNCE draws through") +if not HAS_BOUNCE then + NPC.bounceFrame = function() return nil end +end + +do + local mon = build(SPRITEMOVEDATA_POKEMON) + check(mon.bouncing == true, "a $16 object is built bouncing") + check(mon.fixedFacing == true, + "and keeps FIXED_FACING, which the $16 row's flags1 byte carries") + eq(mon.facing, "down", "its `db DOWN ; facing` column is DOWN") + eq(select(1, NPC.patternFor(SPRITEMOVEDATA_POKEMON)), "stand", + "MovementFunction_Bouncing parks it on STEP_TYPE_STANDING: no walking") +end + +-- ---- eight frames down, eight frames up ------------------------------------ +-- `inc a / and %00001111 / ld [hl], a / and %00001000` is a sixteen-frame +-- cycle with an eight-frame dwell on each pose, and the counter is stepped +-- BEFORE the bit is read -- so the spawn frame is pose 0 and so are the seven +-- after it. +do + local mon = build(SPRITEMOVEDATA_POKEMON) + eq(mon.bounceStep, 0, "OBJECT_STEP_FRAME starts at zero") + local seen, wrong = { mon:bounceFrame() }, nil + for i = 1, 31 do + mon:update() + seen[i + 1] = mon:bounceFrame() + end + for i = 0, 31 do + local want = math.floor(i / 8) % 2 + if seen[i + 1] ~= want and not wrong then + wrong = ("frame %d drew pose %s, wanted %d") + :format(i, tostring(seen[i + 1]), want) + end + end + check(wrong == nil, "two full cycles run 0,0..0,1,1..1 eight frames apiece" + .. (wrong and (" -- " .. wrong) or "")) + eq(seen[1], 0, "the spawn frame is FacingStepDown0, the icon's first half") + eq(seen[9], 1, "the ninth is FacingStepUp0, its second half") + eq(seen[17], 0, "and the seventeenth is back to the first") + eq(mon.bounceStep, 31 % 16, "the counter wrapped at sixteen, not at eight") +end + +-- ---- the frozen column ----------------------------------------------------- +-- OBJECT_ACTION_BOUNCE's second entry is SetFacingFreezeBounce, which writes +-- FACING_STEP_DOWN_0 and never touches OBJECT_STEP_FRAME. So a mon held for a +-- conversation pins its FIRST pose and resumes on the phase it left. +do + local mon = build(SPRITEMOVEDATA_POKEMON) + for _ = 1, 10 do mon:update() end + eq(mon:bounceFrame(), 1, "ten frames in, the mon is on the up pose") + eq(mon.bounceStep, 10, "with OBJECT_STEP_FRAME at 10") + + mon.frozen = true + local pinned = true + for _ = 1, 40 do + mon:update() + if mon:bounceFrame() ~= 0 then pinned = false end + end + check(pinned, "frozen, it holds FacingStepDown0 for the whole conversation") + eq(mon.bounceStep, 10, + "and SetFacingFreezeBounce leaves the step counter where it was") + + mon.frozen = false + mon:update() + eq(mon.bounceStep, 11, "released, the counter carries on from 10") + eq(mon:bounceFrame(), 1, "so the bounce resumes on the phase it froze at") +end + +-- ---- every other movement byte is still ------------------------------------ +-- Only the $16 row carries OBJECT_ACTION_BOUNCE; the Clefairy, Charizard and +-- Pidgeot dolls are SPRITEMOVEDATA_STANDING_DOWN and take +-- OBJECT_ACTION_STAND (data/sprites/map_objects.asm:53-59). +do + for _, movement in ipairs({ SPRITEMOVEDATA_STILL, SPRITEMOVEDATA_STANDING_DOWN, + NPC.MOVE.WANDER, NPC.MOVE.SPINRANDOM_SLOW, NPC.MOVE.BIGDOLL }) do + local npc = build(movement, DOLL_SHEET) + check(not npc.bouncing, + ("movement $%02x does not bounce"):format(movement)) + check(npc:bounceFrame() == nil, + ("and $%02x asks for no frame override"):format(movement)) + end + + -- The other half of it: a mon SHEET on a still row stays still. Gold has no + -- such object, but a mod or a variablesprite slot can make one. + local still = build(SPRITEMOVEDATA_STILL, MON_SHEET) + for _ = 1, 40 do still:update() end + check(still:bounceFrame() == nil, + "a POKEMON_SPRITE sheet on a STILL row never bounces either") +end + +-- ---- the frame reaches the renderer ---------------------------------------- +-- NPC:draw is where the pose is spent: SpriteRenderer:draw's trailing +-- frameOverride argument. A bounce the draw call drops looks exactly like no +-- bounce at all. +do + local mon = build(SPRITEMOVEDATA_POKEMON) + local doll = build(SPRITEMOVEDATA_STANDING_DOWN, DOLL_SHEET) + local seen = {} + local function recorder(who) + return { draw = function(_, _, _, _, _, _, _, _, _, _, frameOverride) + seen[who] = { override = frameOverride, count = (seen[who] + and seen[who].count or 0) + 1 } + end } + end + mon.sprite, doll.sprite = recorder("mon"), recorder("doll") + + mon:draw(0, 0, 1) + doll:draw(0, 0, 1) + eq(seen.mon.override, 0, "the mon's first draw overrides to frame 0") + check(seen.doll.override == nil, "and the doll's overrides to nothing") + + for _ = 1, 8 do mon:update() end + mon:draw(0, 0, 1) + eq(seen.mon.override, 1, "eight frames later it overrides to frame 1") +end + +-- ---- a one-frame sheet has nowhere to bounce to ---------------------------- +-- SpriteRenderer builds one quad per frame and :draw only honours an override +-- it has a quad for, so the extractor half of #1748 is load bearing: with +-- `frames = 1` the up pose is simply not on the sheet. +do + local one = SpriteRenderer.new({ + id = "ONE", frames = 1, trueColor = true, image = MON_SHEET.image }, "one") + eq(one.frameCount, 1, "a frames = 1 def builds one quad") + check(one.frames[1] == nil, "so there is no second frame to override to") + + local two = SpriteRenderer.new(MON_SHEET, "two") + eq(two.frameCount, 2, "a frames = 2 def builds both") + check(two.frames[1] ~= nil, "and frame 1 is a real quad") + eq(two:getFrameGeometry(1).y, two.frameHeight, + "which is the lower 16x16 of the 16x32 icon sheet") +end + +-- ---- World:load repairs a stale cache -------------------------------------- +-- The extractor fix only reaches a player who re-imports, and there is no +-- non-file lever that would force one. World:load back-fills the SpriteMons +-- rows on the way past, keyed on `source` so a mod's own POKEMON_SPRITE row is +-- left exactly as the mod wrote it. +do + local sprites = { + SPRITE_JIGGLYPUFF = { id = "SPRITE_JIGGLYPUFF", frames = 1, + source = "ROM:SpriteMons[20]", spriteType = "POKEMON_SPRITE", + image = "assets/generated/icons/gen2/jigglypuff.png" }, + SPRITE_CHRIS = { id = "SPRITE_CHRIS", frames = 12, walker = true, + source = "ROM:OverworldSprites[0]", + image = "assets/generated/sprites/chris.png" }, + SPRITE_MOD_MON = { id = "SPRITE_MOD_MON", frames = 1, + source = "MOD:pocket-monsters", spriteType = "POKEMON_SPRITE", + image = "mods/pocket-monsters/mon.png" }, + } + local world = World.new({ data = { + gen2Maps = { TEST_MAP = { width = 1, height = 1 } }, + gen2Tilesets = {}, + gen2Sprites = sprites, + } }) + -- load goes on to tilesets, palettes and map art this stub has none of; the + -- back-fill sits in its first dozen lines, so let the rest fall over. + pcall(world.load, world) + eq(sprites.SPRITE_JIGGLYPUFF.frames, 2, + "a pre-#1748 SpriteMons row is back-filled to two frames on load") + eq(sprites.SPRITE_CHRIS.frames, 12, + "an OverworldSprites row is not touched") + eq(sprites.SPRITE_MOD_MON.frames, 1, + "and a mod's own POKEMON_SPRITE row keeps the frame count it declared") +end + +-- ---- the day-care pair ----------------------------------------------------- +-- GetMonSprite's .BreedMon1 / .BreedMon2 arms have no sprites.lua row to +-- back-fill (engine/overworld/overworld.asm:279-305), so the def World builds +-- by hand has to carry the second frame itself. +do + local world = World.new({ data = { gen2Icons = { + species = { PIKACHU = "ICON_PIKACHU" }, + icons = { ICON_PIKACHU = { + image = "assets/generated/icons/gen2/pikachu.png" } }, + } } }) + local def = world:breedmonSpriteDef("PIKACHU") + check(def ~= nil, "a deposited PIKACHU builds a day-care sprite def") + eq(def and def.frames, 2, "two frames, like every other SpriteMons row") + eq(def and def.spriteType, "POKEMON_SPRITE", "and it is a mon sheet") +end + +-- ---- the cache ------------------------------------------------------------- +-- Same default every other gen2 suite uses, so a run with no GOLD_CACHE set +-- still reads the cache instead of skipping silently. +local cache = os.getenv("GOLD_CACHE") + or ((os.getenv("HOME") or "") .. "/Library/Application Support/LOVE/gold-dev/gold") +local function loadCache(name) + local chunk = loadfile(cache .. "/data/generated/" .. name .. ".lua") + return chunk and chunk() or nil +end + +local sprites = loadCache("sprites") +if not sprites then + check(true, "no GOLD_CACHE: the extracted rows are not checked (SKIP)") +else + local monRows, thin = 0, {} + for id, def in pairs(sprites) do + if type(def) == "table" and type(def.source) == "string" + and def.source:find("^ROM:SpriteMons") then + monRows = monRows + 1 + if (def.frames or 1) < 2 then thin[#thin + 1] = id end + end + end + check(monRows > 0, "the cache carries SpriteMons rows at all") + if #thin > 0 then + check(true, ("cache predates #1748 (%d thin rows, first %s) : World:load" + .. " back-fills them, a re-import writes them (SKIP)") + :format(#thin, thin[1])) + else + check(true, ("all %d SpriteMons rows are two frames deep"):format(monRows)) + end + + -- The objects the reporter was looking at: every $16 object on every map has + -- to name a sheet that the bounce can actually flip. + local maps = loadCache("maps") + if not maps then + check(true, "no maps.lua: the $16 objects are not checked (SKIP)") + else + local bouncers, slots, unresolved, oneFrame = 0, 0, {}, {} + for mapId, def in pairs(maps) do + if type(def) == "table" then + for _, obj in ipairs(def.objects or {}) do + if obj.movement == SPRITEMOVEDATA_POKEMON then + bouncers = bouncers + 1 + if type(obj.sprite) == "number" then + -- The day-care pair: GetMonSprite's .BreedMon arms resolve those + -- two bytes to the deposited species' icon at spawn time, so + -- there is no sprites.lua row for them to name. + slots = slots + 1 + elseif not sprites[obj.sprite] then + unresolved[#unresolved + 1] = mapId + elseif (sprites[obj.sprite].frames or 1) < 2 then + oneFrame[#oneFrame + 1] = mapId + end + end + end + end + end + check(bouncers > 0, ("the cache carries %d SPRITEMOVEDATA_POKEMON objects") + :format(bouncers)) + check(#unresolved == 0, "every named one of them names a sheet the cache has" + .. (unresolved[1] and (" (%s does not)"):format(unresolved[1]) or "")) + check(slots == 2, ("and %d of them are the two day-care slots"):format(slots)) + if #oneFrame > 0 then + check(true, ("%d of them sit on a pre-#1748 row : World:load back-fills" + .. " them (SKIP)"):format(#oneFrame)) + else + check(true, "and every one of those sheets is two frames deep") + end + end +end + +S.finish() diff --git a/tests/gen2_pack_rows_test.lua b/tests/gen2_pack_rows_test.lua new file mode 100644 index 00000000..1d812ba2 --- /dev/null +++ b/tests/gen2_pack_rows_test.lua @@ -0,0 +1,404 @@ +-- The PACK's list row and description box, as coordinates. +-- +-- Most of what these three bugs broke is an integer the ASM names outright: +-- TMHMPocket_GetCurrentLineCoord's column 5 and the `ld bc, 3` that puts the +-- move name at column 8 (engine/items/tmhm.asm:355-403, #1695), the 1x9 cursor +-- column at (7,2) whose palette carries the red (engine/gfx/cgb_layouts.asm: +-- 723-726, #1694), and TEXTBOX_INNERY for anything printed in the description +-- box (#1725). Chrome is swapped for a recorder here, so what is asserted is +-- the write list rather than the pixels -- whether the cursor comes out RED on +-- the real screen is tests/drivers/gold_pack_rows_bug1695_test.lua's job. + +package.path = "./?.lua;" .. package.path + +-- The UI modules require love-side helpers at load time. Stub the pieces they +-- touch during construction and logic; nothing here draws. +love = love or {} +love.graphics = love.graphics or { + getColor = function() return 1, 1, 1, 1 end, + setColor = function() end, + rectangle = function() end, + print = function() end, + printf = function() end, + draw = function() end, + newQuad = function() return {} end, + newImage = function() return nil end, + getShader = function() return nil end, + setShader = function() end, + newShader = function() error("no shaders in this harness") end, + getDimensions = function() return 160, 144 end, + push = function() end, pop = function() end, + translate = function() end, scale = function() end, + circle = function() end, clear = function() end, +} +love.math = love.math or { + random = function(a, b) + if b then return a end + return a and 1 or 0.5 + end, +} +love.image = love.image or {} +love.filesystem = love.filesystem or { + load = function() return nil end, + getInfo = function() return nil end, + read = function() return nil end, + write = function() return true end, + remove = function() return true end, +} +love.timer = love.timer or { getTime = function() return 0 end } + +-- No font is loaded here, so Font.encode would warn once per unknown glyph. +require("src.core.Logger").warn = function() end + +local Chrome = require("src.ui.gen2.Chrome") +local PackGfx = require("src.ui.gen2.PackGfx") +local PackMenu = require("src.ui.gen2.PackMenu") +local Save = require("src.core.gen2.Save") + +local failures, checks = 0, 0 +local function check(name, got, want) + checks = checks + 1 + if got ~= want then + failures = failures + 1 + print(("FAIL %s: got %s, want %s"):format( + name, tostring(got), tostring(want))) + end +end + +-- ---------------------------------------------------------------- fixtures + +local ITEMS = { + POTION = { id = "POTION", name = "POTION", pocket = "ITEM", index = 17, + canToss = true, canSelect = false, fieldMenu = "ITEMMENU_PARTY", + description = "Restores HPby 20." }, + ITEMFINDER = { id = "ITEMFINDER", name = "ITEMFINDER", pocket = "KEY_ITEM", + index = 55, canToss = false, canSelect = true, + fieldMenu = "ITEMMENU_CLOSE", + description = "Checks for unseenitems in the area." }, + TM_DYNAMICPUNCH = { id = "TM_DYNAMICPUNCH", name = "TM01", pocket = "TM_HM", + index = 191, tmNumber = 1, tmLabel = "TM01", teaches = "DYNAMICPUNCH" }, + TM_HEADBUTT = { id = "TM_HEADBUTT", name = "TM02", pocket = "TM_HM", + index = 192, tmNumber = 2, tmLabel = "TM02", teaches = "HEADBUTT" }, + TM_NIGHTMARE = { id = "TM_NIGHTMARE", name = "TM50", pocket = "TM_HM", + index = 240, tmNumber = 50, tmLabel = "TM50", teaches = "NIGHTMARE" }, + HM_CUT = { id = "HM_CUT", name = "HM01", pocket = "TM_HM", index = 243, + tmNumber = 51, tmLabel = "HM01", teaches = "CUT" }, + HM_WATERFALL = { id = "HM_WATERFALL", name = "HM07", pocket = "TM_HM", + index = 249, tmNumber = 57, tmLabel = "HM07", teaches = "WATERFALL" }, + -- A cache old enough to carry neither tmLabel nor a numbered name: the row + -- has no number to print and must fall back to the item's own name. + TM_LEGACY = { id = "TM_LEGACY", name = "TM??", pocket = "TM_HM", index = 300, + teaches = "SWIFT" }, +} + +local MOVES = {} +for _, name in ipairs({ "DYNAMICPUNCH", "HEADBUTT", "NIGHTMARE", "CUT", + "WATERFALL", "SWIFT" }) do + MOVES[name] = { id = name, name = name, description = "Movetext." } +end + +local function newInput() + local input = { pressed = {} } + function input:press(...) + for _, button in ipairs({ ... }) do self.pressed[button] = true end + end + function input:wasPressed(button) + if self.pressed[button] then + self.pressed[button] = nil + return true + end + return false + end + function input:isDown() return false end + return input +end + +local function newGame(save) + return { + input = newInput(), + save = save, + options = save and save.options or Save.defaultOptions(), + data = { audio = {}, pokemon = {}, items = ITEMS, moves = MOVES }, + stack = { _items = {}, + push = function(self, s) self._items[#self._items + 1] = s end, + pop = function(self) return table.remove(self._items) end, + top = function(self) return self._items[#self._items] end, + }, + } +end + +-- ------------------------------------------------------------- the recorder +-- +-- PackMenu reaches Chrome through the module table on every call, so swapping +-- the four entry points it draws through collects the screen as a write list. + +local writes, cursors +local real = { + print = Chrome.print, cursor = Chrome.cursor, + cursorThrough = Chrome.cursorThrough, box = Chrome.box, clear = Chrome.clear, +} + +local function install() + writes, cursors = {}, {} + Chrome.print = function(text, tx, ty) + writes[#writes + 1] = { text = text, x = tx, y = ty } + end + Chrome.cursor = function(tx, ty, hollow) + cursors[#cursors + 1] = + { x = tx, y = ty, hollow = hollow or false, palette = nil } + end + Chrome.cursorThrough = function(tx, ty, palette, _invert, hollow) + cursors[#cursors + 1] = + { x = tx, y = ty, hollow = hollow or false, palette = palette } + end + Chrome.box = function() end + Chrome.clear = function() end +end + +local function restore() + Chrome.print, Chrome.cursor = real.print, real.cursor + Chrome.cursorThrough, Chrome.box, Chrome.clear = + real.cursorThrough, real.box, real.clear +end + +-- Where a string landed, as "x,y", so a miss reads as the coordinate it wanted. +local function at(text) + for _, write in ipairs(writes) do + if write.text == text then return write.x .. "," .. write.y end + end + return "absent" +end + +local function drew(text) + for _, write in ipairs(writes) do + if write.text == text then return true end + end + return false +end + +-- The cart's own PACK chrome, stubbed: PackGfx needs a real PNG to answer +-- available(), and none of that matters to where the writes land. +local RED = { { 255, 255, 255 }, { 123, 123, 255 }, { 0, 0, 255 }, { 255, 0, 0 } } +local function withPackGfx(pack) + pack.gfx = { + available = function() return true end, + draw = function() end, + colorsAt = function(_self, tx, ty) + if tx == 7 and ty >= 2 and ty <= 10 then return RED end + return nil + end, + } + return pack +end + +local function openPack(inventory, order, pocket) + local save = Save.newGame() + save.player.name = "GOLD" + save.inventory = inventory + save.bagOrder = order + local game = newGame(save) + local pack = PackMenu.new(game, { save = save, pocket = pocket }) + return withPackGfx(pack), save, game +end + +-- ------------------------------------------- the TM/HM row's number (#1695) +-- +-- TMHM_DisplayPocketItems writes the number itself: PRINTNUM_LEADINGZEROS on a +-- two-digit field for a TM, and the literal 'H' plus a PRINTNUM_LEFTALIGN +-- ordinal for an HM. The item's own name is never printed in this pocket. + +do + local pack = openPack({ + TM_DYNAMICPUNCH = 1, TM_HEADBUTT = 3, TM_NIGHTMARE = 24, + HM_CUT = 1, HM_WATERFALL = 1, TM_LEGACY = 1, + }, { + "HM_WATERFALL", "TM_NIGHTMARE", "HM_CUT", "TM_HEADBUTT", + "TM_DYNAMICPUNCH", "TM_LEGACY", + }, "TM_HM") + + check("TM01 prints as 01", pack.rows[1].tmhmLabel, "01") + check("TM02 prints as 02", pack.rows[2].tmhmLabel, "02") + check("TM50 keeps both digits", pack.rows[3].tmhmLabel, "50") + -- HM01 is TM/HM 51: PrintNum sees 51 - NUM_TMS = 1, left aligned. + check("HM01 prints as H1", pack.rows[4].tmhmLabel, "H1") + check("HM07 prints as H7", pack.rows[5].tmhmLabel, "H7") + check("a numberless TM row has no number to print", + pack.rows[6].tmhmLabel, nil) + + install() + pack.index = 1 + pack:drawPanel() + restore() + + -- hlcoord 5 for the number, `ld bc, 3` on to column 8 for the move name, and + -- the cursor gutter between them at LIST_X - 1. + check("the number sits at column 5", at("01"), "5,2") + check("the move name sits at column 8", at("DYNAMICPUNCH"), "8,2") + check("the item's own name is never printed", drew("TM01"), false) + check("nor is an invented HM prefix", drew("HM01"), false) + check("the cursor is one tile left of the name", cursors[1].x, 7) + check("and level with the row it marks", cursors[1].y, 2) + -- engine/items/tmhm.asm:392-403 -- SCREEN_WIDTH + 9 from the number's coord. + check("a TM's count is at column 17 on the row below", at("\xc3\x97 1"), "17,3") + check("the fourth row is the first HM", at("H1"), "5,8") + check("its move name shares that row", at("CUT"), "8,8") + check("and an HM prints no count", drew("\xc3\x97 1"), true) + + -- The fifth row is HM07, whose count would be the only other "× 1". + local ones = 0 + for _, write in ipairs(writes) do + if write.text == "\xc3\x97 1" then ones = ones + 1 end + end + check("exactly one count on screen, the TM's", ones, 1) +end + +-- A row with no number to print still has to draw something a person can read, +-- so the item's own name stands in rather than the row going blank. +do + local pack = openPack({ TM_LEGACY = 1 }, { "TM_LEGACY" }, "TM_HM") + install() + pack.index = 1 + pack:drawPanel() + restore() + check("a numberless row falls back to the item name", at("TM??"), "8,2") + check("and prints nothing in the number column", drew("SWIFT"), false) +end + +-- ------------------------------------------------- an ordinary pocket's row + +do + local pack = openPack({ POTION = 5 }, { "POTION" }, "ITEM") + install() + pack.index = 1 + pack:drawPanel() + restore() + check("an ITEM row starts at column 8", at("POTION"), "8,2") + check("with no number beside it", drew("01"), false) + check("and its count in the same column a TM's uses", + at("\xc3\x97 5"), "17,3") +end + +-- ------------------------------------------------ the cursor's palette (#1694) +-- +-- _CGB_PackPals fills (7,2) 1x9 with palette $3, whose colour 3 is red, and +-- that rectangle is exactly the five list rows' cursor gutter. + +do + local pack = openPack({ POTION = 5, ESCAPE_ROPE = 1 }, { "POTION" }, "ITEM") + install() + pack.index = 1 + pack:drawPanel() + restore() + check("the cursor asks the pack for its cell's palette", + cursors[1] and cursors[1].palette, RED) + local ink = cursors[1] and cursors[1].palette and cursors[1].palette[4] + check("which is the one whose colour 3 is red", + ink and table.concat(ink, ","), "255,0,0") + + -- SELECT arms a row: ScrollingMenu_PlaceCursor's hollow arrow takes the same + -- palette, so `hollow` has to survive the call. + pack.rows[2] = { id = "ESCAPE_ROPE", count = 1, name = "ESCAPE ROPE", + showCount = true } + pack.switching = 1 + pack.index = 2 + install() + pack:drawPanel() + restore() + local armed + for _, cursor in ipairs(cursors) do + if cursor.hollow then armed = cursor end + end + check("the armed row's hollow arrow is drawn", armed ~= nil, true) + check("in the cursor column", armed and armed.x, 7) + check("and through the same red palette", armed and armed.palette, RED) + + -- No pack tiles in the cache: the fallback must still draw a cursor rather + -- than nothing at all. + pack.gfx = { available = function() return false end, draw = function() end } + pack.switching = nil + pack.index = 1 + install() + pack:drawPanel() + restore() + check("a cache with no pack tiles falls back to the plain arrow", + cursors[1] and cursors[1].palette, nil) +end + +-- PackGfx flattens paletteZones to a per-cell lookup; the cursor zone is the +-- one PackMenu reads, so check the rectangle resolves over its whole height. +do + local pals = { { { 1, 1, 1 } }, { { 2, 2, 2 } }, { { 3, 3, 3 } }, RED } + local gfx = PackGfx.new({ pack = { + palettes = pals, + paletteZones = { { 0, 0, 10, 1, 2 }, { 7, 2, 1, 9, 4 } }, + } }) + check("the cursor column starts at row 2", gfx:colorsAt(7, 2), RED) + check("and runs nine rows down to row 10", gfx:colorsAt(7, 10), RED) + check("row 11 is outside it", gfx:colorsAt(7, 11), pals[1]) + check("so is the name column", gfx:colorsAt(8, 2), pals[1]) + check("the header keeps its own zone", gfx:colorsAt(0, 0), pals[2]) +end + +-- ------------------------------------------ the description box's rows (#1725) +-- +-- Every text box in the game starts at TEXTBOX_INNERY and steps two rows a +-- line. The item description already did; a message printed over the same box +-- did not. + +do + local pack = openPack({ ITEMFINDER = 1 }, { "ITEMFINDER" }, "KEY_ITEM") + + install() + pack.index = 1 + pack:drawPanel() + restore() + check("an item description starts on row 14", + at("Checks for unseen"), "1,14") + check("and its second line is row 16", at("items in the area."), "1,16") + + -- RegisteredItemText, the case the report filed. + pack.message = { "Registered the", "ITEMFINDER." } + install() + pack:drawPanel() + restore() + check("a message's first line shares the description's row", + at("Registered the"), "1,14") + check("and its second line lands on row 16", at("ITEMFINDER."), "1,16") + + -- _AskThrowAwayText, the other two-line message in this box. + pack.message = { "Throw away how", "many?" } + install() + pack:drawPanel() + restore() + check("the toss question starts on row 14", at("Throw away how"), "1,14") + check("with a blank row under it", at("many?"), "1,16") + + -- OakThisIsntTheTimeText is three lines, which two double-spaced rows cannot + -- hold: those pack one row apart so the last is inside the box. + pack.message = { "OAK: {PLAYER}!", "This isn't the", "time to use that!" } + install() + pack:drawPanel() + restore() + check("a three-line message starts a row higher", at("OAK: GOLD!"), "1,13") + check("its second line is row 14", at("This isn't the"), "1,14") + check("and its third clears the bottom border", + at("time to use that!"), "1,15") + + -- The yes/no prompt is printed in the same box by the same path. + pack.message = nil + pack.confirm = { prompt = { "Throw away 1", "POTION(S)?" }, choice = 1 } + install() + pack:drawPanel() + restore() + check("the toss confirmation uses the same two rows", + at("Throw away 1"), "1,14") + check("and the same gap", at("POTION(S)?"), "1,16") +end + +print(("gen2 pack rows: %d checks, %d failures"):format(checks, failures)) +-- Raise rather than os.exit: tests/run_tests.lua dofiles this file, so an +-- exit here takes the whole tier down with it and silently skips every +-- suite listed after this one (see tests/harness.lua's T.suite note). +if failures > 0 then + error(("%d assertion(s) failed"):format(failures), 0) +end diff --git a/tests/gen2_reflect_overflow_test.lua b/tests/gen2_reflect_overflow_test.lua new file mode 100644 index 00000000..12fed752 --- /dev/null +++ b/tests/gen2_reflect_overflow_test.lua @@ -0,0 +1,183 @@ +-- TruncateHL_BC (../pokegold/engine/battle/effect_commands.asm:2625, +-- ../pokecrystal/engine/battle/effect_commands.asm:2614). +-- +-- luajit tests/gen2_reflect_overflow_test.lua +-- +-- ROM-free. The cart hands BattleCommand_DamageCalc one-byte stats, so a +-- 16-bit attack/defence pair is shifted right twice -- both together, so the +-- ratio survives -- and the low byte taken. Gold runs that pass exactly once +-- and keeps whatever the low byte then holds, so Reflect or Light Screen on a +-- 512+ defence pushes the doubled value to 1024 and the truncated byte wraps +-- to 0; DamageCalc's minimum-defence check turns that into 1 and the hit caps. +-- Crystal repeats the pass until both stats fit, which is the fix CT-10 names +-- reflectOverflow. Crystal kept the old arithmetic under LINK_COLOSSEUM; the +-- Gen 2 battle engine here has no link mode, so only the single-player halves +-- are pinned. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 reflect overflow") +local check, eq = S.check, S.eq + +local GameVersion = require("src.core.GameVersion") +local Damage = require("src.battle.gen2.Damage") + +local restore = GameVersion.get() + +-- One NORMAL physical hit with no type table, no STAB and the damage roll +-- pinned at 100%, so the only moving part is the truncated defence. +local function hit(version, defense, screen) + GameVersion.set(version) + return (Damage.calc({ + level = 50, power = 100, moveType = "NORMAL", + attacker = { attack = 200 }, + defender = { defense = defense }, + screen = screen, variation = 100, + })) +end + +local function truncate(version, attack, defense) + GameVersion.set(version) + return Damage.truncateStats(attack, defense, + GameVersion.fixes().reflectOverflow == true) +end + +-- --------------------------------------------------------- the gate itself + +do + eq(GameVersion.fixes("gold").reflectOverflow, nil, + "Gold keeps the cart's single truncation pass") + eq(GameVersion.fixes("silver").reflectOverflow, nil, + "Silver keeps it too") + eq(GameVersion.fixes("crystal").reflectOverflow, true, + "Crystal is the version that loops") +end + +-- ------------------------------------------------- below the truncate line + +do + -- Both stats already fit in a byte, so TruncateHL_BC returns at once and + -- every ordinary battle is untouched by either arm. + local a, d = truncate("gold", 200, 100) + eq(a, 200, "an 8-bit attack passes through") + eq(d, 100, "an 8-bit defence passes through") + eq(hit("gold", 100, false), 90, "Gold, defence 100, no screen") + eq(hit("crystal", 100, false), 90, "Crystal agrees") + eq(hit("gold", 100, true), 46, "Gold, defence 100 doubled to 200") + eq(hit("crystal", 100, true), 46, "Crystal agrees") +end + +-- ----------------------------------------- one pass, which both games share + +do + -- 511 doubled is 1022; one >>2 gives 255, which fits, so Crystal's loop + -- never runs a second time and the two versions still agree. This is the + -- largest defence Reflect can double without the wrap. + local ga, gd = truncate("gold", 200, 511 * 2) + local ca, cd = truncate("crystal", 200, 511 * 2) + eq(gd, 255, "1022 >> 2 is 255, the last value that fits") + eq(ga, 50, "the attack is shifted with it, 200 >> 2") + eq(cd, 255, "Crystal's loop exits on the same pass") + eq(ca, 50, "and leaves the attack alone as well") + eq(hit("gold", 511, true), 10, "Gold, defence 511 doubled") + eq(hit("crystal", 511, true), 10, "Crystal, defence 511 doubled") +end + +do + -- A big defence with no screen cannot reach 1024 on its own (stats cap at + -- 999), so the gate must not move any unscreened hit. + for _, defense in ipairs({ 256, 400, 512, 700, 999 }) do + eq(hit("gold", defense, false), hit("crystal", defense, false), + ("defence %d without a screen is version-independent"):format(defense)) + end + eq(hit("gold", 999, false), 10, "defence 999 unscreened, both versions") +end + +-- ------------------------------------------------------------- the wrap + +do + -- 512 doubled is exactly 1024. Gold: 1024 >> 2 = 256, low byte 0, and + -- DamageCalc's `ld c, 1` turns that into a defence of 1. Crystal: a second + -- pass takes 256 -> 64 and the attack 50 -> 12. + local ga, gd = truncate("gold", 200, 1024) + eq(gd, 0, "Gold truncates 1024 to a defence byte of 0") + eq(ga, 50, "Gold's attack byte after the single pass") + local ca, cd = truncate("crystal", 200, 1024) + eq(cd, 64, "Crystal shifts again, 256 >> 2") + eq(ca, 12, "and the attack with it, 50 >> 2") + + -- 22 * 100 * 50 / 1 / 50 = 2200, capped to 997 + 2. + eq(hit("gold", 512, true), 999, "Gold's Reflect caps the hit at 999") + -- 22 * 100 * 12 / 64 / 50 = 8, + 2. + eq(hit("crystal", 512, true), 10, "Crystal's Reflect keeps the hit at 10") + check(hit("gold", 512, false) == 19, + "and the same defence unscreened is 19 on both") + check(hit("gold", 512, true) > hit("gold", 512, false), + "Gold's Reflect makes the defender take MORE damage") + check(hit("crystal", 512, true) < hit("crystal", 512, false), + "Crystal's Reflect halves it, as it should") +end + +do + -- The worst case the cart can build: a 999 defence doubled to 1998. + -- Gold: 1998 >> 2 = 499, low byte 243. Crystal: 499 >> 2 = 124, attack 12. + local ga, gd = truncate("gold", 200, 999 * 2) + eq(gd, 243, "Gold wraps 499 to 243") + eq(ga, 50, "Gold's attack byte") + local ca, cd = truncate("crystal", 200, 999 * 2) + eq(cd, 124, "Crystal shifts 499 to 124") + eq(ca, 12, "and the attack to 12") + eq(hit("gold", 999, true), 11, "Gold: Reflect on a 999 defence deals 11") + eq(hit("crystal", 999, true), 6, "Crystal: the same hit deals 6") + eq(hit("gold", 999, false), 10, "unscreened it is 10, so the screen HURTS") +end + +-- ------------------------------------------------ the minimum-1 arms + +do + -- `ld a, c / or b / jr nz / inc c` and its hl twin: a stat that shifts down + -- to zero is forced back to 1 before the next pass, on both versions. + local ga, gd = truncate("gold", 3, 2000) + eq(ga, 1, "Gold: an attack of 3 shifts to 0 and is forced to 1") + eq(gd, 244, "Gold: 2000 >> 2 = 500, low byte 244") + local ca, cd = truncate("crystal", 3, 2000) + eq(ca, 1, "Crystal: the forced 1 shifts to 0 and is forced again") + eq(cd, 125, "Crystal: 500 >> 2 = 125") +end + +-- ------------------------------------------------------------- the override + +do + -- The gate is readable per call, which is where a LINK_COLOSSEUM carve-out + -- would hang if the Gen 2 engine ever grows one. + GameVersion.set("crystal") + local bugged = Damage.calc({ + level = 50, power = 100, moveType = "NORMAL", + attacker = { attack = 200 }, defender = { defense = 512 }, + screen = true, variation = 100, reflectOverflowFixed = false, + }) + eq(bugged, 999, "Crystal forced onto the bugged arm matches Gold") + GameVersion.set("gold") + local fixed = Damage.calc({ + level = 50, power = 100, moveType = "NORMAL", + attacker = { attack = 200 }, defender = { defense = 512 }, + screen = true, variation = 100, reflectOverflowFixed = true, + }) + eq(fixed, 10, "Gold forced onto the fixed arm matches Crystal") +end + +-- ------------------------------------------------------- the 999 stat cap + +do + -- ApplyStatLevelMultiplier caps at MAX_STAT_VALUE before anything doubles + -- it (../pokecrystal/engine/battle/core.asm:6739), which is what bounds the + -- doubled defence at 1998 rather than letting +6 stages run past it. + eq(Damage.applyStage(999, 6), 999, "a +6 stage cannot pass 999") + eq(Damage.applyStage(250, 6), 999, "250 x4 is 1000, capped to 999") + eq(Damage.applyStage(249, 6), 996, "249 x4 stays below the cap") + eq(Damage.applyStage(1, -6), 1, "and a stat never reaches 0") +end + +GameVersion.set(restore) + +S.finish() diff --git a/tests/gen2_rom_text_test.lua b/tests/gen2_rom_text_test.lua index 8ffefcc5..73cf6d7a 100644 --- a/tests/gen2_rom_text_test.lua +++ b/tests/gen2_rom_text_test.lua @@ -33,7 +33,7 @@ end -- A label the list names but the symbol table cannot place would fail the -- import at the Dialogue stage rather than at generation time, so the pairing -- is asserted here instead. -for _, edition in ipairs({ "gold", "silver" }) do +for _, edition in ipairs({ "gold", "silver", "crystal" }) do local data = manifest("tools/rom_manifest_" .. edition .. ".json") local labels = (data.text or {}).labels or {} check((data.text or {}).labels ~= nil, @@ -64,7 +64,7 @@ for _, edition in ipairs({ "gold", "silver" }) do end end --- Both editions describe the same strings; only the addresses move. +-- Gold and Silver describe the same strings; only the addresses move. do local gold = (manifest("tools/rom_manifest_gold.json").text or {}).labels or {} local silver = @@ -75,6 +75,20 @@ do if silver[index] ~= label then mismatch = label; break end end eq(mismatch, nil, "and the same labels in the same order") + + local crystal = + (manifest("tools/rom_manifest_crystal.json").text or {}).labels or {} + check(#crystal > #gold, + ("Crystal names more labels than Gold (%d vs %d)"):format(#crystal, #gold)) + local named = {} + for _, label in ipairs(gold) do named[label] = true end + local extra = 0 + for _, label in ipairs(crystal) do + if not named[label] then extra = extra + 1 end + end + check(extra > 0, + ("and %d of them are Crystal-only, so it is not a Gold alias") + :format(extra)) end -- ---- the slots RomText fills ---------------------------------------------- diff --git a/tests/gen2_save_test.lua b/tests/gen2_save_test.lua index 6493b63f..a2a3d5d4 100644 --- a/tests/gen2_save_test.lua +++ b/tests/gen2_save_test.lua @@ -117,16 +117,34 @@ check("a Gen 2 version already on the table is kept", Save.normalize({ version = "silver", player = {} }).version, "silver") check("and a Gen 1 one is replaced by the running edition", Save.normalize({ version = "red", player = {} }).version, "gold") +check("and Crystal is a Gen 2 version, so it keeps its own", + Save.normalize({ version = "crystal", player = {} }).version, "crystal") +-- "nonesuch" is the deliberate never-a-game token: "gold" stood here until +-- Gold shipped, "crystal" until Crystal did. This one never becomes a game. check("as is a version this engine does not have", - Save.normalize({ version = "crystal", player = {} }).version, "gold") + Save.normalize({ version = "nonesuch", player = {} }).version, "gold") check("party exists", type(sparse.party), "table") check("inventory exists", type(sparse.inventory), "table") check("pokedex seen exists", type(sparse.pokedex.seen), "table") check("phone book exists", type(sparse.phoneContacts), "table") check("play time exists", type(sparse.playTime), "table") check("name defaulted", sparse.player.name, "GOLD") +-- wPlayerGender is zeroed by _ResetWRAM and Gold never writes it, so a +-- normalized save has to come back with the field present rather than nil. +check("gender defaulted", sparse.player.gender, "male") +check("and a recorded gender is kept", + Save.normalize({ player = { gender = "female" } }).player.gender, "female") check("normalize rejects a non-table", Save.normalize("nope"), nil) +-- The blank-name fallback is table driven so Crystal can be a row rather than +-- a third arm of a two-way test (data/player_names.asm). +check("Gold's PlayerNameArray row", Save.defaultPlayerName("gold"), "GOLD") +check("Silver's", Save.defaultPlayerName("silver"), "SILVER") +check("Crystal's MalePlayerNameArray row", + Save.defaultPlayerName("crystal"), "CHRIS") +check("and an unknown edition falls back to Gold's", + Save.defaultPlayerName("nonesuch"), "GOLD") + -- Money and coins are clamped to their caps, and a negative is floored at 0. local rich = Save.normalize({ player = { money = 9999999, coins = 99999 } }) check("money capped", rich.player.money, Save.MAX_MONEY) diff --git a/tests/gen2_sleep_counter_test.lua b/tests/gen2_sleep_counter_test.lua new file mode 100644 index 00000000..20cc97d6 --- /dev/null +++ b/tests/gen2_sleep_counter_test.lua @@ -0,0 +1,213 @@ +-- The counter a sleep MOVE writes: BattleCommand_SleepTarget's .random_loop +-- (engine/battle/effect_commands.asm:3591-3598), which masks with SLP_MASK, +-- rerolls 0 and SLP_MASK and only then does `inc a` -- so 2-7, never 1 (#1707). +-- Rest and the disobedience nap keep their own counters and are the controls. +-- +-- luajit tests/gen2_sleep_counter_test.lua -- ROM-free + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 sleep counter") +local check, eq = S.check, S.eq + +local Battle = require("src.battle.gen2.Battle") +local Mon = require("src.battle.gen2.Mon") + +-- ---------------------------------------------------------------- fixtures + +local TYPES = { + NORMAL = { id = "NORMAL", index = 0, category = "physical" }, + FLYING = { id = "FLYING", index = 2, category = "physical" }, + FIRE = { id = "FIRE", index = 20, category = "special" }, + GRASS = { id = "GRASS", index = 22, category = "special" }, + PSYCHIC = { id = "PSYCHIC", index = 24, category = "special" }, +} + +local MATCHUPS = {} + +local MOVES = { + TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, type = "NORMAL", + accuracy = 100, pp = 35, effect = "EFFECT_NORMAL_HIT" }, + -- data/moves/moves.asm: SPORE and HYPNOSIS are both EFFECT_SLEEP. + SPORE = { id = "SPORE", name = "SPORE", power = 0, type = "GRASS", + accuracy = 100, pp = 15, effect = "EFFECT_SLEEP" }, + HYPNOSIS = { id = "HYPNOSIS", name = "HYPNOSIS", power = 0, + type = "PSYCHIC", accuracy = 100, pp = 20, effect = "EFFECT_SLEEP" }, + REST = { id = "REST", name = "REST", power = 0, type = "PSYCHIC", + accuracy = 100, pp = 10, effect = "EFFECT_HEAL" }, +} + +local GROWTH = { + GROWTH_MEDIUM_SLOW = { numerator = 6, denominator = 5, squared = -15, + linear = 100, constant = 140 }, +} + +local POKEMON = { + growthRates = GROWTH, + -- Fast enough to move first against the PIDGEY below at these levels. + CYNDAQUIL = { + id = "CYNDAQUIL", index = 155, name = "CYNDAQUIL", + baseStats = { hp = 39, attack = 52, defense = 43, speed = 65, + specialAttack = 60, specialDefense = 50 }, + types = { "FIRE", "FIRE" }, catchRate = 45, baseExp = 65, + growthRate = "GROWTH_MEDIUM_SLOW", genderRatio = 31, + levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {}, + }, + PIDGEY = { + id = "PIDGEY", index = 16, name = "PIDGEY", + baseStats = { hp = 40, attack = 45, defense = 40, speed = 56, + specialAttack = 35, specialDefense = 35 }, + types = { "NORMAL", "FLYING" }, catchRate = 255, baseExp = 55, + growthRate = "GROWTH_MEDIUM_SLOW", genderRatio = 127, + levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {}, + }, +} + +local DATA = { + pokemon = POKEMON, + moves = MOVES, + type_chart = { types = TYPES, matchups = MATCHUPS }, + items = {}, +} + +local perfect = { attack = 15, defense = 15, speed = 15, special = 15 } +perfect.hp = Mon.hpDV(perfect) + +-- BattleRandom(n) -> 0..n-1, pinned to one value. +local function zeroRandom() return 0 end +local function fixedRandom(value) + return function(n) return math.min(value, (n or 1) - 1) end +end + +local function newBattle(opts) + opts = opts or {} + local player = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }) + player.moves = { + { id = "SPORE", pp = 15, maxPp = 15 }, + { id = "TACKLE", pp = 35, maxPp = 35 }, + } + local wild = Mon.new(DATA, "PIDGEY", 5, { dvs = perfect }) + wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + local battle = Battle.new({ + data = DATA, + party = { player }, + wild = wild, + random = opts.random or zeroRandom, + }) + battle:takeEvents() + return battle, player, wild +end + +local function texts(events) + local out = {} + for _, event in ipairs(events or {}) do + if event.text then out[#out + 1] = event.text end + end + return out +end + +local function found(events, needle) + for _, text in ipairs(texts(events)) do + if text:find(needle, 1, true) then return true end + end + return false +end + +-- ------------------------------------------------------- the roll's range + +-- The whole roll space against .random_loop's window: 2..7 inclusive, six +-- outcomes, no 1 and no 8 (effect_commands.asm:3591-3598). +do + local seen, low, high = {}, math.huge, -math.huge + for roll = 0, 7 do + local battle, _, wild = newBattle({ random = fixedRandom(roll) }) + battle:useMove(battle.player, wild, "SPORE") + eq(wild.status, "sleep", "roll " .. roll .. " slept the target") + local turns = wild.statusTurns or 0 + seen[turns] = true + low, high = math.min(low, turns), math.max(high, turns) + end + eq(low, 2, "the shortest sleep a sleep move can roll is two turns") + eq(high, 7, "the longest is seven, SLP_MASK itself never being written") + check(not seen[1], "one turn is unreachable, so no free-action sleep") + check(not seen[0], "and zero is unreachable") + local count = 0 + for _ in pairs(seen) do count = count + 1 end + eq(count, 6, "six distinct counters, the width of the cart's window") +end + +do + local battle, _, wild = newBattle() + battle:useMove(battle.player, wild, "SPORE") + eq(wild.statusTurns, 2, "the lowest roll writes two, not one") +end + +-- ------------------------------------------- slept by a faster foe (#1707) + +-- CheckPlayerTurn decrements before the sleeper acts, so the round the sleep +-- landed in is the one a 1 would have cost nothing. +do + local battle, player, wild = newBattle() + local hpBefore = player.hp + local events = battle:takeTurn({ kind = "move", move = "SPORE" }) + eq(wild.status, "sleep", "the slower foe is asleep") + check(found(events, "is fast asleep!"), + "and spends the same round's action asleep") + check(not found(events, "woke up!"), + "it cannot wake in the round it was slept") + eq(wild.statusTurns, 1, "one turn of the counter was spent") + eq(player.hp, hpBefore, "so the sleeper landed no attack of its own") +end + +-- The counter still runs out on the next round. +do + local battle, _, wild = newBattle() + battle:takeTurn({ kind = "move", move = "SPORE" }) + local events = battle:takeTurn({ kind = "move", move = "TACKLE" }) + check(found(events, "woke up!"), "the next round wakes it") + eq(wild.status, nil, "and clears the status byte") + eq(wild.statusTurns, nil, "and the counter with it") +end + +-- Hypnosis shares EFFECT_SLEEP, so it shares the window. +do + local battle, _, wild = newBattle() + battle.player.moves[1] = { id = "HYPNOSIS", pp = 20, maxPp = 20 } + battle:useMove(battle.player, wild, "HYPNOSIS") + eq(wild.status, "sleep", "hypnosis slept the target") + eq(wild.statusTurns, 2, "off the same .random_loop") +end + +-- ------------------------------------------------------------- the controls + +-- Rest writes REST_SLEEP_TURNS + 1 straight into the status byte, no roll +-- (effect_commands.asm:6015-6027). +do + local battle, player, wild = newBattle() + player.moves = { { id = "REST", pp = 10, maxPp = 10 }, + { id = "TACKLE", pp = 35, maxPp = 35 } } + player.hp = 1 + battle:useMove(player, wild, "REST") + eq(player.status, "sleep", "rest slept the user") + eq(player.statusTurns, 3, "rest is still exactly three") + eq(player.hp, player.maxHp, "and still a full heal") + + local events = battle:takeTurn({ kind = "move", move = "TACKLE" }) + check(found(events, "is fast asleep!"), "the first turn after rest is lost") + events = battle:takeTurn({ kind = "move", move = "TACKLE" }) + check(found(events, "is fast asleep!"), "so is the second") + events = battle:takeTurn({ kind = "move", move = "TACKLE" }) + check(found(events, "woke up!"), "and it acts on the third") +end + +-- The disobedience nap rerolls only 0 and has no `inc a`, so 1 is legal +-- there (effect_commands.asm:778-785) and the two rolls are not shared. +do + local record = Battle.STATUSES.sleep + check(type(record.onInflict) == "function", "sleep still has an onInflict") + local mon = {} + record.onInflict({ random = zeroRandom }, mon) + eq(mon.statusTurns, 2, "the status record's own floor is two") +end + +S.finish() diff --git a/tests/gen2_sprites_test.lua b/tests/gen2_sprites_test.lua index 78c77ab0..6fecfc95 100644 --- a/tests/gen2_sprites_test.lua +++ b/tests/gen2_sprites_test.lua @@ -172,10 +172,13 @@ else "which ReadMonMenuIcon turns into PIKACHU's menu icon") eq(doll.image, "assets/generated/icons/gen2/pikachu.png", "so it draws from the icon sheet extractIcons already writes") - -- FacingStepDown0 is the only OAM set a still mon object ever uses, and it - -- is tiles $00..$03: the icon's first frame. The second is the party - -- menu's bob and never reaches the map. - eq(doll.frames, 1, "one frame: _DoesSpriteHaveFacings sends a mon down-only") + -- OBJECT_ACTION_BOUNCE swaps FacingStepDown0's tiles $00..$03 for + -- FacingStepUp0's $04..$07, so both icon frames reach the map (#1748). + if doll.frames == 1 then + check(true, "cache predates #1748 : re-import for the mon bounce (SKIP)") + else + eq(doll.frames, 2, "two frames: SetFacingBounce swaps the icon's pair") + end check(not doll.walker, "and a doll does not walk") eq(doll.paletteId, 0, "_GetSpritePalette answers 0 for every mon sprite") eq(doll.palette, "PAL_OW_RED", "which is PAL_OW_RED in the MapObjectPals set") diff --git a/tests/gen2_timed_heal_test.lua b/tests/gen2_timed_heal_test.lua new file mode 100644 index 00000000..361b8fc8 --- /dev/null +++ b/tests/gen2_timed_heal_test.lua @@ -0,0 +1,315 @@ +-- Morning Sun / Synthesis / Moonlight: BattleCommand_TimeBasedHealContinue +-- (engine/battle/effect_commands.asm:6374) walks a four-rung .Multipliers +-- table from the half rung -- the wrong wTimeOfDay steps down, sun up, rain +-- and sandstorm down (#1751). World:startBattle is where the hour comes from. +-- +-- luajit tests/gen2_timed_heal_test.lua -- ROM-free + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 timed heal") +local check, eq = S.check, S.eq + +local Battle = require("src.battle.gen2.Battle") +local Effects = require("src.battle.gen2.Effects") +local Mon = require("src.battle.gen2.Mon") + +-- constants/ram_constants.asm:137-140, wTimeOfDay: MORN_F 0, DAY_F 1, NITE_F 2, +-- DARKNESS_F 3. +local MORN, DAY, NITE, DARK = 0, 1, 2, 3 + +-- ---------------------------------------------------------------- fixtures + +local TYPES = { + NORMAL = { id = "NORMAL", index = 0, category = "physical" }, + FLYING = { id = "FLYING", index = 2, category = "physical" }, + GRASS = { id = "GRASS", index = 22, category = "special" }, + WATER = { id = "WATER", index = 21, category = "special" }, +} + +local MOVES = { + TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, type = "NORMAL", + accuracy = 100, pp = 35, effect = "EFFECT_NORMAL_HIT" }, + -- data/moves/moves.asm: three 5 PP status moves that differ only in effect. + SYNTHESIS = { id = "SYNTHESIS", name = "SYNTHESIS", power = 0, + type = "GRASS", accuracy = 100, pp = 5, effect = "EFFECT_SYNTHESIS" }, + MORNING_SUN = { id = "MORNING_SUN", name = "MORNING SUN", power = 0, + type = "NORMAL", accuracy = 100, pp = 5, effect = "EFFECT_MORNING_SUN" }, + MOONLIGHT = { id = "MOONLIGHT", name = "MOONLIGHT", power = 0, + type = "NORMAL", accuracy = 100, pp = 5, effect = "EFFECT_MOONLIGHT" }, + SUNNY_DAY = { id = "SUNNY_DAY", name = "SUNNY DAY", power = 0, + type = "FIRE", accuracy = 100, pp = 5, effect = "EFFECT_SUNNY_DAY" }, + RAIN_DANCE = { id = "RAIN_DANCE", name = "RAIN DANCE", power = 0, + type = "WATER", accuracy = 100, pp = 5, effect = "EFFECT_RAIN_DANCE" }, +} + +local GROWTH = { + GROWTH_MEDIUM_SLOW = { numerator = 6, denominator = 5, squared = -15, + linear = 100, constant = 140 }, +} + +local POKEMON = { + growthRates = GROWTH, + CHIKORITA = { + id = "CHIKORITA", index = 152, name = "CHIKORITA", + baseStats = { hp = 45, attack = 49, defense = 65, speed = 45, + specialAttack = 49, specialDefense = 65 }, + types = { "GRASS", "GRASS" }, catchRate = 45, baseExp = 64, + growthRate = "GROWTH_MEDIUM_SLOW", genderRatio = 31, + levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {}, + }, + PIDGEY = { + id = "PIDGEY", index = 16, name = "PIDGEY", + baseStats = { hp = 40, attack = 45, defense = 40, speed = 56, + specialAttack = 35, specialDefense = 35 }, + types = { "NORMAL", "FLYING" }, catchRate = 255, baseExp = 55, + growthRate = "GROWTH_MEDIUM_SLOW", genderRatio = 127, + levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {}, + }, +} + +local DATA = { + pokemon = POKEMON, + moves = MOVES, + type_chart = { types = TYPES, matchups = {} }, + items = {}, +} + +local perfect = { attack = 15, defense = 15, speed = 15, special = 15 } +perfect.hp = Mon.hpDV(perfect) + +local function zeroRandom() return 0 end + +local function newBattle(opts) + opts = opts or {} + local player = Mon.new(DATA, "CHIKORITA", 20, { dvs = perfect }) + player.moves = { + { id = "SYNTHESIS", pp = 5, maxPp = 5 }, + { id = "MORNING_SUN", pp = 5, maxPp = 5 }, + { id = "MOONLIGHT", pp = 5, maxPp = 5 }, + { id = "SUNNY_DAY", pp = 5, maxPp = 5 }, + } + local wild = Mon.new(DATA, "PIDGEY", 5, { dvs = perfect }) + wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + local battle = Battle.new({ + data = DATA, + party = { player }, + wild = wild, + random = zeroRandom, + timeOfDay = opts.timeOfDay, + }) + battle:takeEvents() + return battle, player, wild +end + +-- ------------------------------------------------------------- the ladder + +local frac = Effects.timeBasedHealFraction +check(type(frac) == "function", + "Effects models the whole command, not just the weather half") + +-- .Multipliers itself (effect_commands.asm:6450-6454). +local RUNGS = { 1 / 8, 1 / 4, 1 / 2, 1 } +eq(Effects.HEAL_MULTIPLIERS[1], RUNGS[1], "rung 0 is an eighth") +eq(Effects.HEAL_MULTIPLIERS[2], RUNGS[2], "rung 1 is a quarter") +eq(Effects.HEAL_MULTIPLIERS[3], RUNGS[3], "rung 2 is a half, the default") +eq(Effects.HEAL_MULTIPLIERS[4], RUNGS[4], "rung 3 is the whole bar") + +-- The `ld b, MORN_F / DAY_F / NITE_F` each of the three entry points loads +-- (effect_commands.asm:6362-6371). +eq(Effects.SUN_HEAL.EFFECT_MORNING_SUN, MORN, "Morning Sun wants MORN") +eq(Effects.SUN_HEAL.EFFECT_SYNTHESIS, DAY, "Synthesis wants DAY") +eq(Effects.SUN_HEAL.EFFECT_MOONLIGHT, NITE, "Moonlight wants NITE") + +-- The six cells that matter, read as Synthesis: its own hour on the top row, +-- any other hour on the bottom. +eq(frac(nil, DAY, DAY), 1 / 2, "right hour, clear sky: a half") +eq(frac("sun", DAY, DAY), 1, "right hour in sun: the whole bar") +eq(frac("rain", DAY, DAY), 1 / 4, "right hour in rain: a quarter") +eq(frac(nil, DAY, NITE), 1 / 4, "wrong hour, clear sky: a quarter") +eq(frac("sun", DAY, NITE), 1 / 2, "wrong hour in sun: a half") +eq(frac("rain", DAY, NITE), 1 / 8, "wrong hour in rain: an eighth") + +-- Sandstorm takes the same `dec c / dec c` arm rain does. +eq(frac("sandstorm", DAY, DAY), 1 / 4, "sandstorm is rain's rung") +eq(frac("sandstorm", DAY, NITE), 1 / 8, "at either hour") + +-- Every hour that is not the move's own is the wrong one, DARKNESS included. +eq(frac(nil, DAY, MORN), 1 / 4, "morning is the wrong hour for Synthesis") +eq(frac(nil, DAY, DARK), 1 / 4, "and so is DARKNESS") +eq(frac(nil, MORN, MORN), 1 / 2, "Morning Sun at dawn is the top row") +eq(frac(nil, MORN, DAY), 1 / 4, "Morning Sun in the afternoon is not") +eq(frac(nil, NITE, NITE), 1 / 2, "Moonlight at night is the top row") +eq(frac(nil, NITE, DAY), 1 / 4, "Moonlight at noon is not") + +-- wTimeOfDay by name, the spelling World keeps. +eq(frac(nil, DAY, "DAY"), 1 / 2, "the DAY spelling reads as DAY_F") +eq(frac(nil, DAY, "NITE"), 1 / 4, "and NITE as NITE_F") +eq(frac(nil, NITE, "NITE"), 1 / 2, "Moonlight likewise") + +-- `ld a, [wLinkMode] / and a / jr nz, .Weather` (effect_commands.asm:6396-6399): +-- a battle with no clock skips the time term rather than failing the hour. +eq(frac(nil, DAY, nil), 1 / 2, "no clock, no time term") +eq(frac("sun", DAY, nil), 1, "the weather half still walks") + +-- Gen 3's 2/3 is not on this ladder anywhere. +do + local offLadder = {} + for _, weather in ipairs({ "none", "sun", "rain", "sandstorm" }) do + for _, wants in ipairs({ MORN, DAY, NITE }) do + for hour = MORN, DARK do + local got = frac(weather ~= "none" and weather or nil, wants, hour) + local onLadder = false + for _, rung in ipairs(RUNGS) do + if got == rung then onLadder = true end + end + if not onLadder then + offLadder[#offLadder + 1] = ("%s/%d/%d=%s") + :format(weather, wants, hour, tostring(got)) + end + end + end + end + eq(#offLadder, 0, + "every cell lands on a .Multipliers rung: " .. table.concat(offLadder, " ")) +end +check(Effects.weatherHealFraction == nil + or math.abs(Effects.weatherHealFraction("sun") - 2 / 3) > 0.0001, + "and nothing still answers Gen 3's two thirds in sun") + +-- ------------------------------------------------------- through a battle + +-- wTimeOfDay reaches the handler (effect_commands.asm:6401-6404). +do + local battle = newBattle({ timeOfDay = NITE }) + eq(battle.timeOfDay, NITE, "Battle.new carries wTimeOfDay") +end + +local function healedBy(move, timeOfDay, weather) + local battle, player = newBattle({ timeOfDay = timeOfDay }) + battle.weather = weather + battle.weatherTurns = weather and 5 or nil + player.hp = math.floor(player.maxHp / 2) + local before = player.hp + battle:useMove(player, battle.enemy, move) + return player.hp - before, player.maxHp, battle:takeEvents() +end + +do + local healed, maxHp = healedBy("SYNTHESIS", NITE) + eq(healed, math.floor(maxHp / 4), "Synthesis at night restores a quarter") + check(healed ~= math.floor(maxHp / 2), + "which is not the flat half the unfixed move gave") +end + +do + local healed, maxHp, events = healedBy("SYNTHESIS", DAY) + eq(healed, math.floor(maxHp / 2), "Synthesis in the day restores a half") + local said = false + for _, event in ipairs(events) do + if event.text and event.text:find("regained health!", 1, true) then + said = true + end + end + check(said, "and still says so") +end + +-- Sun on the top rung is GetMaxHP, not a bigger fraction, so the bar fills +-- from wherever it started rather than from half. +do + local battle, player = newBattle({ timeOfDay = DAY }) + battle.weather, battle.weatherTurns = "sun", 5 + player.hp = 1 + battle:useMove(player, battle.enemy, "SYNTHESIS") + eq(player.hp, player.maxHp, + "Synthesis in the day under Sunny Day fills the bar from 1 HP") + + battle, player = newBattle({ timeOfDay = DAY }) + battle.weather, battle.weatherTurns = "sun", 5 + player.hp = math.floor(player.maxHp / 2) + battle:useMove(player, battle.enemy, "SYNTHESIS") + eq(player.hp, player.maxHp, "and from half") +end + +do + local healed, maxHp = healedBy("SYNTHESIS", NITE, "rain") + eq(healed, math.floor(maxHp / 8), "Synthesis at night in rain is an eighth") +end + +do + local healed, maxHp = healedBy("MOONLIGHT", NITE) + eq(healed, math.floor(maxHp / 2), "Moonlight at night restores a half") + healed = healedBy("MOONLIGHT", DAY) + eq(healed, math.floor(maxHp / 4), "and a quarter at noon") + healed = healedBy("MORNING_SUN", MORN) + eq(healed, math.floor(maxHp / 2), "Morning Sun at dawn restores a half") + healed = healedBy("MORNING_SUN", NITE) + eq(healed, math.floor(maxHp / 4), "and a quarter at night") +end + +-- At NITE only Moonlight is on its top rung. +do + local night = {} + for _, move in ipairs({ "MORNING_SUN", "SYNTHESIS", "MOONLIGHT" }) do + night[move] = healedBy(move, NITE) + end + check(night.MOONLIGHT > night.SYNTHESIS, + "Moonlight beats Synthesis at night") + eq(night.MORNING_SUN, night.SYNTHESIS, + "and the two daytime moves tie below it") +end + +-- effect_commands.asm:6443-6448: a full-HP user gets HPIsFullText and no heal, +-- whatever the clock says. +do + local battle, player = newBattle({ timeOfDay = NITE }) + battle:useMove(player, battle.enemy, "SYNTHESIS") + eq(player.hp, player.maxHp, "a full-HP user is not healed") + local said = false + for _, event in ipairs(battle:takeEvents()) do + if event.text and event.text:find("HP is full", 1, true) then said = true end + end + check(said, "and hears HPIsFullText instead") +end + +-- ------------------------------------------------- the overworld's clock + +-- World:timeOfDayId is wTimeOfDay off the RTC hour +-- (engine/tilesets/timeofday_pals.asm:5-11), and startBattle hands it over. +do + local World = require("src.world.gen2.World") + local Screens = require("src.ui.Screens") + + local pushed + local realPush = Screens.push + Screens.push = function(_, id, opts) + if id == "Gen2BattleState" then pushed = opts end + end + + local ok, err = pcall(function() + for _, hour in ipairs({ { "MORN", MORN }, { "DAY", DAY }, + { "NITE", NITE }, { "DARK", DARK } }) do + local mon = Mon.new(DATA, "CHIKORITA", 20, { dvs = perfect }) + local game = { + data = DATA, + save = { version = "gold", player = { name = "GOLD", id = 1234 }, + party = { mon }, inventory = {}, boxes = {} }, + } + game.stack = { pop = function() end } + local world = World.new(game) + world.tod = hour[1] + -- No map, so DoBattleTransition has nothing to wipe and the battle + -- screen comes straight in (World:pushBattleTransition). + world.map = nil + pushed = nil + world:startBattle({ wild = Mon.new(DATA, "PIDGEY", 5, { dvs = perfect }) }) + eq(pushed and pushed.battle and pushed.battle.timeOfDay, hour[2], + "a battle started at " .. hour[1] .. " carries that wTimeOfDay") + end + end) + + Screens.push = realPush + check(ok, "the World seam ran: " .. tostring(err)) +end + +S.finish() diff --git a/tests/gen2_unown_chambers_test.lua b/tests/gen2_unown_chambers_test.lua new file mode 100644 index 00000000..609c99db --- /dev/null +++ b/tests/gen2_unown_chambers_test.lua @@ -0,0 +1,233 @@ +-- The four Ruins of Alph secret chambers: +-- ../pokecrystal/engine/events/unown_walls.asm:1 HoOhChamber, :13 +-- OmanyteChamber, :54 SpecialAerodactylChamber, :81 SpecialKabutoChamber. +-- CRYSTAL_CACHE="..." luajit tests/gen2_unown_chambers_test.lua +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 unown chambers") +local check, eq = S.check, S.eq + +love = require("tests.love_stub") + +local Events = require("src.world.gen2.Events") +local Specials = require("src.script.gen2.Specials") +local UnownWords = require("src.world.gen2.UnownWords") + +local function loadTable(path) + local chunk = loadfile(path) + return chunk and chunk() or nil +end + +local function fakeVm(opts) + opts = opts or {} + local pack = opts.pack or {} + return { + scriptVar = 0, + events = opts.events or Events.new(), + specials = { + party = function() return opts.party or {} end, + itemIndex = function(id) return id == "WATER_STONE" and 24 or nil end, + hasItem = function(index) return pack[index] == true end, + }, + } +end + +local WALLS = { + { "HO_OH", 806 }, { "KABUTO", 807 }, + { "OMANYTE", 808 }, { "AERODACTYL", 809 }, +} + +-- ../pokecrystal/constants/event_flags.asm:486-489 +do + for _, row in ipairs(WALLS) do + eq(UnownWords.WALL_OPENED[row[1]], row[2], + "EVENT_WALL_OPENED_IN_" .. row[1] .. "_CHAMBER is flag " .. row[2]) + end + local events = Events.new() + check(not UnownWords.wallOpened(events, "OMANYTE"), "a fresh save has no wall open") + check(UnownWords.openWall(events, "OMANYTE"), "the first SET_FLAG reports the change") + check(UnownWords.wallOpened(events, "OMANYTE"), "and CHECK_FLAG reads it back") + check(not UnownWords.openWall(events, "OMANYTE"), "a second one is a no-op") + check(not UnownWords.wallOpened(events, "KABUTO"), + "and it did not open any of the other three") + check(not UnownWords.openWall(nil, "OMANYTE"), "no bitfield, no write") + check(not UnownWords.openWall(Events.new(), "BETA"), + "and a chamber the cart does not have has no flag to set") +end + +-- ../pokecrystal/engine/events/unown_walls.asm:2-5 HoOhChamber +do + check(UnownWords.leadIsHoOh({ { species = "HO_OH" } }), + "Ho-Oh in the first slot is what the routine asks for") + check(not UnownWords.leadIsHoOh({ { species = "LUGIA" }, { species = "HO_OH" } }), + "Ho-Oh in the second slot is not: it reads wPartySpecies[0] only") + check(not UnownWords.leadIsHoOh({ { species = "HO_OH", isEgg = true } }), + "an egg holds EGG in wPartySpecies, not the hatchling's species") + check(not UnownWords.leadIsHoOh({}), "and an empty party is not Ho-Oh") + + local vm = fakeVm({ party = { { species = "HO_OH" } } }) + Specials.ALL.HoOhChamber(vm) + check(UnownWords.wallOpened(vm.events, "HO_OH"), "the handler opens the wall") + eq(vm.scriptVar, 0, "and leaves wScriptVar alone: the scene's own checkevent reads the flag") + + vm = fakeVm({ party = { { species = "SUICUNE" } } }) + Specials.ALL.HoOhChamber(vm) + check(not UnownWords.wallOpened(vm.events, "HO_OH"), + "any other lead leaves it shut") + + vm = fakeVm({ party = { { species = "HO_OH" } } }) + Specials.ALL.HoOhChamber(vm) + check(not UnownWords.wallOpened(vm.events, "OMANYTE"), + "and it never touches the Omanyte bit") +end + +-- ../pokecrystal/engine/events/unown_walls.asm:28-43 OmanyteChamber +do + eq(UnownWords.waterStoneSlot({ { item = "WATER_STONE" } }), 1, + "one held Water Stone is found") + eq(UnownWords.waterStoneSlot( + { { item = "WATER_STONE" }, {}, { item = "WATER_STONE" } }), 3, + "and with two, the backwards walk stops at the LAST slot") + check(UnownWords.waterStoneSlot({ { item = "FIRE_STONE" }, {} }) == nil, + "no stone, no slot") + check(UnownWords.waterStoneSlot(nil) == nil, "and no party is no slot") + + local vm = fakeVm({ pack = { [24] = true } }) + Specials.ALL.OmanyteChamber(vm) + check(UnownWords.wallOpened(vm.events, "OMANYTE"), + "CheckItem on the pack alone opens it") + + vm = fakeVm({ party = { {}, { item = "WATER_STONE" } } }) + Specials.ALL.OmanyteChamber(vm) + check(UnownWords.wallOpened(vm.events, "OMANYTE"), + "so does a Water Stone held by a party mon") + + vm = fakeVm({ party = { { item = "FIRE_STONE" } } }) + Specials.ALL.OmanyteChamber(vm) + check(not UnownWords.wallOpened(vm.events, "OMANYTE"), + "neither in the pack nor held leaves it shut") + + vm = fakeVm({}) + Specials.ALL.OmanyteChamber(vm) + check(not UnownWords.wallOpened(vm.events, "OMANYTE"), + "and an empty party with an empty pack does nothing") + + -- ../pokecrystal/engine/events/unown_walls.asm:14-20 + local seen = 0 + vm = fakeVm({}) + vm.specials.party = function() seen = seen + 1 return {} end + UnownWords.openWall(vm.events, "OMANYTE") + Specials.ALL.OmanyteChamber(vm) + eq(seen, 0, "an already-open wall returns before the party is walked") +end + +-- ../pokecrystal/engine/events/unown_walls.asm:54, :81, both reached from a +-- field move rather than from a `special`. +do + local events = Events.new() + check(not UnownWords.aerodactylChamber(events, "RUINS_OF_ALPH_KABUTO_CHAMBER"), + "Flash in the wrong chamber returns no carry") + check(not UnownWords.wallOpened(events, "AERODACTYL"), "and opens nothing") + check(not UnownWords.aerodactylChamber(events, "DARK_CAVE_VIOLET_ENTRANCE"), + "nor does Flash in an ordinary dark cave") + check(UnownWords.aerodactylChamber(events, "RUINS_OF_ALPH_AERODACTYL_CHAMBER"), + "in its own chamber it returns the carry FlashFunction jumps on") + check(UnownWords.wallOpened(events, "AERODACTYL"), "and sets the bit") + + events = Events.new() + check(not UnownWords.kabutoChamber(events, "RUINS_OF_ALPH_OMANYTE_CHAMBER"), + "an escape rope elsewhere does nothing") + check(not UnownWords.wallOpened(events, "KABUTO"), "and leaves the bit clear") + check(UnownWords.kabutoChamber(events, "RUINS_OF_ALPH_KABUTO_CHAMBER"), + "in the Kabuto chamber it fires") + check(UnownWords.wallOpened(events, "KABUTO"), "and sets its own bit") + check(not UnownWords.wallOpened(events, "AERODACTYL"), + "with the Aerodactyl bit untouched") +end + +-- data/events/special_pointers.asm:148 OmanyteChamber, :157 HoOhChamber +do + for _, name in ipairs({ "OmanyteChamber", "HoOhChamber" }) do + check(type(Specials.ALL[name]) == "function", name .. " is a handler") + check(Specials.STUBS[name] == nil, name .. " is no longer a stub") + eq(Specials.HANDLER_SOURCE[name], "specials/crystal_story.lua", + "owned by this unit's module") + check(Specials.SUPERSEDED_STUBS[name] ~= nil, + "and its stub reason moved to SUPERSEDED_STUBS") + end +end + +local cache = os.getenv("CRYSTAL_CACHE") +if not cache then + cache = (os.getenv("HOME") or "") + .. "/Library/Application Support/LOVE/crystal-dev/crystal" +end + +local maps = loadTable(cache .. "/data/generated/maps.lua") +local scripts = loadTable(cache .. "/data/generated/scripts.lua") +local consts = loadTable(cache .. "/data/generated/constants.lua") + +-- ../pokecrystal/maps/RuinsOfAlphOmanyteChamber.asm:22-24, the +-- MAPCALLBACK_TILES callback that re-derives the flag numbers from the cart. +local specialId = {} +for id, name in pairs((consts or {}).specialOrder or {}) do + specialId[name] = id - 1 +end + +if not (maps and scripts and consts) then + check(true, "no cache: the chamber scripts are not walked (SKIP)") +elseif not specialId.OmanyteChamber then + -- data/events/special_pointers.asm:124 `; Crystal only` + check(true, "cache is not a Crystal one (SKIP)") +else + -- ../pokecrystal/maps/RuinsOfAlphOmanyteChamber.asm:10 and + -- RuinsOfAlphHoOhChamber.asm:10; the other two chambers have no `special`. + local EXPECTED = { + HO_OH = "HoOhChamber", + OMANYTE = "OmanyteChamber", + KABUTO = false, + AERODACTYL = false, + } + for chamber, wantSpecial in pairs(EXPECTED) do + local mapId = UnownWords.CHAMBER_MAPS[chamber] + local def = maps[mapId] + if not def then + check(false, mapId .. " is in the cache") + else + local callback = (def.callbacks or {})[1] + local rows = callback and scripts[callback.scriptKey] + local first = rows and rows[1] + eq(callback and callback.callback, "MAPCALLBACK_TILES", + mapId .. " has its hidden-doors callback") + eq(first and first.op, "checkevent", "which opens on a checkevent") + eq(first and first.event, UnownWords.WALL_OPENED[chamber], + "of the very flag " .. chamber .. "'s routine writes") + + local scene + for _, entry in pairs(def.sceneScripts or {}) do + if (entry.sceneId or 0) == 0 then scene = entry end + end + local sceneRows = scene and scripts[scene.scriptKey] or {} + local got + for _, row in ipairs(sceneRows) do + if row.op == "special" then got = row.id end + end + if wantSpecial then + eq(got, specialId[wantSpecial], + mapId .. "'s check-wall scene runs " .. wantSpecial) + else + check(got == nil, + mapId .. "'s check-wall scene runs no special: its trigger is a field move") + end + -- ../pokecrystal/maps/RuinsOfAlphOmanyteChamber.asm:11 + local read + for _, row in ipairs(sceneRows) do + if row.op == "checkevent" then read = row.event end + end + eq(read, UnownWords.WALL_OPENED[chamber], + "and it reads the same flag straight afterwards") + end + end +end + +S.finish() diff --git a/tests/gen2_unown_printer_test.lua b/tests/gen2_unown_printer_test.lua index a6e50fe2..f55d36ae 100644 --- a/tests/gen2_unown_printer_test.lua +++ b/tests/gen2_unown_printer_test.lua @@ -285,8 +285,9 @@ end check(Specials.HANDLERS.UnownPrinter ~= nil, "UnownPrinter has a handler: the viewer half needs no printer") eq(Specials.STUBS.UnownPrinter, nil, "and is not also stubbed") -check(Specials.STUBS.PrintDiploma ~= nil, - "PrintDiploma, which is nothing but a print, still is") +check(Specials.HANDLERS.PrintDiploma ~= nil, + "PrintDiploma took the same route: the diploma page is drawn on the " + .. "cartridge and only the send wanted a printer") -- The hook a Specials handler reaches for lives in World:specialHooks, NOT in -- the table handed to Vm.new -- a hook registered in the wrong one is diff --git a/tests/gen2_unown_words_test.lua b/tests/gen2_unown_words_test.lua new file mode 100644 index 00000000..c58f4ae7 --- /dev/null +++ b/tests/gen2_unown_words_test.lua @@ -0,0 +1,310 @@ +-- The Ruins of Alph wall words: engine/events/unown_walls.asm:102 +-- DisplayUnownWords and data/events/unown_walls.asm:7 UnownWalls. +-- CRYSTAL_CACHE="..." luajit tests/gen2_unown_words_test.lua +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 unown words") +local check, eq = S.check, S.eq + +love = require("tests.love_stub") + +local Json = require("src.link.Json") +local UnownWords = require("src.world.gen2.UnownWords") + +local function readFile(path, mode) + local f = io.open(path, mode or "rb") + if not f then return nil end + local body = f:read("*a") + f:close() + return body +end + +local function pngSize(body) + local function be32(s, i) + local a, b, c, d = s:byte(i, i + 3) + return ((a * 256 + b) * 256 + c) * 256 + d + end + return be32(body, 17), be32(body, 21) +end + +local function loadTable(path) + local chunk = loadfile(path) + return chunk and chunk() or nil +end + +-- engine/events/unown_walls.asm:201 .ConvertChar +do + local tl, tr, bl, br = UnownWords.square(0x00) -- 'A' + eq(tl, 0x80, "A's top-left is bank 1 tile $00") + eq(tr, 0x81, "top-right is the next tile") + eq(bl, 0x90, "bottom-left is a 16-tile row down") + eq(br, 0x91, "and bottom-right beside it") + eq((UnownWords.square(0x0e)), 0x8e, "H is bank 1 tile $0e") + eq((UnownWords.square(0x20)), 0xa0, "I steps a whole row: bank 1 tile $20") + eq((UnownWords.square(0x4e)), 0xce, "X is the last computed letter") + local y1, y2, y3, y4 = UnownWords.square(0x60) + eq(y1 * 0x1000000 + y2 * 0x10000 + y3 * 0x100 + y4, + 0x5b5c4d5d, "Y is .YChar's four bank 0 tiles") + local z1, z2, z3, z4 = UnownWords.square(0x62) + eq(z1 * 0x1000000 + z2 * 0x10000 + z3 * 0x100 + z4, + 0x4e4f5e5f, "Z is .ZChar's") + local d1, d2, d3, d4 = UnownWords.square(0x64) + eq(d1 * 0x1000000 + d2 * 0x10000 + d3 * 0x100 + d4, + 0x02030302, "and the dash is .DashChar's") +end + +-- data/events/unown_walls.asm:15 MenuHeaders_UnownWalls, the n = 6 row. +do + local escape = { x1 = 3, y1 = 4, x2 = 16, y2 = 9, + chars = { 0x08, 0x44, 0x04, 0x00, 0x2e, 0x08 } } + local bx, by, bw, bh = UnownWords.boxRect(escape) + eq(bx, 3, "the box starts at column 3") + eq(by, 4, "row 4") + eq(bw, 14, "and spans the coordinate pair inclusive: 14 columns") + eq(bh, 6, "by 6 rows") + local ox, oy = UnownWords.origin(escape) + eq(ox, 4, "the first square is one column inside the frame") + eq(oy, 6, "and two rows down") + local squares = UnownWords.layout(escape) + eq(#squares, 6, "ESCAPE is six squares") + eq(squares[1].tx, 4, "the E starts at column 4") + eq(squares[2].tx, 6, "and every letter is two tiles wide") + eq(squares[6].tx, 14, "so the last one ends on the frame's inner edge") + eq(squares[1].ty, 6, "all of them on the same row") + eq(squares[4].tl, 0x80, "the A in ESCAPE is bank 1 tile $00") +end + +-- constants/charmap.asm:424 the `unown` charmap. +do + local body = readFile("tools/rom_manifest_crystal.json", "r") + if not body then + check(true, "no tools/rom_manifest_crystal.json (SKIP)") + else + local manifest = Json.decode(body) + local map = manifest.unownCharmap + if not map then + check(false, "the Crystal manifest carries unownCharmap") + else + eq(map["0"], "A", "$00 is A") + eq(map["2"], "B", "$02 is B, two tiles along") + eq(map["14"], "H", "$0e is H, the last of the first row") + eq(map["32"], "I", "$20 is I: the row step is $10 every eight letters") + eq(map["78"], "X", "$4e is X") + eq(map["96"], "Y", "$60 is Y") + eq(map["98"], "Z", "$62 is Z") + eq(map["100"], "-", "$64 is the dash") + eq(map["255"], "@", "and $ff terminates a word") + local count = 0 + for _ in pairs(map) do count = count + 1 end + eq(count, 28, "27 printable characters plus the terminator") + end + -- constants/charmap.asm:5 -- $00 is in the main charmap. + local main = manifest.charmap or {} + check(main["0"] ~= "A", "the main charmap did not absorb the Unown rows") + eq(manifest.symbols.UnownWalls[1], 0x22, + "UnownWalls is in the bank pokecrystal.sym says") + eq(manifest.symbols.UnownWalls[2], 0x6ebc, "and at its address") + eq(manifest.symbols.MenuHeaders_UnownWalls[2], 0x6ed5, + "with the menu headers immediately after it") + end +end + +do + for _, edition in ipairs({ "gold", "silver" }) do + local body = readFile("tools/rom_manifest_" .. edition .. ".json", "r") + if not body then + check(true, "no " .. edition .. " manifest (SKIP)") + else + local manifest = Json.decode(body) + check(manifest.unownCharmap == nil, + edition .. " has no Unown charmap: the block is Crystal only") + check(manifest.symbols.UnownWalls == nil, + edition .. " has no UnownWalls either") + end + end +end + +-- constants/charmap.asm:423,433 the two `pushc` blocks the main parser skips. +do + local source = readFile("tools/make_gold_manifest.py", "r") + if not source then + check(true, "no tools/make_gold_manifest.py (SKIP)") + else + check(source:find('if re.match(r"(pushc|newcharmap)\\b", stripped):', + 1, true) ~= nil, "the main charmap parser still stops at newcharmap") + end + local crystal = readFile("tools/make_crystal_manifest.py", "r") + if not crystal then + check(true, "no tools/make_crystal_manifest.py (SKIP)") + else + check(crystal:find('data["unownCharmap"] = unown_charmap(', 1, true) ~= nil, + "and the Unown one is generated beside it, under its own key") + check(crystal:find("PRINTABLE_UNOWN", 1, true) == nil, + "with the letter set read out of charmap.asm, not copied here") + end +end + +-- home/map.asm:1357-1370 LoadTilesetGFX's two CopyBytes. +do + local source = assert(readFile("src/import/RomExtractorGen2.lua", "r")) + check(source:find("local function crystalTilesetSheet(pixels)", 1, true) + ~= nil, "the Crystal sheet lays both VRAM banks out at their tile ids") + check(source:find("if twoBank then pixels = crystalTilesetSheet(pixels) end", + 1, true) ~= nil, "and extractTilesets uses it") + check(source:find("local CRYSTAL_PAL_MAP_BYTES = 112", 1, true) ~= nil, + "the PalMap read covers the bank 1 rows too") + check(source:find("out.unownWalls = walls", 1, true) ~= nil, + "and readEventTables emits the wall words") +end + +-- gfx/tilesets/ruins_of_alph.png, whose bottom half is the Unown alphabet. +do + local png = readFile( + "../pokecrystal/gfx/tilesets/ruins_of_alph.png") + if not png then + check(true, "no ../pokecrystal: the tileset's shape is not pinned (SKIP)") + else + local w, h = pngSize(png) + eq(w, 128, "pret's ruins_of_alph.png is 16 tiles across") + eq(h, 96, "and 12 down: $60 tiles per VRAM bank, two banks") + end +end + +local cache = os.getenv("CRYSTAL_CACHE") +if not cache then + cache = (os.getenv("HOME") or "") + .. "/Library/Application Support/LOVE/crystal-dev/crystal" +end + +local events = loadTable(cache .. "/data/generated/events.lua") +local tilesets = loadTable(cache .. "/data/generated/tilesets.lua") + +-- data/events/unown_walls.asm:2-5 +local EXPECTED = { + { word = "ESCAPE", x1 = 3, y1 = 4, x2 = 16, y2 = 9 }, + { word = "LIGHT", x1 = 4, y1 = 4, x2 = 15, y2 = 9 }, + { word = "WATER", x1 = 4, y1 = 4, x2 = 15, y2 = 9 }, + { word = "HO-OH", x1 = 4, y1 = 4, x2 = 15, y2 = 9 }, +} + +if not events then + check(true, "no Crystal cache: the wall words are not read (SKIP)") +elseif not events.unownWalls then + check(true, "cache predates the wall words: re-import Crystal (SKIP)") +else + local walls = events.unownWalls + eq(#walls, 4, "NUM_UNOWN_WALLS rows came out") + for index, want in ipairs(EXPECTED) do + local got = walls[index] or {} + eq(got.word, want.word, want.word .. " decoded through the Unown charmap") + eq(got.id, index - 1, "at the UNOWNWORDS_* value the setval uses") + eq(#(got.chars or {}), #want.word, "with one character byte per letter") + eq(got.x1, want.x1, want.word .. "'s box left edge") + eq(got.y1, want.y1, "top edge") + eq(got.x2, want.x2, "right edge") + eq(got.y2, want.y2, "bottom edge") + -- data/events/unown_walls.asm:20 MENU_BACKUP_TILES + eq(got.flags, 0x40, "and MENU_BACKUP_TILES set") + -- data/events/unown_walls.asm:21 `menu_coords 9 - n, 4, 10 + n, 9` + local _, _, bw = UnownWords.boxRect(got) + eq(bw - 2, #want.word * 2, "the frame is 2 columns per letter wide") + end + -- engine/events/unown_walls.asm:249 .DashChar + local dash = UnownWords.layout(walls[4])[3] + eq(dash.tl, 0x02, "HO-OH's dash comes out of .DashChar, not the alphabet") +end + +if not tilesets then + check(true, "no Crystal cache: the tileset sheet is not read (SKIP)") +else + local roa = tilesets.TILESET_RUINS_OF_ALPH + if not roa then + check(false, "the Ruins of Alph tileset is in the cache") + elseif roa.imageHeight ~= 128 then + check(true, "cache predates the two-bank sheet: re-import Crystal (SKIP)") + else + eq(roa.imageWidth, 128, "the sheet is 16 tiles across") + eq(roa.imageHeight, 128, "and 16 down: 256 tile ids, both VRAM banks") + eq(roa.tilesPerRow, 16, "so a tile id indexes it directly") + eq(#roa.tilePalettes, 224, "the PalMap covers every id the blocks use") + local png = readFile(cache .. "/assets/generated/tilesets/ruins_of_alph.png") + if not png then + check(false, "the sheet PNG is actually in the cache") + else + local w, h = pngSize(png) + eq(w, 128, "the written PNG is 128 wide") + eq(h, 128, "and 128 tall") + end + -- constants/tileset_constants.asm:34-38, the five Crystal-only tilesets. + for _, name in ipairs({ "TILESET_KABUTO_WORD_ROOM", + "TILESET_OMANYTE_WORD_ROOM", "TILESET_AERODACTYL_WORD_ROOM", + "TILESET_HO_OH_WORD_ROOM", "TILESET_BETA_WORD_ROOM" }) do + local set = tilesets[name] + if not set then + check(false, name .. " extracted") + else + eq(set.imageHeight, 128, name .. " is a two-bank sheet") + -- engine/tilesets/map_palettes.asm:40 -- bit 7 is the VRAM bank. + local high = 0 + for _, block in ipairs(set.blocks) do + for _, tile in ipairs(block) do + if tile >= 0x80 and tile < 0xe0 then high = high + 1 end + end + end + check(high > 0, name .. " really does draw out of bank 1") + end + end + end +end + +-- data/events/special_pointers.asm:151 add_special DisplayUnownWords +do + local Specials = require("src.script.gen2.Specials") + check(type(Specials.ALL.DisplayUnownWords) == "function", + "DisplayUnownWords is a handler and not a stub") + eq(Specials.HANDLER_SOURCE.DisplayUnownWords, + "specials/unown_words.lua", "owned by this unit's module") +end + +do + local maps = loadTable(cache .. "/data/generated/maps.lua") + local scripts = loadTable(cache .. "/data/generated/scripts.lua") + local consts = loadTable(cache .. "/data/generated/constants.lua") + if not (maps and scripts and consts and events and events.unownWalls) then + check(true, "no Crystal cache: the chambers are not walked (SKIP)") + else + local index + for id, name in pairs(consts.specialOrder or {}) do + if name == "DisplayUnownWords" then index = id - 1 end + end + check(index ~= nil, "the cache's specialOrder names DisplayUnownWords") + local chambers = { + RUINS_OF_ALPH_KABUTO_CHAMBER = 0, + RUINS_OF_ALPH_AERODACTYL_CHAMBER = 1, + RUINS_OF_ALPH_OMANYTE_CHAMBER = 2, + RUINS_OF_ALPH_HO_OH_CHAMBER = 3, + } + for mapId, want in pairs(chambers) do + local def = maps[mapId] + local walls = 0 + for _, bg in ipairs((def or {}).bgEvents or {}) do + local rows = bg.scriptKey and scripts[bg.scriptKey] + local pending + for _, row in ipairs(rows or {}) do + if row.op == "setval" then + pending = row.value or (row.args and row.args[1]) + elseif row.op == "special" and row.id == index then + walls = walls + 1 + eq(pending, want, mapId .. " asks for its own word") + check(UnownWords.wallFor( + { gen2EventTables = events }, pending) ~= nil, + "and that value indexes a real wall row") + end + end + end + eq(walls, 2, mapId .. " has both of its wall patterns") + end + end +end + +S.finish() diff --git a/tests/gen2_version_fixes_test.lua b/tests/gen2_version_fixes_test.lua new file mode 100644 index 00000000..6a5d5146 --- /dev/null +++ b/tests/gen2_version_fixes_test.lua @@ -0,0 +1,231 @@ +-- The faithfulness convention: Gold and Silver keep their original cart bugs +-- wherever the bug is not hardware-dependent, and Crystal gets the fixes +-- Crystal shipped. GameVersion.fixes(id) names the FIX, so an absent table +-- reads as "bugged like the cart". +-- luajit tests/gen2_version_fixes_test.lua +-- +-- Four of pokegold's seven documented bugs are engine behaviour this port can +-- express. This file pins the two that are reachable from Lua state: +-- +-- luckyNumberBoxes the Lucky Number Show's loop bound +-- halloffame the first-save PC corruption, which is NOT reachable +-- here and is pinned as such rather than emulated +-- +-- surfOntoNpc lives with the field-move unit and reflectOverflow with the +-- battle unit; only the flag names are asserted here. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 version fixes") +local check, eq = S.check, S.eq + +love = require("tests.love_stub") + +require("src.core.Logger").warn = function() end + +local GameVersion = require("src.core.GameVersion") +local HallOfFame = require("src.core.gen2.HallOfFame") +local Save = require("src.core.gen2.Save") +local Specials = require("src.script.gen2.Specials") + +local priorVersion = GameVersion.get() + +-- ---- CT-10: the API shape ------------------------------------------------- + +eq(type(GameVersion.fixes), "function", "GameVersion.fixes is an accessor") +eq(type(GameVersion.fixes("crystal")), "table", "and it returns a table") + +for _, id in ipairs({ "red", "blue", "yellow", "gold", "silver" }) do + eq(next(GameVersion.fixes(id)), nil, id .. " fixes nothing") +end +eq(next(GameVersion.fixes("nonesuch")), nil, "an unknown id reads as {}") + +for _, name in ipairs({ "luckyNumberBoxes", "surfOntoNpc", "reflectOverflow" }) do + eq(GameVersion.fixes("crystal")[name], true, "crystal fixes " .. name) +end + +do + local count = 0 + for _ in pairs(GameVersion.fixes("crystal")) do count = count + 1 end + eq(count, 3, "and carries no fix name a consumer was not told about") +end + +GameVersion.set("gold") +eq(next(GameVersion.fixes()), nil, "the active version is the default subject") +GameVersion.set("crystal") +eq(GameVersion.fixes().luckyNumberBoxes, true, "which follows GameVersion.set") + +-- Gold's row must stay bug-shaped even if someone later hangs data off it. +GameVersion.set("gold") +check(GameVersion.info("gold").fixes == nil, "the gold row has no fixes table") +check(GameVersion.info("silver").fixes == nil, "nor does silver") + +-- ---- the Lucky Number Show, boxes 10-14 ----------------------------------- +-- +-- pokegold/engine/events/lucky_number.asm:100-102 bounds .BoxesLoop with +-- NUM_BOXES_JP (9, pokegold/constants/pokemon_data_constants.asm:123) where +-- pokecrystal/engine/events/lucky_number.asm:99 uses NUM_BOXES (14). The OPEN +-- box is walked out of sBox before that loop starts and the loop then skips +-- it, so the current box is searched on both carts whatever its number. + +local function boxSet(order) + local seen = {} + for _, index in ipairs(order) do seen[index] = true end + return seen +end + +GameVersion.set("gold") +do + local order = Specials.luckyNumberBoxOrder({ currentBox = 1 }) + eq(#order, 9, "Gold walks nine boxes") + eq(order[1], 1, "starting with the open one") + local seen = boxSet(order) + for index = 1, 9 do check(seen[index], "gold searches box " .. index) end + for index = 10, 14 do + check(not seen[index], "gold skips box " .. index .. " (the cart bug)") + end +end + +do + local order = Specials.luckyNumberBoxOrder({ currentBox = 12 }) + eq(order[1], 12, "the open box is searched first even at 12") + eq(#order, 10, "on top of the nine .BoxesLoop reaches") + check(boxSet(order)[12], "so box 12 is not skipped while it is open") +end + +GameVersion.set("crystal") +do + local order = Specials.luckyNumberBoxOrder({ currentBox = 1 }) + eq(#order, 14, "Crystal walks all fourteen") + local seen = boxSet(order) + for index = 1, 14 do check(seen[index], "crystal searches box " .. index) end +end + +do + local order = Specials.luckyNumberBoxOrder({}) + eq(order[1], 1, "a save with no currentBox opens box 1") + eq(#order, 14, "and still walks the full set") +end + +-- The handler itself, over the same fixture on both editions. The party mon +-- shares three trailing digits (second prize, wScriptVar 2); the mon in box 12 +-- is an exact match (first prize, wScriptVar 1), and the BEST match wins. +local function luckyVm(record) + return { + specials = { + save = function() return record end, + party = function() return record.party end, + monName = function(species) return species end, + }, + setStringBuffer = function(self, value) self.stringBuffer = value end, + } +end + +local function luckyFixture(boxIndex, currentBox) + local record = { + luckyNumber = 12345, + currentBox = currentBox or 1, + party = { { species = "CHIKORITA", otId = 99345 } }, + boxes = {}, + } + record.boxes[boxIndex] = { { species = "MAGIKARP", otId = 12345 } } + return record +end + +GameVersion.set("gold") +do + local vm = luckyVm(luckyFixture(3)) + Specials.HANDLERS.CheckForLuckyNumberWinners(vm) + eq(vm.scriptVar, 1, "Gold finds an exact match in box 3") + check(vm.luckyNumberInBox, "and reports it came from a box") +end + +do + local vm = luckyVm(luckyFixture(12)) + Specials.HANDLERS.CheckForLuckyNumberWinners(vm) + eq(vm.scriptVar, 2, "Gold never sees the same mon in box 12") + check(not vm.luckyNumberInBox, "so the party's partial match wins instead") +end + +do + local vm = luckyVm(luckyFixture(12, 12)) + Specials.HANDLERS.CheckForLuckyNumberWinners(vm) + eq(vm.scriptVar, 1, "unless box 12 is the box the PC has open") +end + +GameVersion.set("crystal") +do + local vm = luckyVm(luckyFixture(12)) + Specials.HANDLERS.CheckForLuckyNumberWinners(vm) + eq(vm.scriptVar, 1, "Crystal finds box 12 without opening it") + check(vm.luckyNumberInBox, "and reports it came from a box") +end + +do + local vm = luckyVm(luckyFixture(14)) + Specials.HANDLERS.CheckForLuckyNumberWinners(vm) + eq(vm.scriptVar, 1, "and box 14, the last one") +end + +-- ---- the Hall of Fame, first-save PC corruption --------------------------- +-- +-- pokegold/engine/events/halloffame.asm:17 is the bug marker; Crystal farcalls +-- HallOfFame_InitSaveIfNeeded there (pokecrystal/engine/menus/save.asm:470-475) +-- to run ErasePreviousSave over uninitialised SRAM. This port has no SRAM, so +-- the induction is pinned as harmless here rather than emulated. + +GameVersion.set("gold") +do + local save = Save.newGame({ name = "GOLD", gender = "male" }) + save.boxes[7] = { { species = "SUDOWOODO", level = 20, otId = 111 } } + save.boxNames[7] = "TREES" + save.currentBox = 7 + eq(HallOfFame.count(save), 0, "a fresh save has never been inducted") + check(not HallOfFame.hasEntered(save), "and carries no status flag") + + local party = { { species = "TYPHLOSION", level = 55, otId = 222, + nickname = "TYPHLO" } } + local entry, wasEntered = HallOfFame.induct(save, party) + check(entry ~= nil, "induction on a never-saved file returns a row") + check(not wasEntered, "and reports the player was not already a champion") + eq(HallOfFame.count(save), 1, "the win counter reaches one") + eq(#save.boxes[7], 1, "box 7 still holds its mon") + eq(save.boxes[7][1].species, "SUDOWOODO", "unchanged") + eq(save.boxNames[7], "TREES", "its name survives too") + eq(save.currentBox, 7, "and the open box is still open") + for index = 1, Save.NUM_BOXES do + local box = save.boxes[index] + check(box == nil or type(box) == "table", + "box " .. index .. " is a table or absent, never garbage") + end +end + +-- ---- the Coin Case terminator --------------------------------------------- +-- +-- pokegold/data/text/common_3.asm:341 ends _CoinCaseCountText with `done` ($57) +-- where pokecrystal/data/text/common_3.asm:1308 uses `text_end`, and +-- pokegold/home/text.asm:590-593 stops only on TX_END. The port decodes the +-- stream once at import (decodeGen2Text breaks on $57), so the cache is the +-- evidence that nothing runs past the string. + +local cache = os.getenv("GOLD_CACHE") +if not cache then + local home = os.getenv("HOME") or "" + cache = home .. "/Library/Application Support/LOVE/gold-dev/gold" +end +local textPath = cache .. "/data/generated/rom_text.lua" +local tf = io.open(textPath, "r") +if not tf then + check(true, "rom_text.lua absent : no gold cache (SKIP cache facts)") +else + tf:close() + local romText = assert(loadfile(textPath))() + eq(romText._CoinCaseCountText, "Coins:\n{NUM}", + "the `done` terminator still ends the decoded string") + check(not tostring(romText._CoinCaseCountText or ""):find("Raise the PP"), + "and nothing from the next label runs into it") +end + +GameVersion.set(priorVersion) + +S.finish() diff --git a/tests/gen2_vm_test.lua b/tests/gen2_vm_test.lua index d0e3a6b3..79ffc201 100644 --- a/tests/gen2_vm_test.lua +++ b/tests/gen2_vm_test.lua @@ -1768,8 +1768,10 @@ end -- Shuckle you caught yourself is the thing the routine refuses. do local list = {} + local record = { party = list } local vm = specialVm(0, { order = { "x" }, hooks = { party = function() return list end, + save = function() return record end, data = function() return { pokemon = { SHUCKLE = { name = "SHUCKLE", index = 213, growthRate = "MEDIUM_FAST", @@ -1785,6 +1787,9 @@ do eq(list[1].otId, Specials.MANIA_OT_ID, "with MANIA's trainer ID") eq(list[1].item, "BERRY", "holding a BERRY") eq(list[1].level, 15, "at level 15") + local dex = record.pokedex or {} + eq((dex.seen or {}).SHUCKLE, true, "seen in the #DEX") + eq((dex.caught or {}).SHUCKLE, true, "and caught in the #DEX") local hooks2 = { party = function() return list end, selectPartyMon = function(_, done) done(1, list[1]) end } @@ -2357,9 +2362,11 @@ check(Specials.STUBS.PhotoStudio == nil, "PhotoStudio is a HANDLER now: the conversation and the portrait card exist") check(Specials.HANDLERS.PhotoStudio ~= nil, "and it is the one that special dispatch resolves to") -check(Specials.STUBS.PrintDiploma ~= nil, - "PrintDiploma stays stubbed -- it is nothing but the print, with no screen " - .. "half of its own") +check(Specials.STUBS.PrintDiploma == nil + and Specials.HANDLERS.PrintDiploma ~= nil, + "PrintDiploma is a handler now: _PrintDiploma opens on the very page " + .. "`special Diploma` shows (engine/printer/printer.asm:382) and only the " + .. "two SendScreenToPrinter passes wanted a printer") check(Specials.STUBS.UnownPrinter == nil and Specials.HANDLERS.UnownPrinter ~= nil, "UnownPrinter is a handler for the same reason this one is: the stamp " diff --git a/tests/gen2_whirlpool_test.lua b/tests/gen2_whirlpool_test.lua new file mode 100644 index 00000000..40dd4e76 --- /dev/null +++ b/tests/gen2_whirlpool_test.lua @@ -0,0 +1,229 @@ +-- Whirlpool tiles force-turn the player: DoPlayerMovement's .CheckTile, the +-- CheckWhirlpoolTile arm that sits ABOVE the nybble ladder. +-- +-- luajit tests/gen2_whirlpool_test.lua +-- +-- COLL_WHIRLPOOL is tested first and takes PLAYERMOVEMENT_FORCE_TURN +-- (engine/overworld/player_movement.asm:117-123), which runs Script_ForcedMovement +-- (engine/events/forced_movement.asm:1-51): step_dig 16, turn_in , +-- step_dig 16, turn_head , step_end. So a whirlpool is entered, whirled +-- on, and left again the way the player came, with the d-pad ignored throughout. +-- +-- The port had no arm at all -- only the HI_NYBBLE_CURRENT and HI_NYBBLE_WARPS +-- ones -- so a whirlpool cell was plain water and a surfer swam straight over +-- it, past the HM06 + GLACIERBADGE gate on Route 41 and Route 27. Two decoding +-- gaps sat under that: turn_away / turn_in / turn_waterfall all `jp TurningStep` +-- (engine/overworld/movement.asm:483-513), which is a real one-cell STEP under +-- OBJECT_ACTION_SPIN, and $4f step_dig was not modelled at all. #1716 +-- +-- The whirl itself is drawing, so what is asserted here is the state the +-- drawing reads (Player.spinFrames) and the grid the player ends on. +-- tests/drivers/gold_whirlpool_forceturn_bug1716_test.lua is the other half. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 whirlpool") +local check, eq, same = S.check, S.eq, S.same + +local Movement = require("src.script.gen2.Movement") +local Permissions = require("src.world.gen2.Permissions") +local World = require("src.world.gen2.World") +local Player = require("src.world.gen2.Player") +local FieldMoves = require("src.world.gen2.FieldMoves") + +-- ---- the tile -------------------------------------------------------------- +-- +-- CheckWhirlpoolTile is the two-entry compare COLL_WHIRLPOOL $24 / COLL_WHIRLPOOL_1 $2c. +local COLL_WATER, COLL_WHIRLPOOL, COLL_WHIRLPOOL_1 = 0x29, 0x24, 0x2c + +check(Permissions.isWhirlpool(COLL_WHIRLPOOL), "COLL_WHIRLPOOL $24") +check(Permissions.isWhirlpool(COLL_WHIRLPOOL_1), "COLL_WHIRLPOOL_1 $2c") +check(not Permissions.isWhirlpool(COLL_WATER), "plain water is not one") +check(not Permissions.isWhirlpool(0x33), "and neither is a waterfall") +-- The arm is above the nybble ladder, not part of it: the tile's PERMISSION is +-- still WATER_TILE, which is what lets a surfing player step onto it at all. +check(Permissions.isWater(COLL_WHIRLPOOL), + "a whirlpool is still WATER_TILE, so .CheckSurfable lets the step happen") +eq(Permissions.currentDirection(COLL_WHIRLPOOL), nil, + "and it is not a HI_NYBBLE_CURRENT tile") + +-- ---- Script_ForcedMovement's stream ---------------------------------------- +-- +-- .MovementData_up is `step_dig 16 / turn_in DOWN / step_dig 16 / +-- turn_head DOWN / step_end`, i.e. every byte points BACK the way the player +-- came. turn_head is the $00 family, turn_in the $24 one, step_dig $4f with +-- its frame count in the byte after it (macros/scripts/movement.asm:163-167). +eq(Movement.STEP_DIG, 0x4f, "$4f step_dig") +eq(Movement.STEP_DIG_FRAMES, 16, "and the 16 frames the stream asks it for") +same(Movement.forcedMovementBytes("down"), + { 0x4f, 16, 0x24, 0x4f, 16, 0x00, 0x47 }, + "thrown DOWN: step_dig 16, turn_in DOWN, step_dig 16, turn_head DOWN, end") +same(Movement.forcedMovementBytes("up"), + { 0x4f, 16, 0x25, 0x4f, 16, 0x01, 0x47 }, "thrown UP") +same(Movement.forcedMovementBytes("left"), + { 0x4f, 16, 0x26, 0x4f, 16, 0x02, 0x47 }, "thrown LEFT") +same(Movement.forcedMovementBytes("right"), + { 0x4f, 16, 0x27, 0x4f, 16, 0x03, 0x47 }, "thrown RIGHT") + +-- The middle byte is the one the port used to read as a facing change, which +-- is why the bounce could not exist: turn_in never moved anybody. +for byte, dir in pairs({ [0x24] = "down", [0x25] = "up", + [0x26] = "left", [0x27] = "right" }) do + local act = Movement.decodeByte(byte) + eq(act.kind, "step", ("$%02x turn_in crosses a cell"):format(byte)) + eq(act.dir, dir, ("$%02x turn_in direction"):format(byte)) + eq(act.spin, true, ("$%02x turn_in spins on the way"):format(byte)) +end +eq(Movement.decodeByte(0x20).kind, "step", "$20 turn_away is a step too") +eq(Movement.decodeByte(0x28).kind, "step", "$28 turn_waterfall as well") +-- The neighbours must not have picked the spin up. +eq(Movement.decodeByte(0x0c).spin, nil, "$0c step is a plain step") +eq(Movement.decodeByte(0x10).spin, nil, "$10 big_step too") +eq(Movement.decodeByte(0x00).kind, "turn", "$00 turn_head is still a turn") + +-- ---- the step -------------------------------------------------------------- +-- +-- One whirlpool at (5,5) in an open pond, the way Route 41's four sit: water on +-- every side of them (data/generated/maps.lua, ROUTE_41 (22,12)). +local WHIRL_X, WHIRL_Y = 5, 5 + +local function whirlWorld(px, py, facing, coll) + local game = { + data = { items = {}, moves = {}, pokemon = {} }, + save = { player = { name = "GOLD", badges = {} }, party = {}, + inventory = {} }, + } + local world = World.new(game) + game.world = world + local cells = {} + for y = 0, 9 do + for x = 0, 9 do cells[y * 100 + x] = COLL_WATER end + end + cells[WHIRL_Y * 100 + WHIRL_X] = coll or COLL_WHIRLPOOL + local map + map = { + id = "ROUTE_41", + width = 5, height = 5, + def = { objects = {}, bgEvents = {}, environment = "WATER", + tileset = "TILESET_JOHTO", width = 5, height = 5 }, + cellCollision = function(_, x, y) + return cells[y * 100 + x] or COLL_WATER + end, + inBounds = function(_, x, y) + return x >= 0 and y >= 0 and x < 10 and y < 10 + end, + isWalkable = function(_, x, y) + return Permissions.isWalkable(map:cellCollision(x, y)) + end, + warpAt = function() return nil end, + connection = function() return nil end, + } + world.map = map + world.maps = { ROUTE_41 = map.def } + world.player = Player.new(px, py, facing) + world.entities = { world.player } + world.npcs = {} + world.playerState = FieldMoves.PLAYER_SURF + world.encounters = {} + world.pollTimeOfDay = function() end + -- Nothing here draws, and nothing here is about the wild roll. + world.noWildEncounters = true + world.updatePeople = function() end + return world, game +end + +-- Swim at the whirlpool from `dir` for `frames` with the d-pad held the whole +-- way, the way a player leaning on it does, and report what happened. +local function swimInto(dir, frames) + local DELTA = { up = { 0, -1 }, down = { 0, 1 }, + left = { -1, 0 }, right = { 1, 0 } } + local d = DELTA[dir] + -- Two cells short of the whirlpool, so the approach is an ordinary swim. + local world = whirlWorld(WHIRL_X - d[1] * 2, WHIRL_Y - d[2] * 2, dir) + local p = world.player + local r = { onWhirlpool = false, spun = false, busy = false, past = false } + for _ = 1, frames or 220 do + world.heldDir = dir + world:step() + if p.cellX == WHIRL_X and p.cellY == WHIRL_Y then r.onWhirlpool = true end + if p.spinFrames then r.spun = true end + if world:busy() then r.busy = true end + -- One cell past the whirlpool, on the far side. + if p.cellX == WHIRL_X + d[1] and p.cellY == WHIRL_Y + d[2] then + r.past = true + end + end + r.x, r.y, r.facing = p.cellX, p.cellY, p.facing + return r, world +end + +-- The cart lets the player land on the whirlpool: FORCE_TURN is answered from +-- the tile UNDERFOOT, so the step onto it happens and the bounce follows. +local BACK = { up = "down", down = "up", left = "right", right = "left" } +for _, dir in ipairs({ "up", "down", "left", "right" }) do + local r = swimInto(dir) + check(r.onWhirlpool, + ("swimming %s reaches the whirlpool cell"):format(dir)) + check(not r.past, + ("holding %s never carries the player through it"):format(dir)) + check(r.busy, + ("the forced movement owns the world while it runs (%s)"):format(dir)) + check(r.spun, + ("step_dig put the player under OBJECT_ACTION_SPIN (%s)"):format(dir)) + eq(r.facing, BACK[dir], + ("turn_head leaves the player facing away from it (%s)"):format(dir)) +end + +-- COLL_WHIRLPOOL_1 is the same arm, not a second tile that fell through. +do + local world = whirlWorld(WHIRL_X, WHIRL_Y + 2, "up", COLL_WHIRLPOOL_1) + local p = world.player + local past = false + for _ = 1, 220 do + world.heldDir = "up" + world:step() + if p.cellY < WHIRL_Y then past = true end + end + check(not past, "$2c force-turns exactly like $24") +end + +-- The negative control, and the reason the bug was invisible: the same cell as +-- plain water is swum straight over. +do + local world = whirlWorld(WHIRL_X, WHIRL_Y + 2, "up", COLL_WATER) + local p = world.player + for _ = 1, 220 do + world.heldDir = "up" + world:step() + end + check(p.cellY < WHIRL_Y, "plain water in the same cell still lets us through") + eq(p.spinFrames, nil, "and nothing spins over it") +end + +-- The turn is answered from the tile the player is STANDING on, not from a +-- neighbour: swimming past a whirlpool one cell to the side is untouched. +do + local world = whirlWorld(WHIRL_X - 1, WHIRL_Y + 2, "up") + local p = world.player + for _ = 1, 220 do + world.heldDir = "up" + world:step() + end + eq(p.cellX, WHIRL_X - 1, "the column beside the whirlpool is ordinary water") + check(p.cellY < WHIRL_Y, "and it is swum up without a bounce") +end + +-- The stream is Script_ForcedMovement's own, byte for byte, and it is queued +-- through the same World:beginMovement every applymovement uses -- so the +-- freeze, the follower and the step-end bookkeeping all come along with it. +do + local world = whirlWorld(WHIRL_X, WHIRL_Y, "up") + check(world:runForcedMovement(), "standing on one starts the stream") + check(world.moveState ~= nil, "through an applymovement, not a bespoke path") + same(world.moveState.bytes, Movement.forcedMovementBytes("down"), + "and the bytes are the ones facing UP asks for") + check(world:busy(), "which is what closes the overworld to input") + -- It does not start a second copy over itself. + check(not world:runForcedMovement(), "one stream at a time") +end + +S.finish() diff --git a/tests/mod_graphics_tests.lua b/tests/mod_graphics_tests.lua index f60ac7d1..8c56df3d 100644 --- a/tests/mod_graphics_tests.lua +++ b/tests/mod_graphics_tests.lua @@ -134,9 +134,7 @@ love.graphics = { -- Fresh copies of the modules that cache a compiled shader or a page set -- at first use, so they see the recorder above -- and so the originals --- every other suite holds never see it. GBCFX is in the list because --- Renderer:endFrame reaches it and parity_gbcfx asserts on its unset --- shader cache. +-- every other suite holds never see it. -- The tile/sprite renderers join them because they report trueColor rects -- to PaletteFX and resolve through Assets, and the loader because it holds -- Assets as an upvalue: an earlier suite's copy of any of the three would @@ -144,7 +142,7 @@ love.graphics = { local savedLoaded = {} for _, name in ipairs({ "src.render.PaletteFX", "src.render.Renderer", "src.render.Font", "src.render.Assets", - "src.render.GBCFX", "src.render.SpriteRenderer", + "src.render.SpriteRenderer", "src.render.TileRenderer", "src.mods.Loader" }) do savedLoaded[name] = package.loaded[name] package.loaded[name] = nil diff --git a/tests/mod_ui_tests.lua b/tests/mod_ui_tests.lua index dc82fa8b..4cb7576b 100644 --- a/tests/mod_ui_tests.lua +++ b/tests/mod_ui_tests.lua @@ -285,8 +285,8 @@ local WANT_IDS = { "textSpeed", "animations", "battleStyle", "battleLayout", "battleFit", "battleHud", "battleBg", "uiLayout", "ruleset", "musicVol", "sfxVol", "musicFilter", "performance", "colors", - "tilt", "gbcfx", "zoom", "voidFill", "videoMode", - "faithfulRes", "fpsCap", + "tilt", "shaderfx", "shaderfx2", "zoom", "voidFill", + "videoMode", "faithfulRes", "screenPos", "fpsCap", "speedOverworld", "speedBattle", "speedMenu", "mods", "controls", "dateFormat", "timeFormat" } local function orow(menu, id) @@ -331,7 +331,9 @@ check(om.game.save.options.musicVol == 6, "music volume steps down") for _ = 1, 10 do orow(om, "musicVol").step(om.game, -1) end check(om.game.save.options.musicVol == 0, "music volume clamps at 0") --- ZOOM / VOID FILL rows (looked up by id; WANT_IDS above pins the order) +-- ZOOM / VOID FILL rows (looked up by id; WANT_IDS above pins the order, +-- with SHADER FX / SHADER FX 2 right after TILT now that GBCFX.lua and +-- its row are gone) local Zoom = require("src.render.Zoom") local TileRenderer = require("src.render.TileRenderer") om.game.save.options.zoom = 0 @@ -359,6 +361,39 @@ check(om.game.save.options.voidFill == "black", "VOID FILL steps to BLACK") orow(om, "voidFill").step(om.game, 1) check(om.game.save.options.voidFill == "trees", "VOID FILL wraps to TREES") +-- the SHADER FX row now pushes a real ShaderFXScreen list instead of +-- cycling in place. With no presets under ShaderFX.presetDir() (nothing +-- is dropped in for this stub love.filesystem), the pushed screen must +-- show OFF plus the permanent DOWNLOAD SHADERS row and stay a safe +-- no-op rather than crash. SHADER FX 2 (the dual-shader secondary slot) +-- mirrors it one row down, opening the same shared screen on +-- "secondary" instead. +local ShaderFX = require("src.render.ShaderFX") +check(om.rows[16].id == "shaderfx", "row 16 is the SHADER FX row") +check(om.rows[16].value(om.game) == "OFF", "SHADER FX shows OFF with no presets") +check(om.rows[16].step == nil, "SHADER FX row has no step() any more") +om.rows[16].activate(om.game) +local sfxScreen = om.game.stack:top() +check(sfxScreen and sfxScreen.title == "SHADER FX", + "SHADER FX row.activate() pushes a ShaderFXScreen") +check(#sfxScreen.items == 2 and sfxScreen.items[1].label == "OFF" + and sfxScreen.items[2].download == true, + "ShaderFXScreen shows OFF + DOWNLOAD SHADERS with zero presets found") +sfxScreen.onChoose(sfxScreen.items[1]) +check(ShaderFX.active("main") == false, "choosing OFF on an empty list stays a safe no-op") +check(om.game.stack:top() == nil, "ShaderFXScreen pops itself after onChoose") + +check(om.rows[17].id == "shaderfx2", "row 17 is the SHADER FX 2 row") +check(om.rows[17].value(om.game) == "OFF", "SHADER FX 2 shows OFF with no presets") +om.rows[17].activate(om.game) +local sfx2Screen = om.game.stack:top() +check(sfx2Screen and sfx2Screen.title == "SHADER FX 2", + "SHADER FX 2 row.activate() pushes the shared ShaderFXScreen on the secondary slot") +sfx2Screen.onChoose(sfx2Screen.items[1]) +check(ShaderFX.active("secondary") == false, "choosing OFF on the secondary slot is a safe no-op") +check(ShaderFX.active() == false, "neither slot active means ShaderFX.active() is false") +check(om.game.stack:top() == nil, "the secondary ShaderFXScreen pops itself after onChoose") + -- the MAX FPS row cycles the render-cap steps and shows the value plain om.game.save.options.fpsCap = nil check(orow(om, "fpsCap").value(om.game) == "60", diff --git a/tests/mod_world_tests.lua b/tests/mod_world_tests.lua index ed4b4fbe..f1ea223c 100644 --- a/tests/mod_world_tests.lua +++ b/tests/mod_world_tests.lua @@ -976,8 +976,10 @@ do check(battle.leveledUp and battle.leveledUp[caterpie], "awardExp records the level-up for EvolveAfterBattle") - game.stack:pop() - battle.onFinish("win") + -- EndOfBattle evolves on the battle screen (end_of_battle.asm:42-45), + -- so drive finish() rather than calling onFinish by hand + battle.result = "win" + battle:finish() -- the "is evolving!" box types out and holds DelayFrames 50 before the -- movie (evos_moves.asm:120-134) (#1596), so drive frames to reach it game.input.wasPressed = function() return false end diff --git a/tests/modkit/record.lua b/tests/modkit/record.lua index b573116b..3b7cf6ff 100644 --- a/tests/modkit/record.lua +++ b/tests/modkit/record.lua @@ -61,8 +61,8 @@ function Record.events(target, opts) return capture end --- the sanctioned draw-capture pattern from tests/parity_gbcfx.lua, lifted --- so sprite/tileset mods can assert on what reached the screen +-- the sanctioned love.graphics.draw-capture pattern, lifted so sprite/ +-- tileset mods can assert on what reached the screen function Record.draw() local original = love.graphics.draw local capture = { draws = {} } diff --git a/tests/parity_cycling_road_brake.lua b/tests/parity_cycling_road_brake.lua index 3b1bffc6..de8b9052 100644 --- a/tests/parity_cycling_road_brake.lua +++ b/tests/parity_cycling_road_brake.lua @@ -132,6 +132,9 @@ do o:handleInput() check(not o.player.moving, "and holding A there changes nothing") Input:reset() + -- a scripted step now reads the live bike state (#1754), so do not leak + -- the mount into whatever suite runs next + Game.save.onBike = false end S.finish() diff --git a/tests/parity_escort_lockstep.lua b/tests/parity_escort_lockstep.lua index 4fc567df..2c98f206 100644 --- a/tests/parity_escort_lockstep.lua +++ b/tests/parity_escort_lockstep.lua @@ -50,7 +50,9 @@ local function runEscort(sync, steps) npc.cellX, npc.cellY, npc.px, npc.py = 10, 4, 160, 64 npc.moving, npc.progress, npc.targetX, npc.targetY = false, 0, nil, nil ow.scriptMoves = {} - npc.stepFrames = sync and (p.stepFramesCur or p.stepFrames) or nil + -- a scripted step re-derives its length from the live walk/bike state + -- (home/overworld.asm:276, #1754), so sync off that, not a stale latch + npc.stepFrames = sync and p:stepLength() or nil local worstDrift, done, i = 0, false, 0 local function tick() i = i + 1 diff --git a/tests/parity_gbcfx.lua b/tests/parity_gbcfx.lua deleted file mode 100644 index 891008e6..00000000 --- a/tests/parity_gbcfx.lua +++ /dev/null @@ -1,171 +0,0 @@ --- Parity test, GBC FX ladder (Pixel Transparency shader). --- Unit-tests the Lua-side API of src/render/GBCFX.lua headless under the --- love stub: level clamping, the OFF→1→2→3→4→OFF cycle, options plumbing, --- level labels, and that active()/present() degrade gracefully when the --- stub offers no love.graphics.newShader (shader() returns nil). -package.path = "./?.lua;./?/init.lua;" .. package.path -if not _G.love then _G.love = require("tests.love_stub") end -local S = require("tests.harness").suite("parity gbcfx") -local check, eq = S.check, S.eq - --- === assertions === - -local GBCFX = require("src.render.GBCFX") - --- defaults -eq(GBCFX.level, 0, "gbcfx starts at level 0 (OFF)") -eq(#GBCFX.LABELS, 5, "five labels (OFF + 4 levels)") -eq(GBCFX.LABELS[1], "OFF", "first label is OFF") - --- setLevel clamps and floors -GBCFX.setLevel(2) -eq(GBCFX.level, 2, "setLevel stores an in-range level") -GBCFX.setLevel(-3) -eq(GBCFX.level, 0, "setLevel clamps below to 0") -GBCFX.setLevel(99) -eq(GBCFX.level, 4, "setLevel clamps above to 4") -GBCFX.setLevel(2.9) -eq(GBCFX.level, 2, "setLevel floors fractional levels") -GBCFX.setLevel("3") -eq(GBCFX.level, 3, "setLevel accepts numeric strings") -GBCFX.setLevel(nil) -eq(GBCFX.level, 0, "setLevel(nil) resets to OFF") -GBCFX.setLevel("junk") -eq(GBCFX.level, 0, "setLevel(non-numeric) resets to OFF") - --- cycle wraps OFF→1→2→3→4→OFF and returns the new level -GBCFX.setLevel(0) -eq(GBCFX.cycle(), 1, "cycle OFF -> 1") -eq(GBCFX.cycle(), 2, "cycle 1 -> 2") -eq(GBCFX.cycle(), 3, "cycle 2 -> 3") -eq(GBCFX.cycle(), 4, "cycle 3 -> 4") -eq(GBCFX.cycle(), 0, "cycle 4 wraps to OFF") -eq(GBCFX.level, 0, "cycle leaves the wrapped level stored") - --- applyOptions reads opts.gbcfx -GBCFX.applyOptions({ gbcfx = 3 }) -eq(GBCFX.level, 3, "applyOptions reads opts.gbcfx") -GBCFX.applyOptions({}) -eq(GBCFX.level, 0, "applyOptions without gbcfx resets to OFF") -GBCFX.setLevel(2) -GBCFX.applyOptions(nil) -eq(GBCFX.level, 0, "applyOptions(nil) resets to OFF") - --- labels -eq(GBCFX.levelLabel(0), "OFF", "label for level 0") -eq(GBCFX.levelLabel(1), "1", "label for level 1") -eq(GBCFX.levelLabel(4), "4", "label for level 4") -GBCFX.setLevel(3) -eq(GBCFX.levelLabel(), "3", "levelLabel() defaults to the current level") -eq(GBCFX.levelLabel(42), "OFF", "out-of-range label falls back to OFF") - --- headless: the love stub has no newShader, so the shader never compiles -eq(GBCFX.shader(), nil, "shader() is nil headless") -GBCFX.setLevel(4) -check(not GBCFX.active(), "active() is false headless even at level 4") - --- present() falls back to a plain draw when the shader is unavailable -local drawn = nil -local g = love.graphics -local oldDraw, oldSetColor = g.draw, g.setColor -g.draw = function(c, x, y) drawn = { c, x, y } end -g.setColor = g.setColor or function() end -local canvas = {} -local ok, err = pcall(GBCFX.present, canvas, 5) -g.draw = oldDraw -g.setColor = oldSetColor -check(ok, "present() does not error headless (" .. tostring(err) .. ")") -check(drawn and drawn[1] == canvas and drawn[2] == 0 and drawn[3] == 0, - "present() falls back to a plain draw at (0,0)") - -GBCFX.setLevel(0) - --- issue #136: Android/iOS refuse GBC FX (shader soft-bricks the APK) -check(GBCFX.isSupported(), "desktop / headless stub supports GBC FX") -local prevSystem = love.system -love.system = { getOS = function() return "Android" end } -check(not GBCFX.isSupported(), "Android reports GBC FX unsupported") -GBCFX.setLevel(3) -eq(GBCFX.level, 0, "setLevel forces OFF on Android") -eq(GBCFX.cycle(), 0, "cycle stays OFF on Android") -local opts = { gbcfx = 4 } -check(GBCFX.applyOptions(opts) == true, - "applyOptions reports a cleared persisted level on Android") -eq(opts.gbcfx, 0, "applyOptions clears opts.gbcfx on Android") -eq(GBCFX.level, 0, "applyOptions leaves level OFF on Android") -check(not GBCFX.active(), "active() is false on Android") -eq(GBCFX.shader(), nil, "shader() is nil on Android") -love.system = { getOS = function() return "iOS" end } -check(not GBCFX.isSupported(), "iOS reports GBC FX unsupported") -love.system = prevSystem -check(GBCFX.isSupported(), "support restores when OS stub is removed") --- desktop path still applies a level after leaving the mobile gate -GBCFX.applyOptions({ gbcfx = 2 }) -eq(GBCFX.level, 2, "applyOptions still sets levels on desktop") -GBCFX.setLevel(0) - --- Options menu hides the GBC FX row on Android -local OptionsMenu = require("src.ui.OptionsMenu") -love.system = { getOS = function() return "Android" end } -local om = OptionsMenu.new({ - data = { rulesets = {}, constants = {} }, - save = { options = {} }, - stack = { pop = function() end }, - input = { wasPressed = function() return false end }, - modStatus = { available = {} }, -}) -local hasGbc = false -for _, row in ipairs(om.rows) do - if row.id == "gbcfx" then hasGbc = true end -end -check(not hasGbc, "Options menu omits GBC FX on Android") -love.system = prevSystem - --- POKEPORT_GBCFX overrides the platform default both ways. Handheld packs --- (build-rg34xxsp.sh) export 0 because their getOS() says "Linux" while the --- GPU is phone class; 1 is the escape hatch if a device turns out to cope. -local prevEnv = os.getenv("POKEPORT_GBCFX") -local realGetenv = os.getenv -local stubEnv -os.getenv = function(name) - if name == "POKEPORT_GBCFX" then return stubEnv end - return realGetenv(name) -end - -stubEnv = "0" -check(not GBCFX.isSupported(), "POKEPORT_GBCFX=0 refuses GBC FX on desktop") -GBCFX.setLevel(3) -eq(GBCFX.level, 0, "setLevel forces OFF under POKEPORT_GBCFX=0") -local handheldOpts = { gbcfx = 4 } -check(GBCFX.applyOptions(handheldOpts) == true, - "applyOptions reports a cleared level under POKEPORT_GBCFX=0") -eq(handheldOpts.gbcfx, 0, "applyOptions heals a persisted level on a handheld") -check(not GBCFX.active(), "active() is false under POKEPORT_GBCFX=0") - -local omHandheld = OptionsMenu.new({ - data = { rulesets = {}, constants = {} }, - save = { options = {} }, - stack = { pop = function() end }, - input = { wasPressed = function() return false end }, - modStatus = { available = {} }, -}) -hasGbc = false -for _, row in ipairs(omHandheld.rows) do - if row.id == "gbcfx" then hasGbc = true end -end -check(not hasGbc, "Options menu omits GBC FX under POKEPORT_GBCFX=0") - --- "1" wins over the Android gate, so the override is a real two-way door. -stubEnv = "1" -love.system = { getOS = function() return "Android" end } -check(GBCFX.isSupported(), "POKEPORT_GBCFX=1 forces GBC FX on despite Android") -love.system = prevSystem - -stubEnv = nil -check(GBCFX.isSupported(), "unset POKEPORT_GBCFX falls back to the OS check") -os.getenv = realGetenv -eq(os.getenv("POKEPORT_GBCFX"), prevEnv, "os.getenv restored") -GBCFX.setLevel(0) - --- === summary === -S.finish() diff --git a/tests/parity_hpcolor_og_palette.lua b/tests/parity_hpcolor_og_palette.lua index 7dc9d72a..faae668b 100644 --- a/tests/parity_hpcolor_og_palette.lua +++ b/tests/parity_hpcolor_og_palette.lua @@ -48,7 +48,8 @@ end local ZONE0 = { { "red", "gbc", { 255, 239, 255 }, { 247, 214, 123 }, { 74, 165, 90 }, { 25, 16, 16 } }, { "blue", "gbc", { 255, 239, 255 }, { 247, 214, 123 }, { 74, 165, 90 }, { 25, 16, 16 } }, - { "yellow", "gbc", { 255, 239, 255 }, { 247, 214, 123 }, { 74, 165, 90 }, { 25, 16, 16 } }, + -- pokeyellow/data/sgb/sgb_palettes.asm:35 PAL_GREENBAR + { "yellow", "gbc", { 255, 255, 247 }, { 255, 255, 156 }, { 0, 173, 0 }, { 49, 49, 49 } }, { "red", "ogred", { 255, 255, 255 }, { 255, 132, 132 }, { 148, 58, 58 }, { 0, 0, 0 } }, { "blue", "ogred", { 255, 255, 255 }, { 99, 165, 255 }, { 0, 0, 255 }, { 0, 0, 0 } }, { "yellow", "ogred", { 255, 255, 255 }, { 255, 255, 0 }, { 0, 255, 0 }, { 25, 25, 25 } }, @@ -218,8 +219,9 @@ local ANIM = { { "blue", "ogred", { 255, 132, 132 }, { 148, 58, 58 }, { 0, 0, 0 } }, { "red", "gbc", { 255, 239, 255 }, { 25, 16, 16 }, { 25, 16, 16 } }, { "blue", "gbc", { 255, 239, 255 }, { 25, 16, 16 }, { 25, 16, 16 } }, - { "yellow", "gbc", { 255, 239, 255 }, { 25, 16, 16 }, { 25, 16, 16 } }, - { "yellow", "ogred", { 255, 239, 255 }, { 25, 16, 16 }, { 25, 16, 16 } }, + -- pokeyellow/data/sgb/sgb_palettes.asm:35 PAL_GREENBAR + { "yellow", "gbc", { 255, 255, 247 }, { 49, 49, 49 }, { 49, 49, 49 } }, + { "yellow", "ogred", { 255, 255, 247 }, { 49, 49, 49 }, { 49, 49, 49 } }, } for _, c in ipairs(ANIM) do diff --git a/tests/parity_rare_candy_menu.lua b/tests/parity_rare_candy_menu.lua index e6c936d6..d9e188da 100644 --- a/tests/parity_rare_candy_menu.lua +++ b/tests/parity_rare_candy_menu.lua @@ -175,7 +175,7 @@ do check(isBox(box), "the last candy levels too") finishLevelUp(game, box) eq(game.stack:top(), list, "the bag stays open after the last candy") - eq(#list.items, 0, "the emptied row left the list") + eq(#list.items, 1, "the emptied row left the list, leaving only CANCEL") eq(game.save.inventory.RARE_CANDY, nil, "no candies left in the inventory") end diff --git a/tests/parity_ss_anne_departure.lua b/tests/parity_ss_anne_departure.lua index f7a196d3..41f42c24 100644 --- a/tests/parity_ss_anne_departure.lua +++ b/tests/parity_ss_anne_departure.lua @@ -83,9 +83,10 @@ do for _, r in ipairs(rowsOfKind(rows, "show_text")) do said[#said + 1] = r[2] end + -- SSAnne2FRivalText's text_asm arms SaveEndBattleTextPointers, so the + -- defeat line prints in battle, not on the map (scripts/SSAnne2F.asm:199) local order = { "_SSAnne2FRivalText", - "_SSAnne2FRivalDefeatedText", "_SSAnne2FRivalCutMasterText", } local pi = 1 @@ -93,7 +94,10 @@ do if pi <= #order and id == order[pi] then pi = pi + 1 end end eq(pi, #order + 1, - ("x=%d text order: greeting, defeated, CUT master"):format(x)) + ("x=%d text order: greeting, then CUT master"):format(x)) + local armed = rowsOfKind(rows, "save_end_battle_text")[1] + check(armed ~= nil and armed[2] == "_SSAnne2FRivalDefeatedText", + ("x=%d arms the defeat line for the battle screen (#1688)"):format(x)) local walk = rowsOfKind(rows, "walk_npc")[1] check(walk ~= nil, ("x=%d exit is a walk_npc list"):format(x)) @@ -123,13 +127,9 @@ local function dockBlock(bx, by) end local function sail(cellX, cellY) - local rows, puffs = nil, 0 + local rows = nil local ow = { player = { cellX = cellX, cellY = cellY }, - startDustAnim = function(_, _, _, done) - puffs = puffs + 1 - if done then done() end - end, queueScript = function(_, r) rows = r end, } local game = { @@ -139,10 +139,15 @@ local function sail(cellX, cellY) story3.VERMILION_DOCK.onEnter(game, ow) check(rows ~= nil, "stepping off the gangway queues the departure") check(game.save.flags.EVENT_SS_ANNE_LEFT == true, "EVENT_SS_ANNE_LEFT set") - eq(puffs, 3, "three funnel smoke puffs (LoadSmokeTileFourTimes)") return rows end +local function kindIndex(rows, kind, from) + for i = from or 1, #rows do + if rows[i][1] == kind then return i end + end +end + do -- the hull ids the slide reuses are the map's own blocks, so a data -- rebuild that renumbered the tileset would be caught here @@ -165,52 +170,25 @@ do local waits = rowsOfKind(rows, "wait") eq(waits[1][2], 120, "120 frames before the first horn") eq(waits[#waits][2], 120, "EraseSSAnne's 120 frames before the walk out") - local slide = 0 - for _, w in ipairs(waits) do - if w[2] == 20 then slide = slide + 1 end - end - eq(slide, 8, "eight column shifts, .shift_columns_up's ld e, $8") - -- the bug was the whole hull blinking to water in a single frame with no - -- travel at all: her bow block has to be written one column further west - -- each step, and the water has to close in astern behind her - local bow, wake = {}, {} - for _, r in ipairs(rowsOfKind(rows, "replace_block")) do - if r[3] == 1 and r[4] == dockBlock(DOCK_HULL.x0, 1) then - bow[#bow + 1] = r[2] - elseif r[3] == 1 and r[4] == 1 then - wake[#wake + 1] = r[2] - end - end - check(dirsEqual(bow, { 4, 3, 2, 1 }), "the bow sails west a column a step") - check(dirsEqual(wake, { 8, 7, 6, 5, 4, 3, 2, 1 }), - "water closes in astern, stern column first") + -- scripts/VermilionDock.asm:50 zeroes wSpritePlayerStateData1ImageIndex and + -- :77 freezes sprite updates: he faces DOWN until he walks out (#1689) + eq(rows[1][1], "face_player_dir", "he is turned before the first delay") + eq(rows[1][2], "down", "and he is turned to face DOWN") - for _, r in ipairs(rowsOfKind(rows, "replace_block")) do - check(r[2] >= 1 and r[2] <= DOCK_HULL.x1, - "the slide stays inside the dock's water, off the pier column 0") - end + -- she used to be shuffled west one whole 32px block per beat, which read as + -- teleporting; .shift_columns_up is a 1px-per-8-frames slide (#1689) + eq(#rowsOfKind(rows, "replace_block"), 0, + "no block shuffle: the hull slides, it does not jump") + eq(#rowsOfKind(rows, "ss_anne_departs"), 1, "one blocking sail-away beat") - -- scripts/VermilionDock.asm:182-203: the tile fill covers the whole ship, - -- the gangway block under the player included (#1211) - local final = {} - for _, r in ipairs(rowsOfKind(rows, "replace_block")) do - final[r[2] .. "," .. r[3]] = r[4] - end - for bx = DOCK_HULL.x0, DOCK_HULL.x1 do - for by = DOCK_HULL.y0, DOCK_HULL.y1 do - check(WATER[final[bx .. "," .. by]], - ("hull block (%d,%d) ends as open water"):format(bx, by)) - end - end - - -- and she has to have travelled: the westmost water column of the map - -- carried hull blocks partway through - local sawWest = false - for _, r in ipairs(rowsOfKind(rows, "replace_block")) do - if r[2] == 1 and not WATER[r[4]] then sawWest = true end - end - check(sawWest, "the hull reaches the west edge of the water before it goes") + local horn1 = kindIndex(rows, "play_sound") + local sailIdx = kindIndex(rows, "ss_anne_departs") + local horn2 = kindIndex(rows, "play_sound", (horn1 or 0) + 1) + check(horn1 and sailIdx and horn2 and horn1 < sailIdx and sailIdx < horn2, + "horn, then she sails, then the horn again") + check(kindIndex(rows, "warp") > sailIdx, + "the walk out only starts once she has gone") end do diff --git a/tests/parity_ss_anne_guard.lua b/tests/parity_ss_anne_guard.lua index 8ad004c5..30cc7efb 100644 --- a/tests/parity_ss_anne_guard.lua +++ b/tests/parity_ss_anne_guard.lua @@ -66,6 +66,7 @@ local function gameWith(opts) _VermilionCitySailor1YouNeedATicketText = "You need a ticket\nto get aboard.", _VermilionCitySailor1ShipSetSailText = "The ship set sail.", + _VermilionCitySailor1WelcomeToSSAnneText = "Welcome to S.S.\nANNE!", _SSAnneCaptainsRoomRubCaptainsBackText = "Rub-rub...", _SSAnneCaptainsRoomCaptainIFeelMuchBetterText = "I feel better!", _SSAnneCaptainsRoomCaptainReceivedHM01Text = "Got HM01!", @@ -143,25 +144,36 @@ do check(#pushed == 0, "no spurious dialog off the check") end --- Sailor talk after ship left: ShipSetSail branch (row script). +-- Sailor talk: once she has sailed he only reports it gone, and before +-- that an A press is the plain greeting, never the ticket check (#1651). +-- scripts/VermilionCity.asm:158-198 do - local ScriptRunner = require("src.script.ScriptRunner") - local game = gameWith({ flags = { EVENT_SS_ANNE_LEFT = true } }) - local rows = city.talk.TEXT_VERMILIONCITY_SAILOR1 - local runner = ScriptRunner.new(game, nil) - runner:run(rows, {}) - local guard = 0 - while runner:isRunning() and guard < 200 do - guard = guard + 1 - if game.stack and game._pushed then - local box = game._pushed[#game._pushed] - if box and box.done then box.done() end - end - runner:update() - end - local last = game._pushed[#game._pushed] - eq(last and last.text, "The ship set sail.", + local talk = city.talk.TEXT_VERMILIONCITY_SAILOR1 + local game, pushed = gameWith({ flags = { EVENT_SS_ANNE_LEFT = true } }) + talk(game, owWith({}), nil, function() end) + eq(pushed[#pushed] and pushed[#pushed].text, "The ship set sail.", "talk after ship left shows ShipSetSail") + + game, pushed = gameWith({}) + local ow = owWith({}) + ow.player.cellX, ow.player.cellY, ow.player.facing = 18, 30, "right" + talk(game, ow, nil, function() end) + eq(pushed[#pushed] and pushed[#pushed].text, "Welcome to S.S.\nANNE!", + "facing him from the west is the plain greeting, no ticket check") + + game, pushed = gameWith({}) + ow = owWith({}) + ow.player.cellX, ow.player.cellY, ow.player.facing = 19, 29, "down" + talk(game, ow, nil, function() end) + eq(pushed[#pushed] and pushed[#pushed].text, "Welcome to S.S.\nANNE!", + "talking from in front of him is the plain greeting too") + + game, pushed = gameWith({}) + ow = owWith({}) + ow.player.cellX, ow.player.cellY, ow.player.facing = 19, 31, "up" + talk(game, ow, nil, function() end) + eq(pushed[#pushed] and pushed[#pushed].text, "Welcome to S.S.\nANNE!", + "and from behind him") end -- Departure cutscene: Music_Surfing + EVENT_SS_ANNE_LEFT via Flags.set. @@ -180,22 +192,22 @@ do check(ow._queued ~= nil, "departure queues the sail-away script") -- #360: the surf override must NOT ride into Vermilion City, and she -- sails west block by block - local kept, horns, slid, underPlayer = false, 0, 0, false + local kept, horns, sails, shuffled, faced = false, 0, 0, 0, false for _, row in ipairs(ow._queued or {}) do if row[1] == "play_music" and row[3] and row[3].keep then kept = true end if row[1] == "play_sound" and row[2] == "SS_Anne_Horn" then horns = horns + 1 end - if row[1] == "replace_block" then - slid = slid + 1 - if row[2] == 7 and row[3] == 1 then underPlayer = true end - end + if row[1] == "ss_anne_departs" then sails = sails + 1 end + if row[1] == "replace_block" then shuffled = shuffled + 1 end + if row[1] == "face_player_dir" and row[2] == "down" then faced = true end end eq(kept, false, "departure lets VERMILION_CITY's own theme take the warp") eq(horns, 2, "the horn blows before and after she sails") - check(slid > 8, "she sails west block by block instead of vanishing") - -- scripts/VermilionDock.asm:182-203 (#1211) - eq(underPlayer, true, "the gangway block under the player is erased too") + eq(sails, 1, "she slides west as one animation, not a block shuffle") + eq(shuffled, 0, "and no hull block is stamped across the dock any more") + -- scripts/VermilionDock.asm:50 (#1689) + eq(faced, true, "he keeps facing down until he walks out") end -- Captain rub jingle: play_once Music_PkmnHealed sits after the rub text. diff --git a/tests/parity_text_markers.lua b/tests/parity_text_markers.lua index c6cdbd0b..7edd5c92 100644 --- a/tests/parity_text_markers.lua +++ b/tests/parity_text_markers.lua @@ -73,14 +73,14 @@ end -- === #250: the CATERPIE / WEEDLE description === -- pokered/text/ViridianCity.asm declares this one without the leading --- underscore the extractor keys on, so it is not in data/generated/text.lua --- and the port carries a literal. That makes the literal load-bearing. +-- underscore, and the extractor now keys on both spellings, so the port's +-- literal has to keep agreeing with what the cart actually says. do - check(Data.text.ViridianCityYoungster2CaterpieAndWeedleDescriptionText == nil - and Data.text._ViridianCityYoungster2CaterpieAndWeedleDescriptionText == nil, - "the description really is absent from generated text (hence a literal)") - local desc = "CATERPIE has no\npoison, but\vWEEDLE does.\fWatch out for its\nPOISON STING!" + local extracted = Data.text.ViridianCityYoungster2CaterpieAndWeedleDescriptionText + or Data.text._ViridianCityYoungster2CaterpieAndWeedleDescriptionText + if extracted then eq(extracted, desc, "the literal still matches the cart") end + local pages = assertPlayable("caterpillar description", desc) eq(#pages, 2, "para breaks the description into two pages (#250)") eq(#pages[1], 3, "page 1 is text + line + cont") diff --git a/tests/parity_trainer_evolution_order.lua b/tests/parity_trainer_evolution_order.lua index d457f4bc..ba4a7af0 100644 --- a/tests/parity_trainer_evolution_order.lua +++ b/tests/parity_trainer_evolution_order.lua @@ -1,6 +1,5 @@ --- A trainer script's post-battle text must finish before a level evolution. --- Its start_battle row resumes the script after OverworldState:afterBattle, --- so this covers the same handoff used by trainer and Rocket encounters. +-- EndOfBattle evolves on the battle screen, before the map comes back +-- (engine/battle/end_of_battle.asm:42-45) (#1656) package.path = "./?.lua;./?/init.lua;" .. package.path if not _G.love then _G.love = require("tests.love_stub") end @@ -77,13 +76,9 @@ check(#Game.stack.states == 1, Game.stack:pop() moreText.onDone() --- The evolution runs as the EvolutionState cutscene screen now (the --- evolve_mon.asm sequence lives in src/ui/EvolutionState.lua), not a bare --- "evolving!" text box, so assert the screen itself took the stack. -local evolution = Game.stack:top() -check(evolution ~= nil - and (evolution.screenId == "EvolutionState" - or stateHas(evolution, "evolving")), - "the level evolution starts after trainer after-text closes") +-- The evolution belongs to the battle screen (BattleState:finish -> +-- Evolution.checkParty), so afterBattle leaves nothing behind the text. +check(#Game.stack.states == 0, + "afterBattle pushes no evolution; the battle screen already ran it") S.finish() diff --git a/tests/parity_trashcans.lua b/tests/parity_trashcans.lua index 3abda268..a8d063f5 100644 --- a/tests/parity_trashcans.lua +++ b/tests/parity_trashcans.lua @@ -59,6 +59,7 @@ local textBoxStub = { end, } local realTextBox = getUpvalue(OW.trashCanSwitch, "TextBox") +textBoxStub.soundOpts = realTextBox.soundOpts local fakeGame = { data = Data, save = SaveData.newGame(), stack = { push = function() end } } check(setUpvalue(OW.trashCanSwitch, "TextBox", textBoxStub), "TextBox upvalue rewired") check(setUpvalue(OW.trashCanSwitch, "Game", fakeGame), "Game upvalue rewired") diff --git a/tests/pet_cries_test.lua b/tests/pet_cries_test.lua new file mode 100644 index 00000000..89f07aaa --- /dev/null +++ b/tests/pet_cries_test.lua @@ -0,0 +1,157 @@ +-- Pet-NPC cries: #1687 (S.S. Anne WIGGLYTUFF + MACHOKE) and #1649 +-- (Vermilion PIDGEY, Vermilion City MACHOP, Fan Club PIKACHU + SEEL). +-- Commands.play_cry only arms the very next show_text, so what is asserted +-- here is placement: one play_cry row, the right species, the waitForButton +-- form, sitting immediately before the box it belongs to. The sound itself +-- is a driver's job (tests/drivers/pet_cries_bug1687_1649_test.lua). +-- Self-contained: `luajit tests/pet_cries_test.lua`. +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local Data = require("src.core.Data") +if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end +local init = require("data.scripts.init") +local GameVersion = require("src.core.GameVersion") +local S = require("tests.harness").suite("pet cries") +local check, eq = S.check, S.eq + +local yellow = GameVersion.isYellow() + +-- pokered/scripts/*.asm, PlayCry line in brackets: +-- SSAnne1FRooms.asm:66 [:70], SSAnneB1FRooms.asm:82 [:86], +-- VermilionPidgeyHouse.asm:15 [:19], VermilionCity.asm:224 [:228], +-- PokemonFanClub.asm:71 [:76] and :84 [:89]. +local PETS = { + { map = "SS_ANNE_1F_ROOMS", const = "TEXT_SSANNE1FROOMS_WIGGLYTUFF", + object = "SSANNE1FROOMS_WIGGLYTUFF", species = "WIGGLYTUFF", + label = "_SSAnne1FRoomsWigglytuffText" }, + { map = "SS_ANNE_B1F_ROOMS", const = "TEXT_SSANNEB1FROOMS_MACHOKE", + object = "SSANNEB1FROOMS_MACHOKE", species = "MACHOKE", + label = "_SSAnneB1FRoomsMachokeText" }, + { map = "VERMILION_PIDGEY_HOUSE", const = "TEXT_VERMILIONPIDGEYHOUSE_PIDGEY", + object = "VERMILIONPIDGEYHOUSE_PIDGEY", species = "PIDGEY", + label = "_VermilionPidgeyHousePidgeyText" }, + -- the cry belongs to the first box; the stomping line is a second box + -- with no cry of its own (VermilionCity.asm:233 .StompingTheLandFlatText) + { map = "VERMILION_CITY", const = "TEXT_VERMILIONCITY_MACHOP", + object = "VERMILIONCITY_MACHOP", species = "MACHOP", + label = "_VermilionCityMachopText", + after = "_VermilionCityMachopStompingTheLandFlatText" }, + { map = "POKEMON_FAN_CLUB", const = "TEXT_POKEMONFANCLUB_SEEL", + object = "POKEMONFANCLUB_SEEL", species = "SEEL", + label = "_PokemonFanClubSeelText" }, +} + +if yellow then + PETS[#PETS + 1] = { + map = "POKEMON_FAN_CLUB", const = "TEXT_POKEMONFANCLUB_CLEFAIRY", + object = "POKEMONFANCLUB_CLEFAIRY", species = "CLEFAIRY", + label = "_PokemonFanClubClefairyText", + } +else + PETS[#PETS + 1] = { + map = "POKEMON_FAN_CLUB", const = "TEXT_POKEMONFANCLUB_PIKACHU", + object = "POKEMONFANCLUB_PIKACHU", species = "PIKACHU", + label = "_PokemonFanClubPikachuText", + } +end + +local function rowsOf(script) + local plays, texts = {}, {} + for i, row in ipairs(script) do + if type(row) == "table" then + if row[1] == "play_cry" then plays[#plays + 1] = i end + if row[1] == "show_text" then texts[#texts + 1] = i end + end + end + return plays, texts +end + +for _, pet in ipairs(PETS) do + local tag = pet.map .. "/" .. pet.const + local script = init.talkScript(pet.map, pet.const) + if check(type(script) == "table", tag .. " has a ported talk script") then + local plays = rowsOf(script) + if eq(#plays, 1, tag .. " carries exactly one play_cry row") then + local cry = script[plays[1]] + eq(cry[2], pet.species, tag .. " cries " .. pet.species) + -- the waitForButton form: without it the box pops itself when the + -- cry ends instead of holding for A/B (Commands.play_cry, #247/#251) + eq(cry[3], true, tag .. " uses the waitForButton play_cry form") + -- play_cry arms ctx.pendingCry for the NEXT show_text only, so the + -- box it belongs to has to be the very next row + local next_ = script[plays[1] + 1] + check(type(next_) == "table" and next_[1] == "show_text" + and next_[2] == pet.label, + tag .. " arms " .. pet.label .. " on the next row") + end + if pet.after then + local _, texts = rowsOf(script) + eq(#texts, 2, tag .. " still shows both of its boxes") + local last = texts[#texts] + eq(script[last][2], pet.after, tag .. " keeps " .. pet.after .. " last") + end + end + + check(Data.pokemon[pet.species] ~= nil, pet.species .. " is a known species") + check(Data.audio.cries and Data.audio.cries[pet.species] ~= nil, + pet.species .. " has a cry program in the mounted cache") + local text = Data.text[pet.label] + check(type(text) == "string" and text ~= "", + pet.label .. " resolves to text") + + -- a script nobody can reach is the same silence as a missing cry + local objects = (Data.maps[pet.map] or {}).objects or {} + local found + for _, o in ipairs(objects) do + if o.name == pet.object then found = o end + end + if check(found ~= nil, pet.map .. " still lists " .. pet.object) then + eq(found.text, pet.const, pet.object .. " talks through " .. pet.const) + end +end + +-- Yellow swaps the Fan Club pet for a CLEFAIRY on its own text constant +-- (pokeyellow/data/maps/objects/PokemonFanClub.asm), so the extra row has +-- to be version-gated in both directions. +if yellow then + check(init.talkScript("POKEMON_FAN_CLUB", "TEXT_POKEMONFANCLUB_CLEFAIRY") ~= nil, + "Yellow registers the CLEFAIRY pet") +else + check(init.talkScript("POKEMON_FAN_CLUB", "TEXT_POKEMONFANCLUB_CLEFAIRY") == nil, + "Red/Blue do not register Yellow's CLEFAIRY pet") +end + +-- The Yellow half of that guard, checked from any version: re-read the +-- module with isYellow forced on, then put package.loaded back exactly as +-- it was so the registry keeps pointing at the table it merged. +do + local key = "data.scripts.flavor.pokemon_fan_club" + local cached = package.loaded[key] + local realIsYellow = GameVersion.isYellow + GameVersion.isYellow = function() return true end + package.loaded[key] = nil + local ok, module = pcall(require, key) + GameVersion.isYellow = realIsYellow + package.loaded[key] = cached + if check(ok, "the Fan Club script loads with isYellow forced on") then + local row = module.POKEMON_FAN_CLUB.talk.TEXT_POKEMONFANCLUB_CLEFAIRY + if check(type(row) == "table", "the Yellow branch adds the CLEFAIRY pet") then + eq(row[1][1], "play_cry", "Yellow CLEFAIRY leads with play_cry") + eq(row[1][2], "CLEFAIRY", "Yellow CLEFAIRY cries CLEFAIRY, not PIKACHU") + eq(row[1][3], true, "Yellow CLEFAIRY uses the waitForButton form") + eq(row[2][2], "_PokemonFanClubClefairyText", + "Yellow CLEFAIRY arms its own text") + end + end +end + +-- Sound.playCry hands PIKACHU to the PCM clips whenever the cache has +-- them, which is Yellow's voiced cry. Red/Blue must never reach that +-- branch: audio.pikaCries is the only thing gating it. +if not yellow then + check(Data.audio.pikaCries == nil, + "a non-Yellow cache has no Pikachu voice clips for playCry to find") +end + +S.finish() diff --git a/tests/rom_importer_last_version_test.lua b/tests/rom_importer_last_version_test.lua index c528be2f..7022ee7e 100644 --- a/tests/rom_importer_last_version_test.lua +++ b/tests/rom_importer_last_version_test.lua @@ -76,11 +76,12 @@ eq(ri4.tab, "blue", "an explicit --game tab beats the remembered version") LaunchOptions.pendingTab = nil -- module is a singleton: do not leak this -- A junk value in options.lua (hand-edited file, a build that knew other --- versions) must not select a tab that does not exist. "gold" used to be --- the stand-in for this (Gen 2's Gold/Silver support was still unwritten); --- now that Gold is a real version, "crystal" is the still-unknown one. +-- versions) must not select a tab that does not exist. Real version ids +-- stood here twice -- "gold", then "crystal" -- and each had to be swapped +-- out the day that game shipped, so this is "nonesuch": a deliberate +-- never-a-game token, not a version anyone is waiting on. local opts = SaveData.loadOptions() -opts.lastVersion = "crystal" +opts.lastVersion = "nonesuch" SaveData.saveOptions(opts) local ri5 = newImporter({ tab = "red", ready = { red = true, yellow = true } }) ri5:_applyLastVersionTab() diff --git a/tests/run_gen2.lua b/tests/run_gen2.lua new file mode 100644 index 00000000..f8a8966e --- /dev/null +++ b/tests/run_gen2.lua @@ -0,0 +1,50 @@ +-- T2 Gen 2 tier: tests/gen2_*.lua, tests/crystal_*.lua and the LZ3 decoder, one +-- process per suite. ROM-free, so it runs with no data/generated/ and no cache. +-- luajit tests/run_gen2.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local FsIo = require("tests.fs_io") + +local PREFIXES = { "gen2_", "crystal_" } +local EXTRA = { "tests/rom_lz3_test.lua" } + +local function suites() + local files = {} + for _, name in ipairs(FsIo.listDir("tests")) do + if name:match("%.lua$") then + for _, prefix in ipairs(PREFIXES) do + if name:sub(1, #prefix) == prefix then + files[#files + 1] = "tests/" .. name + break + end + end + end + end + for _, path in ipairs(EXTRA) do + local handle = io.open(path, "r") + if handle then + handle:close() + files[#files + 1] = path + end + end + table.sort(files) + return files +end + +local lua = (arg and arg[-1]) or "luajit" +local failed, total = 0, 0 +for _, path in ipairs(suites()) do + total = total + 1 + local status = os.execute(("%s %s"):format(lua, path)) + if status == 0 or status == true then + print("ok " .. path) + else + failed = failed + 1 + print("FAIL " .. path) + end +end + +print(("\ngen2: %d/%d suites passed"):format(total - failed, total)) +print(("%s"):format(failed == 0 and "ALL TESTS PASSED" or failed .. " FAILURES")) +os.exit(failed == 0 and 0 or 1) diff --git a/tests/run_tests.lua b/tests/run_tests.lua index b2c10116..b778adaf 100644 --- a/tests/run_tests.lua +++ b/tests/run_tests.lua @@ -1223,8 +1223,8 @@ do local mhc = BattleState.newWild(Game, "SNORLAX", 40) mhc.rng = mkseq({ 0, 255 }) -- acc, damage; crit from the hook mhc:performMove(mhc.player, mhc.enemy, { id = "DOUBLE_KICK", pp = 10 }) - eq(countText(mhc, "Critical hit!"), 2, - "multi-hit prints Critical hit! once per strike") + eq(countText(mhc, "Critical hit!"), 1, + "PrintCriticalOHKOText clears wCriticalHitOrOHKO, so the crit line prints once") local cseq = {} for _, r in ipairs(mhc.queue) do if r.anim == "DOUBLE_KICK" then cseq[#cseq + 1] = "anim" @@ -1233,8 +1233,8 @@ do elseif r.text == "It's super\neffective!" then cseq[#cseq + 1] = "se" end end - eq(table.concat(cseq, ","), "anim,drain,crit,se,anim,drain,crit,se", - "crit then effectiveness follow each multi-hit drain") + eq(table.concat(cseq, ","), "anim,drain,crit,se,anim,drain,se", + "the crit line prints once, effectiveness after every drain") unsub() Runtime.install(savedE, savedH) end @@ -1338,7 +1338,7 @@ do Game.save.pokedex.owned.EKANS = nil local cb = BattleState.newWild(Game, "EKANS", 5) cb:storeCaughtMon() - check(hasText(cb, "New POKéDEX data\nwill be added for\nEKANS!"), + check(hasText(cb, "New POKéDEX data\nwill be added for\vEKANS!"), "_ItemUseBallText06 on a first catch") check(Game.save.pokedex.owned.EKANS == true, "species registered as owned") eq(cb.result, "caught", "catch resolves the battle") @@ -1348,7 +1348,7 @@ do for _ = 1, 6 do table.insert(Game.save.party, Pokemon.new(Data, "RATTATA", 5)) end local cb2 = BattleState.newWild(Game, "EKANS", 5) cb2:storeCaughtMon() - check(hasText(cb2, "EKANS was\ntransferred to\nsomeone's PC!"), + check(hasText(cb2, "EKANS was\ntransferred to\vsomeone's PC!"), "_ItemUseBallText08 before meeting Bill") check(not hasText(cb2, "New POKéDEX data"), "no dex page for an already-owned species") @@ -1372,7 +1372,7 @@ do Game.save.flags.EVENT_MET_BILL = true local cb3 = BattleState.newWild(Game, "EKANS", 5) cb3:storeCaughtMon() - check(hasText(cb3, "EKANS was\ntransferred to\nBILL's PC!"), + check(hasText(cb3, "EKANS was\ntransferred to\vBILL's PC!"), "_ItemUseBallText07 after meeting Bill") Game.save.flags.EVENT_MET_BILL = nil @@ -1658,7 +1658,7 @@ do rep.options = { textSpeed = 3, animations = false, battleStyle = "SET", ruleset = "gen1_faithful", musicVol = 4, sfxVol = 2, musicFilter = 2, - colors = "og", tilt = 2, gbcfx = 3, + colors = "og", tilt = 2, } rep.defeatedTrainers = { ["OPP_BROCK:1"] = true } rep.pokedex = { seen = { PIKACHU = true, CATERPIE = true }, @@ -1687,7 +1687,6 @@ do eq(back.options.animations, false, "options.lua round-trips animations") eq(back.options.colors, "og", "options.lua round-trips colors") eq(back.options.tilt, 2, "options.lua round-trips tilt") - eq(back.options.gbcfx, 3, "options.lua round-trips gbcfx") -- zoom / voidFill ride the same options.lua path when present rep.options.zoom = -2 rep.options.voidFill = "water" @@ -2315,6 +2314,14 @@ do press("down") press("down") press("a") -- QUIT + check(not quitCalled, "QUIT says goodbye before it returns (pokemart.asm:220)") + local goodbye = StateStack:top() + check(goodbye ~= shop and goodbye.isTextBox, + "QUIT prints _PokemartThankYouText") + for _ = 1, 600 do + if quitCalled then break end + press("a") + end check(quitCalled, "QUIT fires onQuit (script runner resume)") eq(#StateStack.states, depth0, "mart menu unwound cleanly") end @@ -2635,14 +2642,14 @@ do -- option boxes (the port rows plus the MODS/CONTROLS entries) through a 4-box -- viewport with a $EE ▼ marker; MUSIC VOL / SFX VOL clamp at 0..7 like -- the text-speed cursor clamps at its ends (.pressedLeftInTextSpeed), --- MUSIC FILTER cycles OFF/1X/2X/3X, and COLORS / TILT / GBC FX / VIDEO MODE --- cycle their display modes. +-- MUSIC FILTER cycles OFF/1X/2X/3X, and COLORS / TILT / VIDEO MODE +-- cycle their display modes (SHADER FX activates a pushed screen instead). do local OptionsMenu = require("src.ui.OptionsMenu") local OInput = require("src.core.Input") local PaletteFX = require("src.render.PaletteFX") local Tilt = require("src.render.Tilt") - local GBCFX = require("src.render.GBCFX") + local ShaderFX = require("src.render.ShaderFX") local GameSpeed = require("src.core.GameSpeed") local FrameCap = require("src.core.FrameCap") local SD = require("src.core.SaveData") @@ -2681,7 +2688,6 @@ do "new saves default to MEDIUM text (InitOptions TEXT_DELAY_MEDIUM)") eq(og.save.options.colors, "gbc", "new saves default COLORS to GBC") eq(og.save.options.tilt, 0, "new saves default TILT to OFF") - eq(og.save.options.gbcfx, 0, "new saves default GBC FX to OFF") eq(og.save.options.zoom, 0, "new saves default ZOOM to FIT") eq(og.save.options.voidFill, "trees", "new saves default VOID FILL to TREES") eq(og.save.options.videoMode, "windowed", @@ -2725,12 +2731,22 @@ do eq(Tilt.level, 1, "Tilt level tracks TILT option") press("a"); press("a"); press("a") eq(og.save.options.tilt, 0, "TILT wraps back to OFF") - check(seek("gbcfx"), "cursor reaches GBC FX") - press("a") - eq(og.save.options.gbcfx, 1, "A cycles GBC FX to 1") - eq(GBCFX.level, 1, "GBCFX level tracks GBC FX option") - for _ = 1, 4 do press("a") end - eq(og.save.options.gbcfx, 0, "GBC FX wraps back to OFF") + check(seek("shaderfx"), "cursor reaches SHADER FX") + -- this row activates a pushed ShaderFXScreen rather than cycling in + -- place like the rest of this suite's rows; `og.stack` above only + -- stubs `pop`, not a real push/top stack, so activate() is not + -- called here -- tests/mod_ui_tests.lua exercises it end to end against + -- a real stack. + check(om.rows[om.index].step == nil, "SHADER FX row has no step()") + check(type(om.rows[om.index].activate) == "function", + "SHADER FX row has an activate()") + check(seek("shaderfx2"), "cursor reaches SHADER FX 2") + -- the dual-shader secondary slot: same shared ShaderFXScreen, opened on + -- "secondary" instead -- see the SHADER FX row above for why activate() + -- isn't exercised against this stub stack either. + check(om.rows[om.index].step == nil, "SHADER FX 2 row has no step() either") + check(type(om.rows[om.index].activate) == "function", + "SHADER FX 2 row has an activate()") check(seek("zoom"), "cursor reaches ZOOM") local ZoomOpt = require("src.render.Zoom") press("a") @@ -2806,7 +2822,7 @@ do require("src.core.Sound").applyOptions(og.save.options) PaletteFX.applyOptions(og.save.options) Tilt.applyOptions(og.save.options) - GBCFX.applyOptions(og.save.options) + ShaderFX.applyOptions(og.save.options) require("src.render.Zoom").applyOptions(og.save.options) require("src.render.TileRenderer").applyOptions(og.save.options) require("src.core.VideoMode").applyOptions(og.save.options) @@ -3354,7 +3370,7 @@ end -- ---------------------------------------------- suite discovery -- The chains below used to be hard-coded arrays, so adding a suite meant -- editing a list and forgetting to meant the suite silently never ran. --- They are globbed now (21-testing-and-ci §CI). +-- They're globbed now instead. -- -- Order still matters: these suites share one process and one Data, and -- the sequence they were chained in is the sequence they are known to @@ -3395,10 +3411,6 @@ end -- back. Nothing notices until a LATER file reads the leftover, and then the -- failure lands nowhere near its cause: -- --- * the Android ROM-importer suites pin getOS to "Android", so --- GBCFX.isSupported() answers false for the rest of the run and --- parity_gbcfx fails "setLevel stores an in-range level (got 0, want 2)" --- -- while passing perfectly on its own; -- * suites that swap in a minimal love.filesystem drop -- getDirectoryItems, so three rom_importer suites then die on -- "attempt to call field 'getDirectoryItems' (a nil value)". @@ -3485,10 +3497,14 @@ runSuites({ "tests/rom_importer_cursor_test.lua" }) -- ---------------------------------------------- launcher last played tab (#835) runSuites({ "tests/rom_importer_last_version_test.lua" }) --- ---------------------------------------------- Gold (Gen 2) --- All ROM-free: each file carries its own fixtures shaped like the extractor's --- output, so they run without a Gold cache. -runSuites({ +-- ---------------------------------------------- Gold / Crystal (Gen 2) +-- All ROM-free: own fixtures, or a self-skip on a missing cache. Globbed on +-- the historical chain; tests/run_gen2.lua runs the same set, one per process. +local LEAKS_SAVE_SLOT_STATE = { + ["tests/gen2_save_export_test.lua"] = true, +} +runSuites(orderedGlob( + "tests/gen2_*.lua tests/crystal_*.lua tests/rom_lz3_test.lua", { "tests/rom_lz3_test.lua", "tests/gen2_world_test.lua", "tests/gen2_audio_test.lua", @@ -3599,10 +3615,8 @@ runSuites({ -- name resolution, and the .sav converter refusing a Gen 2 save table. "tests/gen2_sound_alias_test.lua", "tests/gen2_save_convert_cli_test.lua", - -- The wall radios (`special MapRadio`); the Pokegear-proper suites next to - -- it (gen2_pokegear_unlock_test, gen2_save_export_test) stay out of this - -- block because their headers ask for a GOLD_CACHE, which this tier cannot - -- assume. + -- The wall radios (`special MapRadio`). gen2_save_export_test cannot share a + -- process (LEAKS_SAVE_SLOT_STATE above); tests/run_gen2.lua runs it alone. "tests/gen2_map_radio_test.lua", -- The two seams where a battle meets everything else: what ends a round and -- a battle (and what a battle may not leave on the party), and BattlePack -- @@ -3613,7 +3627,26 @@ runSuites({ -- which failures suppress the attack animation, and the Rollout / -- EFFECT_RAMPAGE lock-ins. ROM-free like the rest of this block. "tests/gen2_battle_lockin_test.lua", -}) + -- Crystal rides the same glob: crystal_*.lua and the gen2_crystal_* files. + "tests/crystal_import_test.lua", + "tests/crystal_world_test.lua", + "tests/gen2_crystal_anim_test.lua", + "tests/gen2_crystal_caught_data_test.lua", + "tests/gen2_crystal_gender_test.lua", + -- Pinned in the order the glob already ran them in, alphabetically last. + "tests/gen2_battle_cursor_test.lua", + "tests/gen2_battle_options_test.lua", + "tests/gen2_billspc_dpad_test.lua", + "tests/gen2_box_intake_test.lua", + "tests/gen2_cycling_road_test.lua", + "tests/gen2_dex_gift_test.lua", + "tests/gen2_ledge_hop_test.lua", + "tests/gen2_ow_bounce_test.lua", + "tests/gen2_pack_rows_test.lua", + "tests/gen2_sleep_counter_test.lua", + "tests/gen2_timed_heal_test.lua", + "tests/gen2_whirlpool_test.lua", +}, LEAKS_SAVE_SLOT_STATE)) -- ---------------------------------------------- Android second ROM pick (#167) runSuites({ "tests/rom_importer_android_pick_test.lua" }) @@ -3630,6 +3663,8 @@ runSuites({ "tests/rom_importer_choose_version_test.lua" }) -- platform_nx_* / rom_importer_nx_* live in tests/engine/ (ROM-free T2) so -- CI's headless lane runs them without data/generated/. runSuites({ "tests/launcher_mods_install_zip_test.lua" }) +-- ---------------------------------------------- pet Pokemon cries (#1687, #1649) +runSuites({ "tests/pet_cries_test.lua" }) -- ---------------------------------------------- parity workstream tests -- Each tests/parity_*.lua is a self-contained file (own bootstrap + check, -- error()s if any assertion fails). Globbed, so dropping a new parity @@ -3648,7 +3683,6 @@ runSuites(orderedGlob("tests/parity_*.lua", { "tests/parity_yellow_bills_pikachu.lua", "tests/parity_trainer_evolution_order.lua", "tests/parity_intro.lua", "tests/parity_tilt.lua", - "tests/parity_gbcfx.lua", })) -- ---------------------------------------------- the globbed tiers diff --git a/tests/save_editor_gen2_tests.lua b/tests/save_editor_gen2_tests.lua index b1877437..5383a8fd 100644 --- a/tests/save_editor_gen2_tests.lua +++ b/tests/save_editor_gen2_tests.lua @@ -1,4 +1,4 @@ --- Headless Gold save-editor rules. Run from repo root: +-- Headless Gen 2 save-editor rules. Run from repo root: -- luajit tests/save_editor_gen2_tests.lua package.path = package.path .. ";./?.lua;./?/init.lua;./tools/save-editor/?.lua" .. ";./tools/save-editor/panels/?.lua" @@ -90,6 +90,7 @@ do eq(Gen.of({ generation = 2 }), 2, "Gen.of generation field") eq(Gen.of({ version = "gold" }), 2, "Gen.of version gold") eq(Gen.of({ version = "silver" }), 2, "Gen.of version silver") + eq(Gen.of({ version = "crystal" }), 2, "Gen.of version crystal") eq(Gen.of(SaveData.newGame()), 1, "Gen.of gen1 newGame") eq(Gen.of(Save2.newGame()), 2, "Gen.of gold newGame") end @@ -116,7 +117,7 @@ do check(not Ops.speciesUsable(S1, "BROKEN"), "partial record is not usable") end -for _, version in ipairs({ "gold", "silver" }) do +for _, version in ipairs({ "gold", "silver", "crystal" }) do local S = newState(version) Ops.partyAdd(S) eq(#S.save.party, 1, "partyAdd on " .. version) @@ -424,5 +425,398 @@ do GameVersion.set("red") end +do + GameVersion.set("red") + local crystal = Gen.newGame("crystal") + eq(crystal.version, "crystal", "Gen.newGame('crystal') stamps crystal") + eq(crystal.generation, 2, "crystal stub is generation 2") + eq(crystal.player.name, "CHRIS", "crystal stub uses Crystal's preset name") + eq(Gen.newGame("silver").version, "silver", "Gen.newGame('silver') stamps silver") + eq(Gen.newGame("silver").player.name, "SILVER", "silver stub keeps SILVER") + eq(Gen.newGame("gold").player.name, "GOLD", "gold stub keeps GOLD") + eq(Gen.newGame("blue").version, "blue", "Gen.newGame('blue') stamps blue") + eq(Gen.newGame("yellow").version, "yellow", "Gen.newGame('yellow') stamps yellow") + eq(Gen.newGame(nil).version, "red", "no version falls back to the active game") + eq(Gen.newGame("nonsense").version, "red", "an unknown id is ignored") +end + +do + local crystal = Gen.newGame("crystal") + local gold = Gen.newGame("gold") + eq(Gen.versionOf(crystal), "crystal", "versionOf reads the save") + eq(Gen.engineOf(crystal), "crystal", "crystal saves are the crystal lineage") + eq(Gen.engineOf(gold), "gs", "gold saves are the gs lineage") + eq(Gen.engineOf(Gen.newGame("silver")), "gs", "silver saves are the gs lineage") + eq(Gen.editionLabel(crystal), "CRYSTAL", "edition label follows the save") + eq(Gen.editionLabel(gold), "GOLD", "gold still labels GOLD") + check(Gen.hasCaughtData(crystal), "crystal has caught data") + check(not Gen.hasCaughtData(gold), "gold has no caught data") + check(not Gen.hasCaughtData(SaveData.newGame()), "gen 1 has no caught data") + check(Gen.hasPlayerGender(crystal), "crystal has a player gender") + check(not Gen.hasPlayerGender(gold), "gold has no player gender") +end + +do + local Gen2Flags = require("Gen2Flags") + local gs = Gen2Flags.byName("gs") + local crys = Gen2Flags.byName("crystal") + eq(gs.EVENT_BEAT_FALKNER, crys.EVENT_BEAT_FALKNER, + "a shared event keeps one id in both lineages") + check(gs.EVENT_BEAT_FALKNER ~= nil, "the gs table is populated") + eq(gs.EVENT_GOT_RAINBOW_WING, 120, "Gold's EVENT_GOT_RAINBOW_WING") + eq(crys.EVENT_GOT_RAINBOW_WING, 822, "Crystal renumbers EVENT_GOT_RAINBOW_WING") + eq(gs.EVENT_TIN_TOWER_1F_SUICUNE, nil, "a Crystal-only event is absent on gs") + eq(crys.EVENT_TIN_TOWER_1F_SUICUNE, 1970, "and resolves on crystal") + eq(crys.EVENT_WADE_READY_FOR_REMATCH, nil, "a retired Gold event is absent on crystal") + check(gs.EVENT_WADE_READY_FOR_REMATCH ~= nil, "and still resolves on gs") + check(Gen2Flags.byName("crystal") == crys, "byName caches per lineage") + check(Gen2Flags.byName("gold") == gs, "an unknown lineage reads as gs") +end + +do + local crystalNames = Catalog.gen2EventList("crystal") + local goldNames = Catalog.goldEventList() + local function has(list, name) + for _, n in ipairs(list) do if n == name then return true end end + return false + end + check(has(crystalNames, "EVENT_TIN_TOWER_1F_SUICUNE"), + "the crystal event list names Crystal's own flags") + check(not has(crystalNames, "EVENT_WADE_READY_FOR_REMATCH"), + "the crystal event list drops Gold's retired flags") + check(has(goldNames, "EVENT_WADE_READY_FOR_REMATCH"), + "the gold event list keeps them") + check(has(goldNames, "EVENT_BEAT_FALKNER"), "gold event list still has Falkner") + check(#crystalNames > #goldNames, "Crystal has more event flags than Gold") +end + +do + local S = newState("crystal") + local name = "EVENT_TIN_TOWER_1F_SUICUNE" + Ops.setFlag(S, name, true) + check(Gen.getFlag(S.save, name), "a Crystal-only event reads back set") + eq(S.save.flags[name], nil, "and never lands as a string key") + check(next(S.save.events) ~= nil, "it wrote the numeric bitfield") + + local G = newState("gold") + Ops.setFlag(G, name, true) + eq(G.save.flags[name], true, "the same name on Gold has no id and stays named") + + local moved = newState("crystal") + Ops.setFlag(moved, "EVENT_GOT_RAINBOW_WING", true) + local Events2 = require("src.world.gen2.Events") + local ev = Events2.new() + ev:restore(moved.save.events) + check(ev:get(822), "EVENT_GOT_RAINBOW_WING sets Crystal's bit 822") + check(not ev:get(120), "not Gold's bit 120") +end + +do + local S = newState("crystal") + Ops.partyAdd(S) + local mon = S.save.party[1] + + check(Ops.setCaughtTime(S, mon, 2), "caught time set") + eq(mon.caughtTime, 2, "caught time DAY") + check(S.dirty, "caught time dirties the save") + check(not Ops.setCaughtTime(S, mon, 2), "re-setting the same time only says") + Ops.setCaughtTime(S, mon, 9) + eq(mon.caughtTime, 3, "caught time clamps to NITE") + Ops.setCaughtTime(S, mon, -1) + eq(mon.caughtTime, 0, "caught time clamps to unknown") + + Ops.setCaughtLevel(S, mon, 40) + eq(mon.caughtLevel, 40, "caught level 40") + Ops.setCaughtLevel(S, mon, 99) + eq(mon.caughtLevel, 63, "caught level clamps to CAUGHT_LEVEL_MASK") + + Ops.setCaughtLocation(S, mon, 12) + eq(mon.caughtLocation, 12, "caught location 12") + Ops.setCaughtLocation(S, mon, -1) + eq(mon.caughtLocation, 127, "stepping below 0 wraps to LANDMARK_EVENT") + Ops.setCaughtLocation(S, mon, 128) + eq(mon.caughtLocation, 0, "and stepping past the mask wraps back to unknown") + + Ops.setCaughtByGender(S, mon, "girl") + eq(mon.caughtByGender, "girl", "caught by girl") + Ops.setCaughtByGender(S, mon, "none") + eq(mon.caughtByGender, nil, "caught by clears to nil, the Gold-save value") + + local Mon = require("src.battle.gen2.Mon") + Ops.setCaughtTime(S, mon, 3) + Ops.setCaughtLevel(S, mon, 20) + Ops.setCaughtLocation(S, mon, 5) + Ops.setCaughtByGender(S, mon, "girl") + local byte0, byte1 = Mon.packCaughtData(mon) + eq(byte0, 3 * 0x40 + 20, "the editor's fields pack into byte 0") + eq(byte1, 0x80 + 5, "and into byte 1") +end + +do + local G = newState("gold") + Ops.partyAdd(G) + local mon = G.save.party[1] + G.dirty = false + check(not Ops.setCaughtTime(G, mon, 2), "Gold refuses caught time") + eq(mon.caughtTime, nil, "and writes nothing") + check(not Ops.setCaughtLevel(G, mon, 20), "Gold refuses caught level") + check(not Ops.setCaughtLocation(G, mon, 5), "Gold refuses caught location") + check(not Ops.setCaughtByGender(G, mon, "boy"), "Gold refuses caught gender") + check(not G.dirty, "a refused caught edit never dirties") +end + +do + local S = newState("crystal") + eq(Gen.playerGender(S.save), "male", "a new Crystal save starts male") + check(Ops.setPlayerGender(S, "female"), "player gender set") + eq(S.save.player.gender, "female", "written to save.player.gender") + check(not Ops.setPlayerGender(S, "female"), "re-setting the same gender only says") + Ops.setPlayerGender(S, "male") + eq(Gen.playerGender(S.save), "male", "and back") + + local G = newState("gold") + check(not Ops.setPlayerGender(G, "female"), "Gold refuses a gender change") + eq(G.save.player.gender, "male", "Gold's field is left alone") +end + +do + local landmarks = { + gen2Landmarks = { + landmarks = { + LANDMARK_AZALEA_TOWN = { id = "LANDMARK_AZALEA_TOWN", index = 12, + name = "AZALEA TOWN" }, + LANDMARK_BURNED_TOWER = { id = "LANDMARK_BURNED_TOWER", index = 24, + name = "BURNED\nTOWER" }, + }, + }, + } + eq(Gen.landmarkName(landmarks, 12), "AZALEA TOWN", "landmark by index") + eq(Gen.landmarkName(landmarks, 24), "BURNED TOWER", "two-line names flatten") + eq(Gen.landmarkName(landmarks, 0), "UNKNOWN", "0 reads as unknown") + eq(Gen.landmarkName(landmarks, 0x7e), "GIFT", "LANDMARK_GIFT") + eq(Gen.landmarkName(landmarks, 0x7f), "EVENT", "LANDMARK_EVENT") + eq(Gen.landmarkName(landmarks, 99), "#99", "an unmapped index shows its number") +end + +do + local Kit = require("Kit") + local MonEditor = require("MonEditor") + local ItemsPanel = require("Items") + + local function clipped(r) + local x1, y1, x2, y2 = r.x, r.y, r.x + r.w, r.y + r.h + if r.clip then + x1 = math.max(x1, r.clip.x); y1 = math.max(y1, r.clip.y) + x2 = math.min(x2, r.clip.x + r.clip.w); y2 = math.min(y2, r.clip.y + r.clip.h) + end + if x2 - x1 <= 1 or y2 - y1 <= 1 then return nil end + return x1, y1, x2, y2 + end + + local function overlap(a, b) + local ax1, ay1, ax2, ay2 = clipped(a) + if not ax1 then return false end + local bx1, by1, bx2, by2 = clipped(b) + if not bx1 then return false end + return math.min(ax2, bx2) - math.max(ax1, bx1) > 1 + and math.min(ay2, by2) - math.max(ay1, by1) > 1 + end + + local function auditFrame(label, W, H) + local controls = {} + for _, r in ipairs(Kit.audit or {}) do + if r.class == "control" then controls[#controls + 1] = r end + end + check(#controls > 0, label .. ": the frame dispatched controls at all") + local collisions, escapes = 0, 0 + for i = 1, #controls do + local a = controls[i] + local x1, y1, x2, y2 = clipped(a) + if x1 and (x1 < -0.5 or y1 < -0.5 or x2 > W + 0.5 or y2 > H + 0.5) then + escapes = escapes + 1 + print((" escape: %s (%.0f,%.0f %.0fx%.0f)") + :format(a.label, a.x, a.y, a.w, a.h)) + end + for j = i + 1, #controls do + if overlap(a, controls[j]) then + collisions = collisions + 1 + print((" overlap: '%s' vs '%s' at (%.0f,%.0f) / (%.0f,%.0f)") + :format(a.label, controls[j].label, a.x, a.y, + controls[j].x, controls[j].y)) + end + end + end + check(collisions == 0, label .. ": no two controls overlap") + check(escapes == 0, label .. ": every control stays inside the panel") + end + + local sizes = { { 420, 700 }, { 640, 768 }, { 1280, 720 } } + for _, version in ipairs({ "gold", "crystal" }) do + for _, size in ipairs(sizes) do + local W, H = size[1], size[2] + local S = newState(version) + Ops.partyAdd(S) + Ops.selectParty(S, 1) + Ops.addToBag(S, S.cat.items[1]) + Ops.addToPc(S, S.cat.items[2]) + Kit.layout(W, H) + for _, panel in ipairs({ { "inspector", MonEditor }, { "items", ItemsPanel } }) do + local label = ("%s %dx%d %s"):format(version, W, H, panel[1]) + Kit.beginFrame(-100, -100, false, 0) + Kit.audit = {} + local ok, err = pcall(panel[2].draw, S, Kit, 0, 0, W, H) + check(ok, label .. " draws: " .. tostring(err)) + if ok then + Kit.beginFrame(-100, -100, false, 0) + Kit.audit = {} + ok, err = pcall(panel[2].draw, S, Kit, 0, 0, W, H) + check(ok, label .. " redraws: " .. tostring(err)) + end + if ok then auditFrame(label, W, H) end + Kit.audit = nil + end + end + end + + local S = newState("crystal") + Ops.partyAdd(S) + Ops.selectParty(S, 1) + Kit.layout(1280, 720) + Kit.beginFrame(-100, -100, false, 0) + Kit.audit = {} + MonEditor.draw(S, Kit, 0, 0, 1280, 720) + local labels = {} + for _, r in ipairs(Kit.audit) do labels[r.label] = true end + Kit.audit = nil + check(labels.MORN and labels.NITE, "the Crystal inspector offers the caught times") + check(labels.BOY and labels.GIRL, "and the OT gender chips") + + local G = newState("gold") + Ops.partyAdd(G) + Ops.selectParty(G, 1) + Kit.beginFrame(-100, -100, false, 0) + Kit.audit = {} + MonEditor.draw(G, Kit, 0, 0, 1280, 720) + local goldLabels = {} + for _, r in ipairs(Kit.audit) do goldLabels[r.label] = true end + Kit.audit = nil + check(not goldLabels.MORN, "the Gold inspector has no caught row") + + local function itemsLabels(state) + Kit.beginFrame(-100, -100, false, 0) + Kit.audit = {} + ItemsPanel.draw(state, Kit, 0, 0, 1280, 720) + local seen = {} + for _, r in ipairs(Kit.audit) do seen[r.label] = true end + Kit.audit = nil + return seen + end + check(itemsLabels(S).GIRL, "the Crystal Items tab carries the TRAINER card") + check(not itemsLabels(G).GIRL, "the Gold Items tab does not") + + local function bottomOverflow(state) + local H = 380 + Kit.layout(640, H) + state.inspectorScroll = 100000 + for _ = 1, 2 do + Kit.beginFrame(-100, -100, false, 0) + Kit.audit = {} + MonEditor.draw(state, Kit, 0, 0, 640, H) + end + local lowest = 0 + for _, r in ipairs(Kit.audit) do + if r.class == "control" then lowest = math.max(lowest, r.y + r.h) end + end + Kit.audit = nil + return math.floor(lowest - H + 0.5) + end + local goldOver = bottomOverflow(G) + eq(bottomOverflow(S), goldOver, + "the caught rows are fully counted in the inspector's scroll height") + check(goldOver <= 4, ("the inspector reaches its last control (%d px short)") + :format(goldOver)) +end + +local function readable(path) + local fh = io.open(path, "r") + if not fh then return false end + fh:close() + return true +end + +do + local Gen2Flags = require("Gen2Flags") + local goldAsm = (os.getenv("POKEGOLD") or "../pokegold") + .. "/constants/event_flags.asm" + local crystalAsm = (os.getenv("POKECRYSTAL") or "../pokecrystal") + .. "/constants/event_flags.asm" + if readable(goldAsm) and readable(crystalAsm) and readable("tools/goldwalk/flags.lua") then + local Flags = dofile("tools/goldwalk/flags.lua") + local gold = Flags.parse(goldAsm) + local crys = Flags.parse(crystalAsm) + local addedBad, removedBad, missing = 0, 0, 0 + for name, id in pairs(crys) do + if gold[name] ~= id and Gen2Flags.CRYSTAL_ADDED[name] ~= id then + missing = missing + 1 + end + end + for name, id in pairs(Gen2Flags.CRYSTAL_ADDED) do + if crys[name] ~= id then addedBad = addedBad + 1 end + end + for name in pairs(Gen2Flags.CRYSTAL_REMOVED) do + if crys[name] ~= nil or gold[name] == nil then removedBad = removedBad + 1 end + end + eq(missing, 0, "every Crystal event Gold disagrees on is in CRYSTAL_ADDED") + eq(addedBad, 0, "every CRYSTAL_ADDED id matches pokecrystal") + eq(removedBad, 0, "every CRYSTAL_REMOVED name is Gold-only") + local byName = Gen2Flags.byName("crystal") + local wrong = 0 + for name, id in pairs(crys) do + if byName[name] ~= id then wrong = wrong + 1 end + end + eq(wrong, 0, "the resolved crystal table is pokecrystal's whole event list") + else + check(true, "pokegold / pokecrystal absent : flag delta cross-check SKIPPED") + end +end + +do + local Gen2Flags = require("Gen2Flags") + local cache = os.getenv("CRYSTAL_CACHE") + or ((os.getenv("HOME") or "") .. + "/Library/Application Support/LOVE/lead-crystal/crystal") + local initialPath = cache .. "/data/generated/initial_events.lua" + local stdScripts = (os.getenv("POKECRYSTAL") or "../pokecrystal") + .. "/engine/events/std_scripts.asm" + if readable(initialPath) and readable(stdScripts) then + local Flags = dofile("tools/goldwalk/flags.lua") + local romIds = assert(loadfile(initialPath))().flags + -- InitializeEventsScript sets EVENT_AZALEA_TOWN_KURT twice in a row + -- (../pokecrystal/engine/events/std_scripts.asm:575-576). + local names = {} + for _, n in ipairs(Flags.setEventsOf(stdScripts, "InitializeEventsScript")) do + if names[#names] ~= n then names[#names + 1] = n end + end + local byName = Gen2Flags.byName("crystal") + eq(#names, #romIds, "InitializeEventsScript sets as many flags as the cart") + local mismatches, lo, hi = 0, math.huge, -math.huge + for i, name in ipairs(names) do + local got = romIds[i] + if got then lo, hi = math.min(lo, got), math.max(hi, got) end + if byName[name] ~= got then + mismatches = mismatches + 1 + if mismatches <= 5 then + print((" #%d %s table=%s cart=%s") + :format(i, name, tostring(byName[name]), tostring(got))) + end + end + end + eq(mismatches, 0, + ("crystal names == cart ids across %d..%d"):format(lo, hi)) + else + check(true, "crystal cache absent : cart cross-check SKIPPED") + end +end + print(string.format("save editor gen2 tests: %d passed, %d failed", passed, failed)) if failed > 0 then os.exit(1) end diff --git a/tests/switch_transfer_docs_test.lua b/tests/switch_transfer_docs_test.lua index 6dbe1198..71596684 100644 --- a/tests/switch_transfer_docs_test.lua +++ b/tests/switch_transfer_docs_test.lua @@ -66,7 +66,6 @@ mustContain(install, "Select + **A**", "install") mustContain(install, "Select + **L**", "install") mustContain(install, "COLORS", "install") mustContain(install, "TILT", "install") -mustContain(install, "GBC FX", "install") mustContain(install, "PERFORMANCE", "install") mustContain(install, "Stock engine effect", "install") mustContain(install, "## Limitations", "install") diff --git a/tools/build_rom_data.py b/tools/build_rom_data.py index e76c17c2..31e06cca 100755 --- a/tools/build_rom_data.py +++ b/tools/build_rom_data.py @@ -34,6 +34,28 @@ DATASETS = ( "text", "field", "battle_anims", ) +# Sound is decoded by the Lua importer (src/import/RomExtractor.lua), not here, +# so --clean must step around it rather than delete what it cannot rebuild. +UNOWNED_DATA = ("audio.lua",) +UNOWNED_ASSETS = ("audio",) + + +def clean_generated(out_dir, assets_dir): + """Empty the generated dirs, keeping artifacts this tool never writes.""" + kept = [] + for path, spared in ((out_dir, UNOWNED_DATA), (assets_dir, UNOWNED_ASSETS)): + if not os.path.isdir(path): + continue + for name in os.listdir(path): + target = os.path.join(path, name) + if name in spared: + kept.append(target) + elif os.path.isdir(target): + shutil.rmtree(target) + else: + os.remove(target) + return kept + _TOOLS_DIR = os.path.dirname(os.path.abspath(__file__)) VERSION_MANIFESTS = { "red": os.path.join(_TOOLS_DIR, "rom_manifest.json"), @@ -2207,9 +2229,9 @@ def main(argv=None): out_dir = args.out or prefix + os.path.join("data", "generated") assets_dir = args.assets or prefix + os.path.join("assets", "generated") if args.clean: - for path in (out_dir, assets_dir): - if os.path.isdir(path): - shutil.rmtree(path) + kept = clean_generated(out_dir, assets_dir) + for path in kept: + print(f"kept {path} (not produced by this tool)") os.makedirs(out_dir, exist_ok=True) os.makedirs(assets_dir, exist_ok=True) datasets = tuple(args.only) if args.only else DATASETS diff --git a/tools/cart_release_workflow.yml b/tools/cart_release_workflow.yml new file mode 100644 index 00000000..b08d1ad6 --- /dev/null +++ b/tools/cart_release_workflow.yml @@ -0,0 +1,122 @@ +name: Release + +on: + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + tag: + description: "Existing v tag to build and publish." + required: true + +permissions: + contents: write + +concurrency: + group: release + cancel-in-progress: false + +env: + CART_ID: "{{CART_ID}}" + CARTKIT_REPO: bryanthaboi/gen1recomp + CARTKIT_REF: dev + +jobs: + release: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.event.inputs.tag || github.ref }} + fetch-depth: 0 + + - name: Fetch cartkit + run: | + set -euo pipefail + curl -fsSL --retry 3 -o "$RUNNER_TEMP/cartkit.py" \ + "https://raw.githubusercontent.com/${CARTKIT_REPO}/${CARTKIT_REF}/tools/cartkit.py" + python3 "$RUNNER_TEMP/cartkit.py" selftest --quiet + + - name: Check the tag against cart.json + id: cart + env: + TAG: ${{ github.event.inputs.tag || github.ref_name }} + run: | + set -euo pipefail + python3 - <<'PY' >> "$GITHUB_OUTPUT" + import json, os, sys + + with open("cart.json", encoding="utf-8") as fh: + cart = json.load(fh) + version = str(cart.get("version", "")) + cart_id = str(cart.get("id", "")) + tag = os.environ["TAG"] + if tag != f"v{version}": + print(f"::error::tag {tag} does not match cart.json version " + f"{version} (expected v{version})", file=sys.stderr) + raise SystemExit(1) + stamped = os.environ["CART_ID"] + if cart_id != stamped: + print(f"::error::cart.json id is {cart_id}, but this workflow " + f"was stamped for {stamped}; rerun cartkit " + "add-release-workflow", file=sys.stderr) + raise SystemExit(1) + print(f"version={version}") + print(f"id={cart_id}") + print(f"tag={tag}") + PY + + - name: Validate every pin + env: + GITHUB_TOKEN: ${{ github.token }} + run: python3 "$RUNNER_TEMP/cartkit.py" validate . --online --strict + + - name: Pack the cart + env: + CART_VERSION: ${{ steps.cart.outputs.version }} + CART_NAME: ${{ steps.cart.outputs.id }} + run: | + set -euo pipefail + out="$GITHUB_WORKSPACE/dist" + rm -rf "$out" + mkdir -p "$out" + python3 "$RUNNER_TEMP/cartkit.py" pack . \ + -o "$out/${CART_NAME}-${CART_VERSION}.g1rcart" + (cd "$out" && sha256sum ./*.g1rcart > sha256sums.txt) + cat "$out/sha256sums.txt" + + - name: Publish GitHub Release + env: + GH_TOKEN: ${{ github.token }} + CART_VERSION: ${{ steps.cart.outputs.version }} + CART_NAME: ${{ steps.cart.outputs.id }} + TAG: ${{ steps.cart.outputs.tag }} + run: | + set -euo pipefail + + prev="$(git tag -l 'v*' --sort=-v:refname | grep -v "^${TAG}$" | head -1 || true)" + range="${prev:+${prev}..}${TAG}" + changes="$(git log --no-merges --pretty='- %s' "$range" | head -50 || true)" + + notes=$'Download the .g1rcart and open it from the game to install this cart.' + notes+=$'\n\nThe cart is a manifest: it pins each mod to the exact build listed in cart.json and ships no code of its own.' + if [ -n "$changes" ]; then + notes+=$'\n\n## Changes\n\n'"$changes" + fi + + asset="dist/${CART_NAME}-${CART_VERSION}.g1rcart" + if gh release view "$TAG" >/dev/null 2>&1; then + gh release upload "$TAG" "$asset" "dist/sha256sums.txt" --clobber + else + gh release create "$TAG" \ + --target "$GITHUB_SHA" \ + --title "$CART_VERSION" \ + --notes "$notes" \ + "$asset" \ + "dist/sha256sums.txt" + fi + + echo "Published $TAG" diff --git a/tools/cartkit.py b/tools/cartkit.py new file mode 100644 index 00000000..0e1db64a --- /dev/null +++ b/tools/cartkit.py @@ -0,0 +1,1770 @@ +#!/usr/bin/env python3 +"""cartkit: the custom-cart authoring CLI. + + python3 tools/cartkit.py [args] + +Subcommands: + scaffold [--title T] [--author A] [--base red|blue|yellow|gold|silver] + [--shell "#rrggbb"] [--seal sealed|open] [--summary S] + [--github owner/repo] [--into DIR] [--force] + validate [--online] [--no-download] [--strict] + pin [--id ID] [--option k=v]... [--file N] + [--clear-options] + pack [-o out.g1rcart] [--online] [--no-download] + add-release-workflow [--force] + selftest + +A cart is a manifest, not code: cart.json names a base game and a list of mods +pinned to exact published builds. for pin is "owner/repo@1.2.3", a +GameBanana mod URL, or a GameBanana mod id. + +Global flags: --repo PATH, --json, --quiet. +Exit codes: 0 success, 1 validation/pack failure, 2 usage error. + +validate is offline by default and checks the manifest alone; --online also +resolves every pin against GitHub and GameBanana and compares the published +hash. A pin that a reachable API says does not exist is a finding; an API +that could not be reached is a note, so a rate limit or a dropped connection +never fails a cart that is fine. pack runs validate --strict, so a warning +refuses the bundle too. +""" + +import argparse +import base64 +import contextlib +import hashlib +import io +import json +import os +import re +import shutil +import struct +import sys +import tempfile +import time +import urllib.error +import urllib.parse +import urllib.request +import zlib + +CARTKIT_VERSION = "1.0.0" +USER_AGENT = ("cartkit/%s (gen1recomp custom carts; " + "+https://github.com/bryanthaboi/gen1recomp)" % CARTKIT_VERSION) + +CART_FILE = "cart.json" +CART_EXT = ".g1rcart" +BUNDLE_FORMAT = "g1rcart" +BUNDLE_VERSION = 1 +CART_SCHEMA = 1 + +BASES = ("red", "blue", "yellow", "gold", "silver", "crystal") +SEALS = ("sealed", "sealed+", "open") +FINISHES = ("sparkle", "holo", "sparkle+holo") +# GameSpeed.LEVELS (src/core/GameSpeed.lua) +SPEED_LEVELS = (1, 2, 3, 4, 10, 20, 30, 50, 75, 100, 200) +SOURCES = ("github", "gamebanana") +MAX_MODS = 64 +MAX_OPTIONS = 64 +MAX_LABEL_PATH = 128 +LABEL_WARN_BYTES = 256 * 1024 +LABEL_MAX_BYTES = 1024 * 1024 + +CART_KEYS = ("schema", "id", "title", "version", "author", "repo", "summary", + "shell", "finish", "label", "base", "engine", "seal", "speeds", + "mods", "load_order") +MOD_KEYS = ("id", "source", "repo", "version", "sha256", "mod", "file", "md5", + "enabled", "options") + +ID_RE = re.compile(r"[A-Za-z0-9_-]{1,64}") +SEMVER_RE = re.compile( + r"(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)" + r"(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?" + r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?") +SHELL_RE = re.compile(r"#[0-9a-fA-F]{6}") +SHA256_RE = re.compile(r"[0-9a-f]{64}") +MD5_RE = re.compile(r"[0-9a-f]{32}") +REPO_RE = re.compile(r"[A-Za-z0-9][\w.\-]*/[A-Za-z0-9][\w.\-]*") +LABEL_RE = re.compile(r"[A-Za-z0-9_][A-Za-z0-9_.\-]*") +CONTROL_RE = re.compile(r"[\x00-\x1f\x7f]") +LUA_NAME_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*") + +GITHUB_SPEC = re.compile( + r"^(?:https?://github\.com/)?([\w.\-]+)/([\w.\-]+?)(?:\.git)?@(.+)$") +GITHUB_SLUG = re.compile( + r"^(?:https?://github\.com/)?([\w.\-]+)/([\w.\-]+?)(?:\.git)?/?$") +GAMEBANANA_SPEC = re.compile( + r"^(?:gamebanana:|(?:https?://)?(?:www\.)?gamebanana\.com/mods/)?(\d+)/?$") + +PLACEHOLDER_REPO = "owner/example-mod" +PLACEHOLDER_SHA = "0" * 64 + + +# ---------------------------------------------------------------- findings + +class Finding: + def __init__(self, rule, severity, message, path=None): + self.rule = rule + self.severity = severity + self.message = message + self.path = path + + def as_dict(self): + return {"rule": self.rule, "severity": self.severity, + "message": self.message, "path": self.path} + + def line(self): + where = f"{self.path}: " if self.path else "" + return f"{self.rule} {self.severity.upper():5} {where}{self.message}" + + +def err(rule, message, path=CART_FILE): + return Finding(rule, "error", message, path) + + +def warn(rule, message, path=CART_FILE): + return Finding(rule, "warn", message, path) + + +def report(findings, args, summary_ok, summary_fail, notes=None): + notes = notes or [] + errors = [f for f in findings if f.severity == "error"] + if getattr(args, "strict", False): + errors = list(findings) + if args.json: + print(json.dumps({"ok": not errors, + "findings": [f.as_dict() for f in findings], + "notes": notes})) + else: + for f in findings: + print(f.line()) + if not args.quiet: + for note in notes: + print(f"cartkit: {note}") + print(summary_fail if errors else summary_ok) + return 1 if errors else 0 + + +# ---------------------------------------------------------------- repo/root + +def find_repo(start): + node = os.path.abspath(start) + while True: + if os.path.isfile(os.path.join(node, "tools", "rom_manifest.json")): + return node + parent = os.path.dirname(node) + if parent == node: + return None + node = parent + + +def engine_version(repo): + try: + src = open(os.path.join(repo, "src", "core", "Version.lua"), + encoding="utf-8").read() + except OSError: + return "0.0.0-dev" + match = re.search(r'engine\s*=\s*"([^"]+)"', src) + return match.group(1) if match else "0.0.0-dev" + + +def resolve_cart_dir(repo, arg): + path = os.path.abspath(arg) + if os.path.isfile(path) and os.path.basename(path) == CART_FILE: + return os.path.dirname(path) + if os.path.isdir(path): + return path + if repo: + candidate = os.path.join(repo, "carts", arg) + if os.path.isdir(candidate): + return candidate + return None + + +def read_cart(cart_dir): + path = os.path.join(cart_dir, CART_FILE) + if not os.path.isfile(path): + return None, err("CK001", f"{CART_FILE} missing; run " + "cartkit scaffold to make one") + try: + cart = json.load(open(path, encoding="utf-8")) + except ValueError as problem: + return None, err("CK001", f"{CART_FILE} unparseable: {problem}") + if not isinstance(cart, dict): + return None, err("CK001", f"{CART_FILE} must be a JSON object") + return cart, None + + +def write_cart(cart_dir, cart): + ordered = {key: cart[key] for key in CART_KEYS if key in cart} + for key in cart: + if key not in ordered: + ordered[key] = cart[key] + with open(os.path.join(cart_dir, CART_FILE), "w", encoding="utf-8") as fh: + json.dump(ordered, fh, indent=2, ensure_ascii=False) + fh.write("\n") + + +# ---------------------------------------------------------------- schema + +def text_problem(value, low, high): + if not isinstance(value, str): + return "must be a string" + if CONTROL_RE.search(value): + return "must not contain control characters" + if len(value) < low: + return f"must be at least {low} character(s)" + if len(value) > high: + return f"must be at most {high} characters (got {len(value)})" + return None + + +def full_match(pattern, value): + return isinstance(value, str) and pattern.fullmatch(value) is not None + + +RANGE_OPS = {"", "=", "==", ">", ">=", "<", "<=", "^"} +RANGE_CORE = re.compile(r"[vV]?\d+(?:\.\d+){0,2}(?:-[0-9A-Za-z.\-]+)?" + r"(?:\+[0-9A-Za-z.\-]+)?") + + +def range_problem(text): + if not isinstance(text, str) or not text.strip(): + return "must be a non-empty semver range string" + for alternative in text.split("||"): + tokens = alternative.split() + if not tokens: + return "has an empty alternative around '||'" + for token in tokens: + head = re.match(r"^[=<>^]*", token).group(0) + if head not in RANGE_OPS: + return f"has an unknown comparator {head!r}" + if not RANGE_CORE.fullmatch(token[len(head):]): + return f"has an unparsable version in {token!r}" + return None + + +def label_problem(value): + if not isinstance(value, str) or not value: + return "must be a non-empty string" + if len(value) > MAX_LABEL_PATH: + return f"must be at most {MAX_LABEL_PATH} characters" + if value.startswith("/") or "\\" in value \ + or re.match(r"^[A-Za-z]:", value): + return "must be a relative path inside the cart" + segments = [s for s in value.split("/") if s and s != "."] + if not segments or any(s == ".." for s in segments): + return "must not leave the cart directory" + if not all(LABEL_RE.fullmatch(s) for s in segments): + return ("must be a plain relative path " + "(letters, digits, . _ - and /)") + return None + + +def option_problems(options, label): + problems = [] + if not isinstance(options, dict): + return [f"{label} options must be an object"] + if len(options) > MAX_OPTIONS: + problems.append(f"{label} has {len(options)} options (max " + f"{MAX_OPTIONS})") + for key, value in sorted(options.items()): + if not isinstance(key, str) or CONTROL_RE.search(key): + problems.append(f"{label} option key {key!r} must be plain text") + continue + if not 1 <= len(key) <= 64: + problems.append(f"{label} option key {key!r} must be 1..64 " + "characters") + if isinstance(value, bool): + continue + if isinstance(value, str): + if CONTROL_RE.search(value): + problems.append(f"{label} option {key!r} must not contain " + "control characters") + elif len(value) > 256: + problems.append(f"{label} option {key!r} is {len(value)} " + "characters (max 256)") + elif isinstance(value, (int, float)): + if isinstance(value, float) and (value != value or + value in (float("inf"), + float("-inf"))): + problems.append(f"{label} option {key!r} must be a finite " + "number") + else: + problems.append(f"{label} option {key!r} must be a string, " + "number or boolean") + return problems + + +def check_identity(cart, findings): + schema = cart.get("schema") + if schema is None: + findings.append(err("CK002", '"schema" is missing; add "schema": 1')) + elif isinstance(schema, bool) or not isinstance(schema, int): + findings.append(err("CK002", '"schema" must be the number 1')) + elif schema != CART_SCHEMA: + findings.append(err("CK002", f'"schema" is {schema}; this cartkit ' + f"writes and reads schema {CART_SCHEMA}")) + + if not full_match(ID_RE, cart.get("id")): + findings.append(err("CK002", '"id" must be 1..64 characters of ' + "letters, digits, _ or -")) + + for key, low, high in (("title", 1, 48), ("author", 1, 64)): + problem = text_problem(cart.get(key), low, high) + if problem: + findings.append(err("CK002", f'"{key}" {problem}')) + + if not full_match(SEMVER_RE, cart.get("version")): + findings.append(err("CK002", '"version" must be semver, e.g. 1.0.0')) + + if "repo" in cart and cart["repo"] is not None: + if not full_match(REPO_RE, cart["repo"]): + findings.append(err("CK002", '"repo" must be owner/name')) + + if "summary" in cart and cart["summary"] is not None: + problem = text_problem(cart["summary"], 0, 120) + if problem: + findings.append(err("CK002", f'"summary" {problem}')) + + if not full_match(SHELL_RE, cart.get("shell")): + findings.append(err("CK002", '"shell" must be "#rrggbb", e.g. ' + '"#8b1a1a"')) + + if cart.get("base") not in BASES: + findings.append(err("CK002", '"base" must be one of ' + + ", ".join(BASES))) + + if "engine" in cart and cart["engine"] is not None: + problem = range_problem(cart["engine"]) + if problem: + findings.append(err("CK002", f'"engine" {problem}; write it like ' + '">=1.0.0 <2.0.0"')) + + seal = cart.get("seal", "sealed") + if seal not in SEALS: + findings.append(err("CK002", '"seal" must be one of: ' + + ", ".join(SEALS))) + finish = cart.get("finish") + if finish is not None and finish not in FINISHES: + findings.append(err("CK002", '"finish" must be one of: ' + + ", ".join(FINISHES))) + speeds = cart.get("speeds") + if speeds is not None: + if not isinstance(speeds, list) or not speeds: + findings.append(err("CK002", + '"speeds" must be a non-empty array')) + elif any(v not in SPEED_LEVELS for v in speeds): + findings.append(err("CK002", '"speeds" entries must be game-speed ' + 'levels: ' + + ", ".join(str(v) for v in SPEED_LEVELS))) + + for key in cart: + if key not in CART_KEYS: + findings.append(warn("CK001", f"unknown field {key!r}; cartkit " + "packs only the documented fields")) + + +def check_label(cart, cart_dir, findings): + label = cart.get("label") + if label is None: + return + problem = label_problem(label) + if problem: + findings.append(err("CK003", f'"label" {problem}')) + return + if cart_dir is None: + return + path = os.path.join(cart_dir, label) + if not os.path.isfile(path): + findings.append(err("CK003", f"label art {label!r} is missing from " + "the cart directory", label)) + return + if not os.path.realpath(path).startswith( + os.path.realpath(cart_dir) + os.sep): + findings.append(err("CK003", f"label art {label!r} resolves outside " + "the cart directory", label)) + return + if not label.lower().endswith(".png"): + findings.append(warn("CK003", "label art should be a .png; the game " + "draws nothing else", label)) + size = os.path.getsize(path) + if size > LABEL_MAX_BYTES: + findings.append(err("CK003", f"label art is {size} bytes; keep it " + f"under {LABEL_MAX_BYTES} so the bundle " + "stays small", label)) + elif size > LABEL_WARN_BYTES: + findings.append(warn("CK003", f"label art is {size} bytes; a cart " + "label wants a few KB, not a photo", + label)) + + +def check_mods(cart, findings): + mods = cart.get("mods") + if not isinstance(mods, list) or not mods: + findings.append(err("CK004", '"mods" must list 1..64 pinned mods; a ' + "cart with no mods is just the base " + "game")) + return [] + if len(mods) > MAX_MODS: + findings.append(err("CK004", f'"mods" has {len(mods)} entries (max ' + f"{MAX_MODS})")) + seen = {} + ids = [] + for index, entry in enumerate(mods): + label = f"mods[{index + 1}]" + if not isinstance(entry, dict): + findings.append(err("CK004", f"{label} must be an object")) + continue + mod_id = entry.get("id") + if not full_match(ID_RE, mod_id): + findings.append(err("CK004", f"{label} id must be 1..64 " + "characters of letters, digits, _ " + "or -")) + else: + label = f"mods[{index + 1}] {mod_id}" + if mod_id in seen: + findings.append(err("CK004", f"{label} repeats the id pinned " + f"at mods[{seen[mod_id] + 1}]; " + "one pin per mod")) + else: + seen[mod_id] = index + ids.append(mod_id) + source = entry.get("source") + if source == "local": + findings.append(err("CK004", f"{label} is pinned to one install; " + "a published cart needs a github or " + "gamebanana pin nobody else has to " + "guess at")) + elif source not in SOURCES: + findings.append(err("CK004", f"{label} source must be " + + " or ".join(SOURCES))) + elif source == "github": + check_github_pin(entry, label, findings) + else: + check_gamebanana_pin(entry, label, findings) + if "enabled" in entry and not isinstance(entry["enabled"], bool): + findings.append(err("CK004", f"{label} enabled must be true or " + "false; omit it to ship the mod on")) + if "options" in entry and entry["options"] is not None: + for problem in option_problems(entry["options"], label): + findings.append(err("CK004", problem)) + for key in entry: + if key not in MOD_KEYS: + findings.append(warn("CK004", f"{label} has unknown field " + f"{key!r}")) + return ids + + +def check_github_pin(entry, label, findings): + if not full_match(REPO_RE, entry.get("repo")): + findings.append(err("CK004", f"{label} repo must be owner/name")) + elif str(entry["repo"]).lower() == PLACEHOLDER_REPO: + findings.append(warn("CK004", f"{label} still points at the scaffold " + "placeholder; pin a real release with " + "cartkit pin owner/repo@X.Y.Z")) + if not full_match(SEMVER_RE, entry.get("version")): + findings.append(err("CK004", f"{label} version must be semver, e.g. " + "1.2.3")) + sha = entry.get("sha256") + if not full_match(SHA256_RE, sha): + findings.append(err("CK004", f"{label} sha256 must be 64 lowercase " + "hex characters; cartkit pin resolves it " + "for you")) + elif sha == PLACEHOLDER_SHA: + findings.append(warn("CK004", f"{label} carries the scaffold " + "placeholder hash; resolve it with " + "cartkit pin")) + for key in ("mod", "file", "md5"): + if key in entry: + findings.append(warn("CK004", f"{label} has {key!r}, which " + "belongs to a gamebanana pin")) + + +def check_gamebanana_pin(entry, label, findings): + for key in ("mod", "file"): + value = entry.get(key) + if isinstance(value, bool) or not isinstance(value, int) or value <= 0: + findings.append(err("CK004", f"{label} {key} must be a positive " + "integer id")) + if not full_match(MD5_RE, entry.get("md5")): + findings.append(err("CK004", f"{label} md5 must be 32 lowercase hex " + "characters; cartkit pin resolves it for " + "you")) + for key in ("repo", "version", "sha256"): + if key in entry: + findings.append(warn("CK004", f"{label} has {key!r}, which " + "belongs to a github pin")) + + +def check_load_order(cart, ids, findings): + order = cart.get("load_order") + if order is None: + return + if not isinstance(order, list) or any(not isinstance(x, str) + for x in order): + findings.append(err("CK005", '"load_order" must be an array of the ' + "mod ids")) + return + if not ids: + return + missing = [x for x in ids if x not in order] + extra = [x for x in order if x not in ids] + if len(order) != len(set(order)): + findings.append(err("CK005", '"load_order" repeats an id; it is a ' + "permutation of the mods, not a list of " + "passes")) + if missing: + findings.append(err("CK005", '"load_order" leaves out ' + + ", ".join(missing) + + "; name every pinned mod or drop the field")) + if extra: + findings.append(err("CK005", '"load_order" names ' + ", ".join(extra) + + ", which is not pinned in mods")) + + +def schema_findings(cart, cart_dir=None): + findings = [] + check_identity(cart, findings) + check_label(cart, cart_dir, findings) + ids = check_mods(cart, findings) + check_load_order(cart, ids, findings) + return findings + + +# ------------------------------------------------------------------- http + +class Unreachable(Exception): + pass + + +class NotFound(Exception): + pass + + +_LAST_REQUEST = [0.0] +MIN_INTERVAL = 0.4 +MAX_BACKOFF = 30.0 + + +def github_token(): + for name in ("CARTKIT_GITHUB_TOKEN", "GITHUB_TOKEN", "GH_TOKEN"): + value = os.environ.get(name, "").strip() + if value: + return value + return None + + +def _pause(): + wait = MIN_INTERVAL - (time.monotonic() - _LAST_REQUEST[0]) + if wait > 0: + time.sleep(wait) + + +def _retry_after(response): + raw = response.headers.get("Retry-After") if response.headers else None + try: + return min(float(raw), MAX_BACKOFF) if raw else None + except ValueError: + return None + + +def open_url(url, accept=None, token=None, timeout=30, attempts=3): + headers = {"User-Agent": USER_AGENT} + if accept: + headers["Accept"] = accept + if token: + headers["Authorization"] = "Bearer " + token + request = urllib.request.Request(url, headers=headers) + for attempt in range(attempts): + _pause() + try: + response = urllib.request.urlopen(request, timeout=timeout) + _LAST_REQUEST[0] = time.monotonic() + return response + except urllib.error.HTTPError as problem: + _LAST_REQUEST[0] = time.monotonic() + if problem.code in (404, 410): + raise NotFound(url) + if problem.code == 403 and \ + problem.headers.get("X-RateLimit-Remaining") == "0": + raise Unreachable( + "GitHub rate limit reached; set GITHUB_TOKEN to a token " + "with public read and try again") + retriable = problem.code in (403, 429) or 500 <= problem.code < 600 + if retriable and attempt + 1 < attempts: + time.sleep(_retry_after(problem) or min(2 ** attempt * 2, + MAX_BACKOFF)) + continue + raise Unreachable(f"{url}: HTTP {problem.code} {problem.reason}") + except Exception as problem: + _LAST_REQUEST[0] = time.monotonic() + if attempt + 1 < attempts: + time.sleep(min(2 ** attempt, MAX_BACKOFF)) + continue + raise Unreachable(f"{url}: {problem}") + raise Unreachable(url) + + +def get_json(url, accept=None, token=None): + with open_url(url, accept, token) as response: + body = response.read() + try: + return json.loads(body.decode("utf-8")) + except ValueError as problem: + raise Unreachable(f"{url}: response was not JSON ({problem})") + + +def get_text(url, token=None): + with open_url(url, None, token) as response: + return response.read().decode("utf-8", "replace") + + +def stream_digest(url, algorithm="sha256"): + digest = hashlib.new(algorithm) + total = 0 + with open_url(url, timeout=120) as response: + while True: + chunk = response.read(262144) + if not chunk: + break + total += len(chunk) + digest.update(chunk) + return digest.hexdigest(), total + + +# --------------------------------------------------------------- resolvers + +def github_release(slug, version, token): + tags = [f"v{version}", version] + for tag in tags: + url = (f"https://api.github.com/repos/{slug}/releases/tags/" + + urllib.parse.quote(tag)) + try: + return get_json(url, "application/vnd.github+json", token), tag + except NotFound: + continue + raise NotFound(f"{slug} has no release tagged " + + " or ".join(tags)) + + +def pick_asset(release, mod_id, version): + assets = [a for a in release.get("assets", []) + if isinstance(a, dict) and a.get("name")] + wanted = f"{mod_id}-{version}.zip" + for asset in assets: + if asset["name"] == wanted: + return asset + zips = [a for a in assets if a["name"].lower().endswith(".zip")] + if len(zips) == 1: + return zips[0] + if not zips: + raise NotFound(f"release {release.get('tag_name')} has no .zip asset; " + "publish the mod archive on the release") + names = ", ".join(sorted(a["name"] for a in zips)) + raise NotFound(f"release {release.get('tag_name')} has {len(zips)} .zip " + f"assets ({names}); the game picks {wanted}, so name the " + "mod archive that way") + + +def sums_digest(release, asset_name, token): + for asset in release.get("assets", []): + if asset.get("name") == "sha256sums.txt": + body = get_text(asset["browser_download_url"], None) + for line in body.splitlines(): + parts = line.split() + if len(parts) == 2 and os.path.basename( + parts[1].lstrip("*")) == asset_name: + return parts[0].lower() + return None + + +def resolve_github(slug, version, mod_id, token, download=True): + release, tag = github_release(slug, version, token) + asset = pick_asset(release, mod_id, version) + digest = sums_digest(release, asset["name"], token) + how = "sha256sums.txt" + if digest is None: + if not download: + return {"tag": tag, "asset": asset["name"], + "size": asset.get("size", 0), "sha256": None, + "how": "not published"} + digest, size = stream_digest(asset["browser_download_url"]) + how = f"downloading {size} bytes" + return {"tag": tag, "asset": asset["name"], "size": asset.get("size", 0), + "sha256": digest, "how": how} + + +def gamebanana_files(mod_id): + url = f"https://gamebanana.com/apiv11/Mod/{int(mod_id)}/DownloadPage" + payload = get_json(url) + if not isinstance(payload, dict): + raise Unreachable(f"{url}: unexpected response") + if payload.get("_bIsTrashed") or payload.get("_bIsWithheld"): + raise NotFound(f"GameBanana mod {mod_id} is trashed or withheld") + files = payload.get("_aFiles") + if not isinstance(files, list) or not files: + raise NotFound(f"GameBanana mod {mod_id} publishes no files") + return files + + +def gamebanana_file(files, file_id): + for entry in files: + if isinstance(entry, dict) and entry.get("_idRow") == int(file_id): + return entry + return None + + +# ------------------------------------------------------------ online rules + +def online_findings(cart, findings, notes, download=True): + token = github_token() + if not token: + notes.append("no GITHUB_TOKEN in the environment; GitHub allows 60 " + "anonymous API calls an hour") + for index, entry in enumerate(cart.get("mods") or []): + if not isinstance(entry, dict): + continue + mod_id = entry.get("id") + label = f"mods[{index + 1}] {mod_id}" + if entry.get("source") == "github": + resolve_github_pin(entry, label, findings, notes, token, download) + elif entry.get("source") == "gamebanana": + resolve_gamebanana_pin(entry, label, findings, notes) + + +def resolve_github_pin(entry, label, findings, notes, token, download): + slug, version = entry.get("repo"), entry.get("version") + if not (full_match(REPO_RE, slug) and full_match(SEMVER_RE, version)): + return + try: + found = resolve_github(slug, version, entry.get("id") or "", + token, download) + except NotFound as problem: + findings.append(err("CK100", f"{label} does not resolve: {problem}")) + return + except Unreachable as problem: + notes.append(f"{label} not resolved: {problem}") + return + if found["sha256"] is None: + notes.append(f"{label} hash not checked: the release publishes no " + "sha256sums.txt and --no-download was given") + return + if found["sha256"] != entry.get("sha256"): + findings.append(err("CK101", f"{label} pins sha256 " + f"{entry.get('sha256')} but " + f"{found['asset']} on {slug} " + f"{found['tag']} hashes to " + f"{found['sha256']}; re-pin it")) + + +def resolve_gamebanana_pin(entry, label, findings, notes): + mod, file_id = entry.get("mod"), entry.get("file") + if isinstance(mod, bool) or not isinstance(mod, int) or mod <= 0: + return + if isinstance(file_id, bool) or not isinstance(file_id, int) \ + or file_id <= 0: + return + try: + files = gamebanana_files(mod) + except NotFound as problem: + findings.append(err("CK110", f"{label} does not resolve: {problem}")) + return + except Unreachable as problem: + notes.append(f"{label} not resolved: {problem}") + return + found = gamebanana_file(files, file_id) + if found is None: + have = ", ".join(str(f.get("_idRow")) for f in files + if isinstance(f, dict)) + findings.append(err("CK110", f"{label} pins file {file_id}, which is " + f"not on GameBanana mod {mod} " + f"(it publishes {have})")) + return + published = str(found.get("_sMd5Checksum") or "").lower() + if published != entry.get("md5"): + findings.append(err("CK111", f"{label} pins md5 {entry.get('md5')} " + f"but file {file_id} " + f"({found.get('_sFile')}) publishes " + f"{published or 'no checksum'}; " + "re-pin it")) + + +# -------------------------------------------------------------- lua bundle + +def lua_string(text): + out = ['"'] + for position, ch in enumerate(text): + code = ord(ch) + if ch in '"\\\n': + out.append("\\" + ch) + elif code < 32 or code == 127: + following = text[position + 1:position + 2] + out.append("\\%03d" % code if following.isdigit() + else "\\%d" % code) + else: + out.append(ch) + out.append('"') + return "".join(out) + + +def lua_number(value): + if isinstance(value, int): + return str(value) + if value != value or value in (float("inf"), float("-inf")): + raise ValueError(f"cannot serialize {value!r}") + text = "%.14g" % value + return text + + +def lua_value(value, indent=0): + pad = " " * indent + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, (int, float)): + return lua_number(value) + if isinstance(value, str): + return lua_string(value) + if isinstance(value, (list, tuple)): + pairs = [(position + 1, item) for position, item in enumerate(value)] + elif isinstance(value, dict): + pairs = sorted(value.items(), key=lambda kv: kv[0].encode("utf-8")) + else: + raise ValueError(f"cannot serialize {type(value).__name__}") + if not pairs: + return "{}" + lines = [] + for key, item in pairs: + if isinstance(key, str) and LUA_NAME_RE.fullmatch(key): + rendered = key + elif isinstance(key, str): + rendered = "[" + lua_string(key) + "]" + else: + rendered = "[" + lua_number(key) + "]" + lines.append(pad + " " + rendered + " = " + + lua_value(item, indent + 1)) + return "{\n" + ",\n".join(lines) + ",\n" + pad + "}" + + +def lua_encode(data): + return "return " + lua_value(data, 0) + "\n" + + +def packed_cart(cart): + out = {} + for key in CART_KEYS: + if key in cart and cart[key] is not None: + out[key] = cart[key] + out.pop("schema", None) + out["shell"] = str(cart.get("shell", "")).lower() + out["seal"] = cart.get("seal", "sealed") + mods = [] + for entry in cart.get("mods") or []: + pin = {key: entry[key] for key in MOD_KEYS + if key in entry and entry[key] is not None} + if not pin.get("options"): + pin.pop("options", None) + mods.append(pin) + out["mods"] = mods + out["load_order"] = list(cart.get("load_order") + or [entry.get("id") for entry in mods]) + return out + + +def bundle_table(cart, label_bytes=None, label_name=None): + root = {"format": BUNDLE_FORMAT, "formatVersion": BUNDLE_VERSION, + "cart": packed_cart(cart)} + if label_bytes: + root["labelArt"] = { + "name": label_name, + "encoding": "base64", + "bytes": len(label_bytes), + "data": base64.b64encode(label_bytes).decode("ascii"), + } + return root + + +def bundle_bytes(cart, cart_dir=None): + label_bytes, label_name = None, None + label = cart.get("label") + if label and cart_dir: + path = os.path.join(cart_dir, label) + if os.path.isfile(path): + label_bytes = open(path, "rb").read() + label_name = os.path.basename(label) + return lua_encode(bundle_table(cart, label_bytes, + label_name)).encode("utf-8") + + +# ------------------------------------------------------------------ label + +def png_bytes(width, height, rows): + raw = bytearray() + for row in rows: + raw.append(0) + for pixel in row: + raw += bytes(pixel) + + def chunk(tag, body): + return (struct.pack(">I", len(body)) + tag + body + + struct.pack(">I", zlib.crc32(tag + body) & 0xffffffff)) + + header = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0) + return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", header) + + chunk(b"IDAT", zlib.compress(bytes(raw), 9)) + + chunk(b"IEND", b"")) + + +def shell_rgb(shell): + value = shell.lstrip("#") + return tuple(int(value[i:i + 2], 16) for i in (0, 2, 4)) + + +def mix(colour, target, amount): + return tuple(int(round(c + (t - c) * amount)) + for c, t in zip(colour, target)) + + +def label_art(shell): + size = 96 + face = shell_rgb(shell) + edge = mix(face, (0, 0, 0), 0.55) + sticker = mix(face, (255, 255, 255), 0.78) + ink = mix(face, (0, 0, 0), 0.35) + rows = [] + for y in range(size): + row = [] + for x in range(size): + inset = min(x, y, size - 1 - x, size - 1 - y) + if inset < 5: + row.append(edge) + elif 12 <= x < size - 12 and 16 <= y < size - 28: + row.append(sticker if (x + y) % 16 else ink) + else: + row.append(face) + rows.append(row) + return png_bytes(size, size, rows) + + +# --------------------------------------------------------------- templates + +README_TEMPLATE = """# {{title}} + +A custom cart for the LOVE2D Pokemon engine: a pinned mod setup that plays as +its own game on top of `{{base}}`. It ships no code -- every mod named here is +published separately, and the cart pins each one to an exact build. + +## Files + +- `cart.json` - identity, base game, seal, and one pin per mod +- `{{label}}` - the cart label art the launcher draws +- `.github/workflows/release.yml` - packs and publishes on a `v*` tag + +## Loop + +Every command below is `tools/cartkit.py` from a gen1recomp checkout, pointed +at this directory. + +1. pin a mod: + + ```sh + python3 tools/cartkit.py pin . owner/repo@1.2.3 + python3 tools/cartkit.py pin . https://gamebanana.com/mods/546899 + ``` + +2. freeze an option the player inherits: + + ```sh + python3 tools/cartkit.py pin . owner/repo@1.2.3 --option difficulty=hard + ``` + +3. check it, hashes and all: + + ```sh + python3 tools/cartkit.py validate . --online + ``` + +4. build the bundle players load: + + ```sh + python3 tools/cartkit.py pack . + ``` + +`seal` is `{{seal}}`. A sealed cart loads exactly the mods listed below and +nothing else; an open cart lets the player add more on top. + +## Releasing + +Bump `version` in `cart.json`, tag it `v`, and push the tag. The +workflow validates, packs, and attaches `{{id}}-.g1rcart` to the +release. +""" + +CHANGELOG_TEMPLATE = """# Changelog + +All notable changes to this cart are recorded here, newest first. + +## [0.1.0] - unreleased + +### Added + +- First cut of {{title}}. +""" + + +def cmd_scaffold(args, repo): + if not ID_RE.fullmatch(args.id): + print(f"cartkit: bad id {args.id!r} (letters, digits, _ or -, " + "1..64 characters)") + return 2 + if args.shell and not SHELL_RE.fullmatch(args.shell): + print(f"cartkit: bad shell {args.shell!r} (expected #rrggbb)") + return 2 + github = "" + if args.github: + match = GITHUB_SLUG.fullmatch(args.github.strip()) + if not match: + print(f"cartkit: bad --github {args.github!r} " + "(expected owner/name or a github.com URL)") + return 2 + github = f"{match.group(1)}/{match.group(2)}" + + dest_root = args.into or args.dest or (os.path.join(repo, "carts") + if repo else os.getcwd()) + dest = os.path.join(dest_root, args.id) + if os.path.exists(dest) and not args.force: + print(f"cartkit: {dest} exists (use --force to overwrite)") + return 2 + + title = args.title or args.id.replace("_", " ").replace("-", " ").title() + if text_problem(title, 1, 48): + print(f"cartkit: bad title {title!r} (1..48 characters)") + return 2 + shell = (args.shell or "#8b1a1a").lower() + engine = engine_version(repo) if repo else "0.0.0" + engine_range = ">=%s <%d.0.0" % (engine, int(engine.split(".")[0]) + 1) + + cart = { + "schema": CART_SCHEMA, + "id": args.id, + "title": title, + "version": "0.1.0", + "author": args.author or "TODO your handle", + "summary": args.summary or "", + "shell": shell, + "label": "label.png", + "base": args.base, + "engine": engine_range, + "seal": args.seal, + "mods": [{ + "id": "example-mod", + "source": "github", + "repo": PLACEHOLDER_REPO, + "version": "0.1.0", + "sha256": PLACEHOLDER_SHA, + }], + } + if github: + cart["repo"] = github + if not cart["summary"]: + del cart["summary"] + + os.makedirs(dest, exist_ok=True) + write_cart(dest, cart) + with open(os.path.join(dest, "label.png"), "wb") as fh: + fh.write(label_art(shell)) + subst = {"{{id}}": args.id, "{{title}}": title, "{{base}}": args.base, + "{{seal}}": args.seal, "{{label}}": "label.png"} + for name, template in (("README.md", README_TEMPLATE), + ("CHANGELOG.md", CHANGELOG_TEMPLATE)): + body = template + for key, value in subst.items(): + body = body.replace(key, value) + with open(os.path.join(dest, name), "w", encoding="utf-8") as fh: + fh.write(body) + install_workflow(repo, dest, args.id, force=True) + + if not args.quiet: + print(f"created {dest} ({args.base} base, {args.seal})") + print("next: python3 tools/cartkit.py pin " + f"{dest} owner/repo@1.2.3 (replaces the placeholder pin)") + print(f"then: python3 tools/cartkit.py validate {dest} --online") + return 0 + + +# ---------------------------------------------------------------- workflow + +def install_workflow(repo, cart_dir, cart_id, force=False): + template = os.path.join(repo or "", "tools", "cart_release_workflow.yml") + if not os.path.isfile(template): + return f"missing template {template}" + dest_dir = os.path.join(cart_dir, ".github", "workflows") + dest = os.path.join(dest_dir, "release.yml") + if os.path.exists(dest) and not force: + return f"{dest} exists (use --force to overwrite)" + body = open(template, encoding="utf-8").read().replace("{{CART_ID}}", + cart_id) + os.makedirs(dest_dir, exist_ok=True) + with open(dest, "w", encoding="utf-8") as fh: + fh.write(body) + return None + + +def cmd_add_release_workflow(args, repo): + cart_dir = resolve_cart_dir(repo, args.cart) + if not cart_dir: + print(f"cartkit: no cart at {args.cart!r}") + return 2 + cart, problem = read_cart(cart_dir) + if problem: + print(problem.line()) + return 1 + cart_id = cart.get("id") or os.path.basename(cart_dir) + trouble = install_workflow(repo, cart_dir, cart_id, args.force) + if trouble: + print(f"cartkit: {trouble}") + return 2 + if not args.quiet: + dest = os.path.join(cart_dir, ".github", "workflows", "release.yml") + print(f"wrote {dest}") + print("push the cart as its own GitHub repo and tag v to " + f"publish {cart_id}-{CART_EXT}") + return 0 + + +# ---------------------------------------------------------------- validate + +def cmd_validate(args, repo): + cart_dir = resolve_cart_dir(repo, args.cart) + if not cart_dir: + print(f"cartkit: no cart at {args.cart!r} (expected a directory " + f"holding {CART_FILE})") + return 2 + cart, problem = read_cart(cart_dir) + findings, notes = [], [] + if problem: + findings.append(problem) + name = os.path.basename(cart_dir) + else: + findings.extend(schema_findings(cart, cart_dir)) + name = cart.get("id") or os.path.basename(cart_dir) + if args.online: + online_findings(cart, findings, notes, not args.no_download) + else: + notes.append("pins not resolved; rerun with --online to check " + "every release, file id and hash") + return report(findings, args, f"ok {name} valid", f"FAIL {name} invalid", + notes) + + +# -------------------------------------------------------------------- pack + +def cmd_pack(args, repo): + cart_dir = resolve_cart_dir(repo, args.cart) + if not cart_dir: + print(f"cartkit: no cart at {args.cart!r}") + return 2 + cart, problem = read_cart(cart_dir) + if problem: + print(problem.line()) + return 1 + findings = schema_findings(cart, cart_dir) + notes = [] + if args.online: + online_findings(cart, findings, notes, not args.no_download) + for finding in findings: + print(finding.line()) + if not args.quiet: + for note in notes: + print(f"cartkit: {note}") + if findings: + print("cartkit: pack refused (pack runs validate --strict, so the " + "warnings above are fatal too)") + return 1 + + out = args.output or f"{cart['id']}-{cart['version']}{CART_EXT}" + body = bundle_bytes(cart, cart_dir) + with open(out, "wb") as fh: + fh.write(body) + if not args.quiet: + mods = len(cart.get("mods") or []) + label = " + label art" if cart.get("label") else "" + plural = "" if mods == 1 else "s" + print(f"wrote {out} ({len(body)} bytes, {mods} pinned " + f"mod{plural}{label})") + return 0 + + +# --------------------------------------------------------------------- pin + +def parse_spec(spec): + match = GITHUB_SPEC.fullmatch(spec.strip()) + if match: + return ("github", f"{match.group(1)}/{match.group(2)}", + match.group(3).lstrip("vV")) + match = GAMEBANANA_SPEC.fullmatch(spec.strip()) + if match: + return ("gamebanana", int(match.group(1)), None) + return (None, None, None) + + +def parse_option(text): + key, sep, raw = text.partition("=") + if not sep or not key: + raise ValueError(f"--option wants key=value (got {text!r})") + lowered = raw.strip().lower() + if lowered in ("true", "false"): + return key, lowered == "true" + try: + return key, int(raw) + except ValueError: + pass + try: + return key, float(raw) + except ValueError: + pass + return key, raw + + +def is_placeholder(entry): + return (isinstance(entry, dict) + and str(entry.get("repo", "")).lower() == PLACEHOLDER_REPO + and entry.get("sha256") == PLACEHOLDER_SHA) + + +def derive_id(text): + cleaned = re.sub(r"[^A-Za-z0-9_-]+", "-", text.strip()).strip("-") + return (cleaned or "mod")[:64].lower() + + +def cmd_pin(args, repo): + cart_dir = resolve_cart_dir(repo, args.cart) + if not cart_dir: + print(f"cartkit: no cart at {args.cart!r}") + return 2 + cart, problem = read_cart(cart_dir) + if problem: + print(problem.line()) + return 1 + source, target, version = parse_spec(args.spec) + if not source: + print(f"cartkit: cannot read {args.spec!r}; write owner/repo@1.2.3, a " + "gamebanana mod url, or a gamebanana mod id") + return 2 + if source == "github" and not SEMVER_RE.fullmatch(version): + print(f"cartkit: {version!r} is not semver; pin an exact release like " + "owner/repo@1.2.3") + return 2 + options = {} + try: + for text in args.option or []: + key, value = parse_option(text) + options[key] = value + except ValueError as problem: + print(f"cartkit: {problem}") + return 2 + + try: + if source == "github": + entry, note = pin_github(target, version, args, options) + else: + entry, note = pin_gamebanana(target, args, options) + except NotFound as problem: + print(f"cartkit: {problem}") + return 1 + except Unreachable as problem: + print(f"cartkit: {problem}") + return 1 + if entry is None: + return 2 + + mods = cart.setdefault("mods", []) + replaced = False + for index, existing in enumerate(mods): + if isinstance(existing, dict) and existing.get("id") == entry["id"]: + keep = existing.get("options") or {} + if args.clear_options: + keep = {} + keep.update(entry.get("options") or {}) + if keep: + entry["options"] = keep + mods[index] = entry + replaced = True + break + dropped = [] + if not replaced: + for existing in list(mods): + if is_placeholder(existing): + mods.remove(existing) + dropped.append(existing.get("id")) + mods.append(entry) + order = cart.get("load_order") + if isinstance(order, list): + cart["load_order"] = [x for x in order if x not in dropped] + cart["load_order"].append(entry["id"]) + write_cart(cart_dir, cart) + + if not args.quiet: + verb = "updated" if replaced else "added" + print(f"{verb} pin {entry['id']} ({note})") + for name in dropped: + print(f"dropped the scaffold placeholder pin {name}") + print(f"wrote {os.path.join(cart_dir, CART_FILE)}") + return 0 + + +def pin_github(slug, version, args, options): + mod_id = args.id or derive_id(slug.split("/")[1]) + found = resolve_github(slug, version, mod_id, github_token(), True) + if found["sha256"] is None: + raise NotFound(f"{slug} {found['tag']} publishes no hash for " + f"{found['asset']}") + entry = {"id": mod_id, "source": "github", "repo": slug, + "version": version, "sha256": found["sha256"]} + if options: + entry["options"] = options + return entry, (f"{slug} {found['tag']} -> {found['asset']}, sha256 from " + f"{found['how']}") + + +def pin_gamebanana(mod_id, args, options): + files = gamebanana_files(mod_id) + chosen = None + if args.file: + chosen = gamebanana_file(files, args.file) + if chosen is None: + have = ", ".join(f"{f.get('_idRow')} ({f.get('_sFile')})" + for f in files if isinstance(f, dict)) + raise NotFound(f"GameBanana mod {mod_id} has no file " + f"{args.file}; it publishes {have}") + elif len(files) == 1: + chosen = files[0] + else: + have = "\n".join(f" --file {f.get('_idRow')} {f.get('_sFile')}" + for f in files if isinstance(f, dict)) + print(f"cartkit: GameBanana mod {mod_id} publishes {len(files)} " + f"files; pick one:\n{have}") + return None, None + md5 = str(chosen.get("_sMd5Checksum") or "").lower() + if not MD5_RE.fullmatch(md5): + raise NotFound(f"GameBanana file {chosen.get('_idRow')} publishes no " + "md5; a cart cannot pin it") + name = args.id or derive_id(os.path.splitext( + str(chosen.get("_sFile") or ""))[0]) or f"gb{mod_id}" + entry = {"id": name, "source": "gamebanana", "mod": int(mod_id), + "file": int(chosen["_idRow"]), "md5": md5} + if options: + entry["options"] = options + return entry, (f"gamebanana {mod_id} -> file {chosen['_idRow']} " + f"({chosen.get('_sFile')}), md5 from the v11 API") + + +# ---------------------------------------------------------------- selftest + +def good_cart(): + return { + "schema": 1, + "id": "kanto-hard", + "title": "Kanto Hard Mode", + "version": "1.0.0", + "author": "someone", + "repo": "someone/kanto-hard", + "summary": "Every trainer bites back.", + "shell": "#8b1a1a", + "label": "label.png", + "base": "red", + "engine": ">=1.0.0 <2.0.0", + "seal": "sealed", + "mods": [ + {"id": "harder-trainers", "source": "github", + "repo": "someone/harder-trainers", "version": "2.1.0", + "sha256": "a" * 64, + "options": {"difficulty": "brutal", "levelCap": 100, + "nuzlocke": True}}, + {"id": "new-music", "source": "gamebanana", "mod": 546899, + "file": 1294214, "md5": "b" * 32}, + ], + "load_order": ["new-music", "harder-trainers"], + } + + +def rules(findings): + return sorted({f.rule for f in findings if f.severity == "error"}) + + +def t_good(): + found = schema_findings(good_cart()) + assert not found, rules(found) + + +def t_identity(): + for key, value in (("id", "no spaces here"), ("id", "x" * 65), + ("title", ""), ("title", "t" * 49), + ("version", "1.0"), ("version", "v1.0.0"), + ("author", ""), ("shell", "8b1a1a"), + ("shell", "#8b1a1"), ("base", "nonesuch"), + ("seal", "welded"), ("schema", 2), + ("summary", "s" * 121), ("repo", "someone"), + ("engine", ">=1.0.0 <<2.0.0")): + cart = good_cart() + cart[key] = value + assert "CK002" in rules(schema_findings(cart)), (key, value) + + +def t_optional_fields(): + for key in ("repo", "summary", "engine", "label", "load_order", "seal"): + cart = good_cart() + del cart[key] + assert not schema_findings(cart), key + + +def t_unknown_field(): + cart = good_cart() + cart["colour"] = "red" + assert any(f.rule == "CK001" and f.severity == "warn" + for f in schema_findings(cart)) + + +def t_label(): + for value in ("/etc/passwd", "../out.png", "a/../../b.png", "x" * 129, + "C:\\art.png"): + cart = good_cart() + cart["label"] = value + assert "CK003" in rules(schema_findings(cart)), value + + +def t_mods(): + cart = good_cart() + cart["mods"] = [] + assert "CK004" in rules(schema_findings(cart)) + cart = good_cart() + cart["mods"] = [dict(cart["mods"][0]) for _ in range(65)] + assert "CK004" in rules(schema_findings(cart)) + cart = good_cart() + cart["mods"][1] = dict(cart["mods"][0]) + cart["load_order"] = ["harder-trainers", "harder-trainers"] + assert "CK004" in rules(schema_findings(cart)) + + +def t_pins(): + cases = [ + {"source": "torrent"}, + {"repo": "nope"}, + {"version": "1.0"}, + {"sha256": "A" * 64}, + {"sha256": "a" * 63}, + ] + for patch in cases: + cart = good_cart() + cart["mods"][0].update(patch) + assert "CK004" in rules(schema_findings(cart)), patch + for patch in [{"mod": 0}, {"mod": "546899"}, {"file": -1}, + {"md5": "B" * 32}, {"md5": "b" * 31}]: + cart = good_cart() + cart["mods"][1].update(patch) + assert "CK004" in rules(schema_findings(cart)), patch + + +def t_placeholder_warns(): + cart = good_cart() + cart["mods"][0]["repo"] = PLACEHOLDER_REPO + cart["mods"][0]["sha256"] = PLACEHOLDER_SHA + found = schema_findings(cart) + assert not rules(found) + assert sum(1 for f in found if f.severity == "warn") == 2 + + +def t_options(): + for value in ({"a": {"nested": 1}}, {"a": None}, {"a": ["list"]}, + {"k" * 65: 1}, {"a": "x" * 257}, + dict((str(n), n) for n in range(65))): + cart = good_cart() + cart["mods"][0]["options"] = value + assert "CK004" in rules(schema_findings(cart)), value + + +def t_load_order(): + for value in (["harder-trainers"], ["harder-trainers", "ghost"], + ["harder-trainers", "harder-trainers"], "harder-trainers", + [1, 2]): + cart = good_cart() + cart["load_order"] = value + assert "CK005" in rules(schema_findings(cart)), value + + +def t_lua_strings(): + assert lua_string('a"b') == '"a\\"b"' + assert lua_string("a\\b") == '"a\\\\b"' + assert lua_string("a\nb") == '"a\\\nb"' + assert lua_string("a\tb") == '"a\\9b"' + assert lua_string("a\t1") == '"a\\0091"' + assert lua_string("a\rb") == '"a\\13b"' + assert lua_string("Pokémon") == '"Pokémon"' + + +def t_lua_numbers(): + assert lua_value(3) == "3" + assert lua_value(-2) == "-2" + assert lua_value(0.5) == "0.5" + assert lua_value(True) == "true" + assert lua_value([]) == "{}" + assert lua_value({}) == "{}" + + +def t_bundle_shape(): + body = lua_encode(bundle_table(good_cart())) + assert body.startswith("return {\n") + assert body.endswith("}\n") + assert ' format = "g1rcart",' in body + assert " formatVersion = 1,\n" in body + assert body.index("cart =") < body.index("format =") < \ + body.index("formatVersion =") + assert '[1] = {' in body + + +def t_bundle_defaults(): + cart = good_cart() + del cart["seal"] + del cart["load_order"] + packed = packed_cart(cart) + assert packed["seal"] == "sealed" + assert packed["load_order"] == ["harder-trainers", "new-music"] + + +def t_bundle_deterministic(): + cart = good_cart() + shuffled = {key: cart[key] for key in reversed(list(cart))} + assert lua_encode(bundle_table(cart)) == lua_encode(bundle_table(shuffled)) + + +def t_bundle_label(): + art = label_art("#8b1a1a") + table = bundle_table(good_cart(), art, "label.png") + assert table["labelArt"]["bytes"] == len(art) + assert base64.b64decode(table["labelArt"]["data"]) == art + + +def t_specs(): + assert parse_spec("owner/repo@1.2.3") == ("github", "owner/repo", "1.2.3") + assert parse_spec("https://github.com/owner/repo@1.2.3") == \ + ("github", "owner/repo", "1.2.3") + assert parse_spec("owner/repo.git@v1.2.3") == \ + ("github", "owner/repo", "1.2.3") + assert parse_spec("https://gamebanana.com/mods/546899") == \ + ("gamebanana", 546899, None) + assert parse_spec("gamebanana:546899") == ("gamebanana", 546899, None) + assert parse_spec("546899") == ("gamebanana", 546899, None) + assert parse_spec("not a spec")[0] is None + + +def t_options_parse(): + assert parse_option("a=true") == ("a", True) + assert parse_option("a=3") == ("a", 3) + assert parse_option("a=1.5") == ("a", 1.5) + assert parse_option("a=hard") == ("a", "hard") + try: + parse_option("nope") + except ValueError: + return + raise AssertionError("--option without = must be rejected") + + +def t_ranges(): + for text in (">=1.0.0 <2.0.0", "^1.2", "1.2.3", ">1 || <0.9", "<=2"): + assert range_problem(text) is None, text + for text in ("", ">=x", ">>1.0.0", "1.0.0 || "): + assert range_problem(text) is not None, text + + +def t_png(): + art = label_art("#123456") + assert art.startswith(b"\x89PNG\r\n\x1a\n") + assert art[12:16] == b"IHDR" and art.endswith(b"IEND\xae\x42\x60\x82") + assert label_art("#123456") == art + + +def t_roundtrip(): + root = tempfile.mkdtemp(prefix="cartkit-selftest-") + repo = find_repo(os.path.dirname(os.path.abspath(__file__))) + try: + with contextlib.redirect_stdout(io.StringIO()): + _roundtrip(root, repo) + finally: + shutil.rmtree(root, ignore_errors=True) + + +def _roundtrip(root, repo): + assert main(["scaffold", "demo_cart", "--into", root, + "--quiet"]) == 0, "scaffold failed" + cart_dir = os.path.join(root, "demo_cart") + assert os.path.isfile(os.path.join(cart_dir, CART_FILE)) + assert os.path.isfile(os.path.join(cart_dir, "label.png")) + assert os.path.isfile(os.path.join(cart_dir, "README.md")) + assert os.path.isfile(os.path.join(cart_dir, "CHANGELOG.md")) + assert os.path.isfile(os.path.join(cart_dir, ".github", "workflows", + "release.yml")) or not repo + assert main(["validate", cart_dir, "--quiet"]) == 0, "validate failed" + assert main(["validate", cart_dir, "--quiet", "--strict"]) == 1, \ + "the placeholder pin must fail --strict" + cart, problem = read_cart(cart_dir) + assert problem is None + cart["mods"][0] = {"id": "harder-trainers", "source": "github", + "repo": "someone/harder-trainers", + "version": "2.1.0", "sha256": "c" * 64} + write_cart(cart_dir, cart) + assert main(["pack", cart_dir, "-o", + os.path.join(root, "out" + CART_EXT), "--quiet"]) == 0 + first = open(os.path.join(root, "out" + CART_EXT), "rb").read() + assert main(["pack", cart_dir, "-o", + os.path.join(root, "again" + CART_EXT), "--quiet"]) == 0 + again = open(os.path.join(root, "again" + CART_EXT), "rb").read() + assert first == again, "pack is not byte-identical twice" + assert first.startswith(b"return {\n") + assert b'id = "demo_cart"' in first + assert b'labelArt' in first + + +CHECKS = [ + ("schema accepts a full cart", t_good), + ("identity fields are checked", t_identity), + ("optional fields stay optional", t_optional_fields), + ("unknown top-level fields warn", t_unknown_field), + ("label paths cannot escape", t_label), + ("mods list bounds", t_mods), + ("pin fields per source", t_pins), + ("scaffold placeholders warn", t_placeholder_warns), + ("frozen options are scalars", t_options), + ("load_order is a permutation", t_load_order), + ("lua string escapes", t_lua_strings), + ("lua scalars and empty tables", t_lua_numbers), + ("bundle shape and key order", t_bundle_shape), + ("bundle materializes defaults", t_bundle_defaults), + ("bundle is order-independent", t_bundle_deterministic), + ("bundle carries the label art", t_bundle_label), + ("pin specs", t_specs), + ("option parsing", t_options_parse), + ("engine ranges", t_ranges), + ("label png is deterministic", t_png), + ("scaffold validate pack round trip", t_roundtrip), +] + + +def cmd_selftest(args, repo): + failures = [] + for name, check in CHECKS: + try: + check() + except Exception as problem: + failures.append((name, problem)) + if not args.quiet: + print(f"FAIL {name}: {problem!r}") + else: + if not args.quiet: + print(f"ok {name}") + if failures: + print(f"FAIL {len(failures)} of {len(CHECKS)} checks") + return 1 + if not args.quiet: + print(f"ok {len(CHECKS)} checks") + return 0 + + +# -------------------------------------------------------------------- main + +def main(argv): + shared = argparse.ArgumentParser(add_help=False) + shared.add_argument("--repo", default=argparse.SUPPRESS, + help="repo root override") + shared.add_argument("--json", action="store_true", + default=argparse.SUPPRESS) + shared.add_argument("--quiet", action="store_true", + default=argparse.SUPPRESS) + + parser = argparse.ArgumentParser(prog="cartkit", parents=[shared], + description="author, check and pack " + "custom carts") + sub = parser.add_subparsers(dest="command") + + p = sub.add_parser("scaffold", parents=[shared], + help="write a new cart repo") + p.add_argument("id") + p.add_argument("--title", help="shown on the cart, 1..48 characters") + p.add_argument("--author") + p.add_argument("--summary", help="one line, <=120 characters") + p.add_argument("--base", default="red", choices=list(BASES)) + p.add_argument("--shell", help="cart shell colour as #rrggbb") + p.add_argument("--seal", default="sealed", choices=list(SEALS), + help="sealed loads only the pinned mods; open lets the " + "player add more") + p.add_argument("--github", help="owner/repo this cart is published from") + p.add_argument("--into", help="parent directory for the new cart") + p.add_argument("--dest", help=argparse.SUPPRESS) + p.add_argument("--force", action="store_true") + + p = sub.add_parser("validate", parents=[shared], + help="check cart.json, offline unless --online") + p.add_argument("cart") + p.add_argument("--online", action="store_true", + help="resolve every pin against GitHub and GameBanana") + p.add_argument("--no-download", action="store_true", + help="never fetch a release asset just to hash it") + p.add_argument("--strict", action="store_true") + + p = sub.add_parser("pin", parents=[shared], + help="add or update one pin, resolving its hash") + p.add_argument("cart") + p.add_argument("spec", help="owner/repo@1.2.3, a gamebanana mod url, or " + "a gamebanana mod id") + p.add_argument("--id", help="mod id in the cart (default: derived)") + p.add_argument("--file", type=int, + help="gamebanana file id when the mod publishes several") + p.add_argument("--option", action="append", metavar="KEY=VALUE", + help="freeze an option value; true/false and numbers are " + "stored as such") + p.add_argument("--clear-options", action="store_true", + help="drop option values already frozen on this pin") + + p = sub.add_parser("pack", parents=[shared], + help="write -" + CART_EXT) + p.add_argument("cart") + p.add_argument("-o", "--output") + p.add_argument("--online", action="store_true") + p.add_argument("--no-download", action="store_true") + + p = sub.add_parser("add-release-workflow", parents=[shared], + help="copy the GitHub Actions release workflow in") + p.add_argument("cart") + p.add_argument("--force", action="store_true") + + sub.add_parser("selftest", parents=[shared], + help="run cartkit's own checks") + + args = parser.parse_args(argv) + for dest, fallback in (("repo", None), ("json", False), ("quiet", False)): + if not hasattr(args, dest): + setattr(args, dest, fallback) + if not args.command: + parser.print_help() + return 2 + + repo = args.repo or find_repo(os.getcwd()) or find_repo( + os.path.dirname(os.path.abspath(__file__))) + if repo: + repo = os.path.abspath(repo) + elif args.command in ("scaffold", "add-release-workflow"): + print("cartkit: cannot find the repo root " + "(looked for tools/rom_manifest.json); pass --repo PATH") + return 2 + + handler = { + "scaffold": cmd_scaffold, + "validate": cmd_validate, + "pin": cmd_pin, + "pack": cmd_pack, + "add-release-workflow": cmd_add_release_workflow, + "selftest": cmd_selftest, + }[args.command] + return handler(args, repo) + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/tools/crystal_movie_symbols.py b/tools/crystal_movie_symbols.py new file mode 100644 index 00000000..8fec9ce5 --- /dev/null +++ b/tools/crystal_movie_symbols.py @@ -0,0 +1,74 @@ +"""Crystal intro-movie and title-screen symbols for the Crystal manifest. + +Gold's title/intro symbols do not exist in Crystal: CrystalIntro is a different +program with its own asset set (engine/movie/intro.asm:1678-1777) and the title +composes on the fly with no tilemap (engine/movie/title.asm:364-374). +""" + +# engine/movie/intro.asm:1678-1777 +INTRO_SYMBOLS = [ + "IntroSuicuneRunGFX", + "IntroPichuWooperGFX", + "IntroBackgroundGFX", + "IntroBackgroundTilemap", + "IntroBackgroundAttrmap", + "IntroBackgroundPalette", + "IntroUnownsGFX", + "IntroPulseGFX", + "IntroUnownATilemap", + "IntroUnownAAttrmap", + "IntroUnownHITilemap", + "IntroUnownHIAttrmap", + "IntroUnownsTilemap", + "IntroUnownsAttrmap", + "IntroUnownsPalette", + "IntroCrystalUnownsGFX", + "IntroCrystalUnownsTilemap", + "IntroCrystalUnownsAttrmap", + "IntroCrystalUnownsPalette", + "IntroSuicuneCloseGFX", + "IntroSuicuneCloseTilemap", + "IntroSuicuneCloseAttrmap", + "IntroSuicuneClosePalette", + "IntroSuicuneJumpGFX", + "IntroSuicuneBackGFX", + "IntroSuicuneJumpTilemap", + "IntroSuicuneJumpAttrmap", + "IntroSuicuneBackTilemap", + "IntroSuicuneBackAttrmap", + "IntroSuicunePalette", + "IntroUnownBackGFX", + "IntroGrass1GFX", + "IntroGrass2GFX", + "IntroGrass3GFX", + "IntroGrass4GFX", +] + +# Palette fades that live as local labels inside their scene routines. +# engine/movie/intro.asm:1189 (fade.pal), :1385-1388 (unown_1.pal / unown_2.pal) +INTRO_FADE_SYMBOLS = [ + "Intro_Scene24_ApplyPaletteFade.FadePals", + "Intro_Scene20_AppearUnown.pal1", + "Intro_Scene20_AppearUnown.pal2", + "Intro_FadeUnownWordPals.FastFadePalettes", + "Intro_FadeUnownWordPals.SlowFadePalettes", +] + +# engine/movie/title.asm:364-374 +TITLE_SYMBOLS = [ + "TitleSuicuneGFX", + "TitleLogoGFX", + "TitleCrystalGFX", + "TitleScreenPalettes", +] + +# engine/movie/splash.asm:344 -- replaces Gold's GameFreakLogoStarsGFX. +# The Ditto OBJ palette is a local label (engine/gfx/cgb_layouts.asm:876-893). +SPLASH_SYMBOLS = [ + "GameFreakDittoGFX", + "GameFreakDittoPaletteFade", + "_CGB_GamefreakLogo.GamefreakDittoPalette", +] + +MOVIE_SYMBOLS = ( + INTRO_SYMBOLS + INTRO_FADE_SYMBOLS + TITLE_SYMBOLS + SPLASH_SYMBOLS) diff --git a/tools/crystal_symbol_deltas.py b/tools/crystal_symbol_deltas.py new file mode 100644 index 00000000..047dc1e3 --- /dev/null +++ b/tools/crystal_symbol_deltas.py @@ -0,0 +1,149 @@ +"""REQUIRED_SYMBOLS delta between pokegold.sym and pokecrystal.sym. + +make_gold_manifest.REQUIRED_SYMBOLS names 341 symbols by hand. 25 of them do +not exist in pokecrystal.sym: Crystal renamed the credits mons, split the +trainer-card / Pokegear / pack-pals blocks by player gender, split the two +FontsExtra tiles apart, and replaced the whole Gold/Silver intro movie and +title screen. DROP_SYMBOLS lists those 25 and ADD_SYMBOLS the verified +replacements; the intro, title and splash names come from +crystal_movie_symbols.MOVIE_SYMBOLS (that file is the pinned CT-9 list and is +not edited here). MOBILE_SYMBOLS is the Mobile System GB art, which no Gold +or Silver ROM carries at all. + +Every name below was checked against ../pokecrystal-symbols/pokecrystal.sym. +""" + +from __future__ import annotations + +from crystal_movie_symbols import MOVIE_SYMBOLS + +# Gold-only names, grouped by what replaces them. +DROP_SYMBOLS = frozenset({ + # engine/movie/title.asm:364-373 -- no tilemap; DrawTitleGraphic composes. + "TitleScreenGFX1", "TitleScreenGFX2", "TitleScreenGFX3", + "TitleScreenGFX4", "TitleScreenTilemap", + # engine/movie/credits.asm:610-613 + "CreditsBellossomGFX", "CreditsTogepiGFX", + "CreditsElekidGFX", "CreditsSentretGFX", + # gfx/misc.asm:44 + "GameFreakLogoStarsGFX", + # engine/gfx/player_gfx.asm:114,117,120,203,206 + "ChrisPicAndTrainerCardGFX", + # engine/gfx/color.asm:1330,1333 + "PokegearPals", + # engine/gfx/cgb_layouts.asm:818,821 + "_CGB_PackPals.PackPals", + # gfx/font.asm:51,63 + "FontsExtra_SolidBlackAndUpArrowGFX", + # engine/movie/intro.asm:1 -- CrystalIntro is a different program. + "Intro_WaterGFX1", "Intro_WaterTilemap", "Intro_WaterMeta", + "Intro_WaterGFX2", + "Intro_GrassGFX1", "Intro_GrassTilemap", "Intro_GrassMeta", + "Intro_GrassGFX2", + "Intro_FireGFX1", "Intro_FireGFX2", "Intro_FireGFX3", +}) + +# Crystal replacements, minus the intro/title/splash ones MOVIE_SYMBOLS owns. +ADD_SYMBOLS = frozenset({ + # engine/movie/credits.asm:610-613 + "CreditsPichuGFX", "CreditsSmoochumGFX", + "CreditsDittoGFX", "CreditsIgglybuffGFX", + # engine/gfx/player_gfx.asm:114,117,120,203,206 + "ChrisCardPic", "KrisCardPic", "TrainerCardGFX", "ChrisPic", "KrisPic", + # engine/gfx/color.asm:1330,1333 + "MalePokegearPals", "FemalePokegearPals", + # engine/gfx/cgb_layouts.asm:818,821 + "_CGB_PackPals.ChrisPackPals", "_CGB_PackPals.KrisPackPals", + # engine/events/fishing_gfx.asm:41 -- Kris' half of the fishing pose. + "KrisFishingGFX", + # gfx/font.asm:51,63 -- black is 1bpp at tile $60, up_arrow 2bpp at $61. + "FontsExtra_SolidBlackGFX", "FontsExtra2_UpArrowGFX", + # main.asm:425-448 -- the pic-animation pointer tables, absent from Gold. + "AnimationPointers", "AnimationIdlePointers", + "BitmasksPointers", "FramesPointers", + "UnownAnimationPointers", "UnownAnimationIdlePointers", + "UnownBitmasksPointers", "UnownFramesPointers", + # engine/tilesets/tileset_anims.asm -- the five Crystal-only anim steps. + "AnimateFountainTile", + "ForestTreeLeftAnimation", "ForestTreeRightAnimation", + "ForestTreeLeftAnimation2", "ForestTreeRightAnimation2", + # engine/overworld/wildmons.asm:493-524 -- Raikou and Entei only. + "InitRoamMons", + # data/events/unown_walls.asm:7,15 -- the four Ruins of Alph wall words + # and the menu box each one is drawn in. + "UnownWalls", "MenuHeaders_UnownWalls", + # data/battle_tower/classes.asm:6 and data/battle_tower/parties.asm:1 -- + # the Battle Tower roster, which no Gold or Silver ROM carries; the + # sprites table is engine/events/battle_tower/battle_tower.asm:1578 + # INCLUDE "data/trainers/sprites.asm". + "BattleTowerTrainers", "BattleTowerMons", "BTTrainerClassSprites", + # data/battle_tower/unknown.asm:1, copied into wBT_OTTrainerData whole. + "BattleTowerTrainerData", + # engine/events/battle_tower/load_trainer.asm:24,112 -- the two rejection + # loops whose `maskbits` / `cp` pair carries the sample ceiling. Crystal + # 1.0 and 1.1 differ only in the trainer one (:29-37), so it is read out + # of the cart rather than written down. + "LoadOpponentTrainerAndPokemon.resample", + "LoadRandomBattleTowerMon.resample", +}) + +# Mobile System GB art, all of it present in the international v1.0 object: +# mobile/*.asm assembles there, only the menus that reach it are Japan-only. +MOBILE_SYMBOLS = frozenset({ + # mobile/mobile_5c.asm:290,293,296,753,756-771,866,874,878 + "AsciiFontGFX", "PichuAnimatedMobileGFX", "ElectroBallMobileGFX", + "PichuBorderMobileGFX", "Stadium2N64GFX", "Stadium2N64Tilemap", + "Stadium2N64Attrmap", "PasswordTopTilemap", "PasswordBottomTilemap", + "PasswordShiftTilemap", "ChooseMobileCenterTilemap", + "MobilePasswordAttrmap", "ChooseMobileCenterAttrmap", + "MobilePasswordPalettes", + # mobile/mobile_5e.asm:2,5,8,11,14,18,925,928,931,934,941 + "MobileCardGFX", "ChrisSilhouetteGFX", "KrisSilhouetteGFX", + "MobileCard2GFX", "CardLargeSpriteAndFolderGFX", "CardSpriteGFX", + "DialpadTilemap", "DialpadAttrmap", "DialpadGFX", "DialpadCursorGFX", + "MobileCardListGFX", + # mobile/mobile_5f.asm:84,87,91,3528,3534,3537 + "HaveWantGFX", "MobileSelectGFX", "HaveWantMap", "PokemonNewsGFX", + "PokemonNewsTileAttrmap", "PokemonNewsPalettes", + # mobile/mobile_5b.asm:206,209,212,215,758 + "MobileSystemSplashScreen_InitGFX.Tiles", + "MobileSystemSplashScreen_InitGFX.Tilemap", + "MobileSystemSplashScreen_InitGFX.Attrmap", + "MobileSplashScreenPalettes", "MobileAdapterCheckGFX", + # mobile/mobile_42.asm:1733-1763 and mobile/mobile_40.asm:6921,6924 + "MobileTradeSpritesGFX", "MobileTradeGFX", "MobileTradeTilemapLZ", + "MobileTradeAttrmapLZ", "MobileCable1GFX", "MobileCable2GFX", + "UnusedMobilePulsePalettes", "MobileTradeBGPalettes", + "MobileTradeOB1Palettes", "MobileTradeOB2Palettes", + "MobileAdapterPalettes", "MobileTradeLightsGFX", + "MobileTradeLightsPalettes", + # mobile/mobile_45_2.asm:1362,1365,1368 and + # mobile/mobile_45_sprite_engine.asm:311 + "PichuBorderMobileOBPalettes", "PichuBorderMobileBGPalettes", + "PichuBorderMobileTilemapAttrmap", "MobileDialingGFX", + # mobile/mobile_12.asm:1007,1010, mobile/mobile_22.asm:518, + # mobile/mobile_41.asm:1115, mobile/fixed_words.asm:3231 + "MobileUpArrowGFX", "MobileDownArrowGFX", "EZChatCursorGFX", + "MobileDialingFrameGFX", "SelectStartGFX", + # engine/menus/main_menu.asm:24 and gfx/font.asm:58 + "MobileMenuGFX", "MobilePhoneTilesGFX", + # engine/link/mystery_gift.asm:1606,1920,1923 + "MysteryGiftGFX", "CardTradeGFX", "CardTradeSpriteGFX", +}) + + +def crystal_required(gold_required): + """Apply the delta to make_gold_manifest.REQUIRED_SYMBOLS.""" + gold = frozenset(gold_required) + stale = sorted(DROP_SYMBOLS - gold) + if stale: + raise SystemExit( + "crystal_symbol_deltas: DROP_SYMBOLS names that Gold no longer " + "requires: " + ", ".join(stale)) + extra = ADD_SYMBOLS | MOBILE_SYMBOLS | set(MOVIE_SYMBOLS) + collisions = sorted(extra & gold) + if collisions: + raise SystemExit( + "crystal_symbol_deltas: ADD names already in the Gold set: " + + ", ".join(collisions)) + return frozenset((gold - DROP_SYMBOLS) | extra) diff --git a/tools/make_crystal_manifest.py b/tools/make_crystal_manifest.py new file mode 100644 index 00000000..033686a6 --- /dev/null +++ b/tools/make_crystal_manifest.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +"""Generate tools/rom_manifest_crystal.json from pret/pokecrystal. + +Crystal is its own pret tree, but the manifest SHAPE is Gold's: same constant +blocks, same charmap parser, same symbol resolution. So this drives +make_gold_manifest.generate() over ../pokecrystal instead of forking it, the +way make_yellow_manifest.py drives make_rom_manifest's helpers over +pokeyellow. Three things are passed in: + + * defines -- the EMPTY set. ../pokecrystal/Makefile:129 builds the retail + international v1.0 object with no -D at all (:130-134 add + _CRYSTAL11 / _CRYSTAL_AU / _DEBUG / _CRYSTAL11_VC for the four + other targets). Leaving Gold's {"_GOLD"} in place would take + an `IF DEF(_GOLD)` arm that Crystal never assembles. + * required -- crystal_symbol_deltas.crystal_required(), the Gold symbol set + with the 25 Gold-only names swapped for their Crystal + replacements plus crystal_movie_symbols.MOVIE_SYMBOLS. + * sha1 -- the Crystal cart hash. + +`anim_labels` is on: Crystal has per-species pic-animation tables +(../pokecrystal/main.asm:425-448) and Gold has none. + +Crystal also declares two extra RGBDS charmaps that Gold has not got at all +(../pokecrystal/constants/charmap.asm:422-442): `unown`, used by the Ruins of +Alph wall words, and `ascii`, used only by the Mobile System GB code. Neither +is more rows of the main charmap -- the same byte means a different thing in +each -- so make_gold_manifest.charmap deliberately STOPS at the first +`newcharmap`, and that guard has to keep protecting the main table. The Unown +one is carried here instead, as its own top-level `unownCharmap` key: that is +where `charmap` and `fontCharmap` already live, so a consumer picks the map it +wants by name and no reader of the main table can see these bytes. The `ascii` +map is not emitted, because nothing outside the Mobile System reads it. + +Usage: python3 tools/make_crystal_manifest.py +Default paths: pokecrystal at ../pokecrystal (relative to the repo) or +/Users/bryanbassett/Documents/development/pokecrystal; symbols at +/Users/bryanbassett/Documents/development/pokecrystal-symbols/pokecrystal.sym. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import make_gold_manifest as gold # noqa: E402 +from crystal_symbol_deltas import crystal_required # noqa: E402 +from rom_data import CANONICAL_CRYSTAL_SHA1 # noqa: E402 + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +DEV = "/Users/bryanbassett/Documents/development" +DEFAULT_POKECRYSTAL_CANDIDATES = [ + os.path.join(os.path.dirname(REPO_ROOT), "pokecrystal"), + os.path.join(DEV, "pokecrystal"), +] +DEFAULT_SYMBOLS = os.path.join(DEV, "pokecrystal-symbols/pokecrystal.sym") +DEFAULT_OUT = os.path.join( + os.path.dirname(__file__), "rom_manifest_crystal.json") + +# ../pokecrystal/Makefile:129 +CRYSTAL_ASM_DEFINES = frozenset() + +CRYSTAL_REQUIRED_SYMBOLS = crystal_required(gold.REQUIRED_SYMBOLS) + + +def _rgbds_int(expr, i): + """Evaluate one `charmap` value expression for loop counter `i`. + + RGBDS `$xx` is hex and `/` is integer division; nothing else in the block + needs modelling. The whitelist is what keeps this from being eval() on + arbitrary asm. + """ + text = re.sub(r"\$([0-9a-fA-F]+)", lambda m: str(int(m.group(1), 16)), expr) + text = text.replace("/", "//") + if not re.fullmatch(r"[0-9i()+\-*/ ]+", text): + raise ValueError(f"unsupported charmap expression: {expr}") + return int(eval(text, {"__builtins__": {}}, {"i": i})) # noqa: S307 + + +def unown_charmap(pokecrystal, defines=None): + """The `unown` charmap: byte -> letter, same shape as gold.charmap. + + ../pokecrystal/constants/charmap.asm:422-431 is a `pushc` block that names + the map, DEFs a string of every printable character, and emits one + `charmap STRSLICE(...)` per character inside a `for`. So the parse has to + follow the loop rather than read literal rows -- but it still reads the + letter set and the tile arithmetic out of the asm, not out of a copy here. + """ + path = os.path.join(pokecrystal, "constants", "charmap.asm") + out, strings, inside, loop = {}, {}, False, None + for _, line in gold.read_asm(path, defines): + s = line.strip() + if re.match(r"newcharmap\s+unown\b", s): + inside = True + continue + if not inside: + continue + if re.match(r"(popc|newcharmap)\b", s): + break + m = re.match(r'DEF\s+(\w+)\s+EQUS\s+"(.*)"$', s) + if m: + strings[m.group(1)] = m.group(2) + continue + m = re.match(r"for\s+(\w+),\s*STRLEN\(#(\w+)\)\s*$", s) + if m: + loop = (m.group(1), strings[m.group(2)]) + continue + if re.match(r"endr\s*$", s): + loop = None + continue + m = re.match( + r"charmap\s+STRSLICE\(#(\w+),\s*\w+,\s*\w+\s*\+\s*1\),\s*(.+)$", s) + if m and loop: + for index, char in enumerate(strings[m.group(1)]): + out[str(_rgbds_int(m.group(2), index))] = char + continue + m = re.match(r'charmap\s+"(.*)",\s*(\$[0-9a-fA-F]+)$', s) + if m: + out[str(int(m.group(2)[1:], 16))] = m.group(1) + if not out: + raise SystemExit("charmap.asm has no `unown` charmap") + return out + + +def generate(pokecrystal, symbols_path): + data = gold.generate( + pokecrystal, symbols_path, + defines=CRYSTAL_ASM_DEFINES, + required=CRYSTAL_REQUIRED_SYMBOLS, + sha1=CANONICAL_CRYSTAL_SHA1, + anim_labels=True) + # Crystal renumbers the block: 162 flags to Gold's 93, so a consumer that + # hardcodes Gold's indices reads the wrong flag. + data["constants"]["engineFlagOrder"] = gold.parse_const_block( + os.path.join(pokecrystal, "constants", "engine_flags.asm"), + defines=CRYSTAL_ASM_DEFINES) + data["unownCharmap"] = unown_charmap(pokecrystal, CRYSTAL_ASM_DEFINES) + return data + + +def find_pokecrystal(): + for candidate in DEFAULT_POKECRYSTAL_CANDIDATES: + if os.path.isfile(os.path.join(candidate, "main.asm")): + return candidate + return DEFAULT_POKECRYSTAL_CANDIDATES[-1] + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--pokecrystal", default=find_pokecrystal()) + parser.add_argument("--symbols", default=DEFAULT_SYMBOLS) + parser.add_argument("--out", default=DEFAULT_OUT) + args = parser.parse_args() + + pokecrystal = os.path.abspath(args.pokecrystal) + if not os.path.isfile(os.path.join(pokecrystal, "main.asm")): + raise SystemExit(f"{pokecrystal} is not a pokecrystal checkout") + data = generate(pokecrystal, os.path.abspath(args.symbols)) + with open(args.out, "w", encoding="utf-8", newline="\n") as f: + json.dump(data, f, ensure_ascii=False, indent=2, sort_keys=True) + f.write("\n") + print(f"wrote {args.out}") + + +if __name__ == "__main__": + main() diff --git a/tools/make_gold_manifest.py b/tools/make_gold_manifest.py index 074e7811..e9564f9e 100644 --- a/tools/make_gold_manifest.py +++ b/tools/make_gold_manifest.py @@ -69,8 +69,14 @@ def strip_comment(line): return "".join(out).rstrip() -def read_asm(path): - """Read an asm file as (lineno, text), comments stripped, IF resolved.""" +def read_asm(path, defines=None): + """Read an asm file as (lineno, text), comments stripped, IF resolved. + + `defines` overrides ASM_DEFINES for another tree; pokecrystal's retail + v1.0 target passes no -D at all (../pokecrystal/Makefile:129). + """ + if defines is None: + defines = ASM_DEFINES lines = [] stack = [] # stack of [taking, condition_known] with open(path, encoding="utf-8") as f: @@ -79,7 +85,7 @@ def read_asm(path): s = line.strip() m = re.match(r"IF\s+(!)?DEF\((\w+)\)\s*$", s, re.IGNORECASE) if m: - defined = m.group(2) in ASM_DEFINES + defined = m.group(2) in defines taking = (not defined) if m.group(1) else defined stack.append([taking, True]) continue @@ -115,7 +121,7 @@ def parse_number(tok): return -val if neg else val -def parse_const_block(path, stop_at=None): +def parse_const_block(path, stop_at=None, defines=None): """Parse a linear const_def/const/const_skip block into an ordered list. Index i of the returned list is the constant's value (None for a gap); @@ -123,7 +129,7 @@ def parse_const_block(path, stop_at=None): """ names = [] value = None - for _, line in read_asm(path): + for _, line in read_asm(path, defines): s = line.strip() if not s: continue @@ -153,7 +159,7 @@ def parse_const_block(path, stop_at=None): return names -def parse_const_block_at(path, first_const, stop_at=None): +def parse_const_block_at(path, first_const, stop_at=None, defines=None): """Parse the one const_def block whose first `const` is `first_const`. parse_const_block walks a file linearly and a second `const_def` in the @@ -166,7 +172,7 @@ def parse_const_block_at(path, first_const, stop_at=None): names = [] value = None started = False - for _, line in read_asm(path): + for _, line in read_asm(path, defines): s = line.strip() if not s: continue @@ -201,7 +207,7 @@ def parse_const_block_at(path, first_const, stop_at=None): return names -def parse_prefixed_consts(path, prefixes, exact=()): +def parse_prefixed_consts(path, prefixes, exact=(), defines=None): """Ordered const names from a file, filtered by prefix (mixed blocks). map_data_constants.asm and friends stack several unrelated const_def @@ -210,7 +216,7 @@ def parse_prefixed_consts(path, prefixes, exact=()): even when they carry no shared prefix (TOWN, BALL, ...). """ out = [] - for _, line in read_asm(path): + for _, line in read_asm(path, defines): m = re.match(r"const\s+(\w+)", line.strip()) if not m: continue @@ -220,7 +226,7 @@ def parse_prefixed_consts(path, prefixes, exact=()): return out -def parse_sparse_consts(path, prefixes): +def parse_sparse_consts(path, prefixes, defines=None): """Ordered const names where index IS the const value (sparse blocks). parse_prefixed_consts packs a block densely, which is wrong for a block @@ -233,7 +239,7 @@ def parse_sparse_consts(path, prefixes): """ out = [] value = None - for _, line in read_asm(path): + for _, line in read_asm(path, defines): s = line.strip() m = re.match(r"const_def(?:\s+(\d+))?\s*$", s) if m: @@ -259,7 +265,7 @@ def parse_sparse_consts(path, prefixes): return out -def extract_trainer_classes(pokegold): +def extract_trainer_classes(pokegold, defines=None): """Ordered trainer class names (constants/trainer_constants.asm). `trainerclass NAME` bumps its own counter and resets the const_def used @@ -271,7 +277,7 @@ def extract_trainer_classes(pokegold): classes = [] members = {} current = None - for _, line in read_asm(path): + for _, line in read_asm(path, defines): s = line.strip() m = re.match(r"trainerclass\s+(\w+)", s) if m: @@ -287,7 +293,7 @@ def extract_trainer_classes(pokegold): return classes, members -def extract_items(pokegold): +def extract_items(pokegold, defines=None): """Ordered item ids, and how many of them ItemNames actually covers. parse_const_block cannot do this file: after NUM_ITEMS the TM and HM items @@ -304,7 +310,7 @@ def extract_items(pokegold): order = [] name_count = None in_macro = False - for _, line in read_asm(path): + for _, line in read_asm(path, defines): s = line.strip() # The add_tm / add_hm macro bodies contain `const TM_\1` themselves; # counting those would insert a phantom "TM_" item before the real run. @@ -340,7 +346,7 @@ def extract_items(pokegold): return order[1:], name_count - 1 -def extract_specials(pokegold): +def extract_specials(pokegold, defines=None): """Ordered SpecialsPointers labels (data/events/special_pointers.asm). The `special` script command carries an index into this table, not a name, @@ -350,7 +356,7 @@ def extract_specials(pokegold): path = os.path.join(pokegold, "data/events/special_pointers.asm") names = [] in_macro = False - for _, line in read_asm(path): + for _, line in read_asm(path, defines): s = line.strip() if re.match(r"MACRO\b", s): in_macro = True @@ -368,11 +374,11 @@ def extract_specials(pokegold): return names -def extract_std_scripts(pokegold): +def extract_std_scripts(pokegold, defines=None): """Ordered StdScripts labels (engine/events/std_scripts.asm).""" path = os.path.join(pokegold, "engine/events/std_scripts.asm") names = [] - for _, line in read_asm(path): + for _, line in read_asm(path, defines): m = re.match(r"add_stdscript\s+(\w+)", line.strip()) if m: names.append(m.group(1)) @@ -381,14 +387,14 @@ def extract_std_scripts(pokegold): return names -def extract_map_groups(pokegold): +def extract_map_groups(pokegold, defines=None): """Parse constants/map_constants.asm's newgroup/map_const/endgroup.""" path = os.path.join(pokegold, "constants/map_constants.asm") order = [] groups = {} group = 0 map_index = 0 - for _, line in read_asm(path): + for _, line in read_asm(path, defines): s = line.strip() if re.match(r"newgroup\s+\w+", s): group += 1 @@ -411,12 +417,12 @@ def extract_map_groups(pokegold): return order, groups -def extract_types(pokegold): +def extract_types(pokegold, defines=None): """Type constants are physical IDs, a gap, then special IDs (Gen 1-style).""" path = os.path.join(pokegold, "constants/type_constants.asm") types = {} value = None - for _, line in read_asm(path): + for _, line in read_asm(path, defines): s = line.strip() m = re.match(r"const_def(?:\s+(\$?\w+))?$", s) if m: @@ -435,12 +441,14 @@ def extract_types(pokegold): return types -def charmap(pokegold): +def charmap(pokegold, defines=None): """Byte -> text charmap, same shape as make_rom_manifest.charmap.""" expansions = { "": ".", "": "{LV}", "": "{ID}", + # ../pokecrystal/constants/charmap.asm:6 -- $14 is "" in English + "": "", # Compression bytes: the cart stores one byte and PlaceString expands # it into several glyphs. The font sheet only starts at $60, so these # three have no tile of their own and MUST be expanded here or the @@ -453,10 +461,15 @@ def charmap(pokegold): } out = {} path = os.path.join(pokegold, "constants/charmap.asm") - for _, line in read_asm(path): + for _, line in read_asm(path, defines): + stripped = line.strip() + # ../pokecrystal/constants/charmap.asm:423,433 -- the unown and ascii + # charmaps are separate RGBDS charmaps, not more rows of the main one. + if re.match(r"(pushc|newcharmap)\b", stripped): + break m = re.match( r'charmap\s+"((?:[^"\\]|\\.)*)",\s*(\$[0-9a-fA-F]+)', - line.strip()) + stripped) if not m: continue value = int(m.group(2)[1:], 16) @@ -478,22 +491,22 @@ def species_label(species_id): return "".join(part.capitalize() for part in parts) -def pokemon_names(pokegold): +def pokemon_names(pokegold, defines=None): """PokemonNames dname entries, in declared (dex) order.""" names = [] path = os.path.join(pokegold, "data/pokemon/names.asm") - for _, line in read_asm(path): + for _, line in read_asm(path, defines): m = re.match(r'dname\s+"([^"]*)"', line.strip()) if m: names.append(m.group(1)) return names -def music_order(pokegold): +def music_order(pokegold, defines=None): """Music_* labels in MUSIC_* id order from audio/music_pointers.asm.""" path = os.path.join(pokegold, "audio/music_pointers.asm") order = [] - for _, line in read_asm(path): + for _, line in read_asm(path, defines): m = re.match(r"dba\s+(Music_\w+)\s*$", line.strip()) if m: order.append(m.group(1)) @@ -502,11 +515,11 @@ def music_order(pokegold): return order -def sfx_order(pokegold): +def sfx_order(pokegold, defines=None): """Sfx_* labels in SFX_* id order from audio/sfx_pointers.asm.""" path = os.path.join(pokegold, "audio/sfx_pointers.asm") order = [] - for _, line in read_asm(path): + for _, line in read_asm(path, defines): m = re.match(r"dba\s+(Sfx_\w+)\s*$", line.strip()) if m: order.append(m.group(1)) @@ -515,7 +528,27 @@ def sfx_order(pokegold): return order -def pokemon_assets(pokegold, species_order, symbols): +# ../pokecrystal/main.asm:425-448 -- the four per-species pic-animation +# tables; field name in the manifest, symbol suffix in pokecrystal.sym. +ANIM_LABEL_SUFFIXES = ( + ("animLabel", "Animation"), + ("idleLabel", "AnimationIdle"), + ("bitmaskLabel", "Bitmasks"), + ("framesLabel", "Frames"), +) + + +def _anim_labels(base, symbols): + """{animLabel/idleLabel/bitmaskLabel/framesLabel: label or None}.""" + out = {} + for field, suffix in ANIM_LABEL_SUFFIXES: + label = base + suffix + out[field] = label if label in symbols.by_name else None + return out + + +def pokemon_assets(pokegold, species_order, symbols, defines=None, + anim_labels=False): """{species: {id, name, front, back, frontLabel, backLabel}}. Unown shares one pic per letter through UnownPicPointers (its @@ -523,11 +556,15 @@ def pokemon_assets(pokegold, species_order, symbols): per-species Frontpic/Backpic label, so its front/back/labels are left null; the extractor's own Unown handling (if any) goes through UnownPicPointers directly instead of this table. + + `anim_labels` adds the four pic-animation labels per species; off by + default because pokegold.sym carries none of them. """ - names = pokemon_names(pokegold) + names = pokemon_names(pokegold, defines) assets = {} for index, species in enumerate(species_order): name = names[index] if index < len(names) else species + base = species_label(species) if species == "UNOWN": # No per-species pic, but it does have a dex entry like anything # else, so the #DEX screen can still read it. @@ -537,8 +574,9 @@ def pokemon_assets(pokegold, species_order, symbols): "frontLabel": None, "backLabel": None, "dexLabel": "UnownPokedexEntry", } + if anim_labels: + assets[species].update(_anim_labels(base, symbols)) continue - base = species_label(species) front_label, back_label = base + "Frontpic", base + "Backpic" if front_label not in symbols.by_name or back_label not in symbols.by_name: raise ValueError( @@ -554,6 +592,8 @@ def pokemon_assets(pokegold, species_order, symbols): "frontLabel": front_label, "backLabel": back_label, "dexLabel": dex_label if dex_label in symbols.by_name else None, } + if anim_labels: + assets[species].update(_anim_labels(base, symbols)) return assets @@ -800,6 +840,9 @@ REQUIRED_SYMBOLS = { # who just spotted the player, and the other faces scripts use. "ShockEmote", "QuestionEmote", "HappyEmote", "SadEmote", "HeartEmote", "BoltEmote", "SleepEmote", "FishEmote", + # gfx/overworld/chris_fish.2bpp (engine/events/fishing_gfx.asm:23): the + # fishing pose rows and the rod tiles FacingFish* parks by the player. + "FishingGFX", # The Pokecenter heal machine's OBJ art (engine/events/ # heal_machine_anim.asm): two tiles -- the machine's light ($7c) and the # ball ($7d) -- plus the CGB palette .LoadPalettes copies over @@ -927,7 +970,7 @@ TEXT_SOURCES = ( ) -def text_labels(pokegold): +def text_labels(pokegold, defines=None): """Every text label in TEXT_SOURCES, in sorted order. Unlike Gen 1 there is no `dynamic` map beside this. pokered's decoder is @@ -939,7 +982,7 @@ def text_labels(pokegold): for name in TEXT_SOURCES: path = os.path.join(pokegold, "data/text", name) pending = None - for _, line in read_asm(path): + for _, line in read_asm(path, defines): stripped = line.strip() if not stripped: continue @@ -959,9 +1002,11 @@ def text_labels(pokegold): def embedded_symbols(symbols, pokemon_labels, song_labels=(), - text_label_names=()): + text_label_names=(), required=None): """Resolve REQUIRED_SYMBOLS + pic labels + songs + text labels.""" - names = (set(REQUIRED_SYMBOLS) | set(pokemon_labels) + if required is None: + required = REQUIRED_SYMBOLS + names = (set(required) | set(pokemon_labels) | set(song_labels) | set(text_label_names)) for symbol_name in symbols.by_name: # Pokedex entries are split across four banks and the game derives the @@ -985,23 +1030,24 @@ def embedded_symbols(symbols, pokemon_labels, song_labels=(), } -def generate(pokegold, symbols_path): +def generate(pokegold, symbols_path, defines=None, required=None, + sha1=None, anim_labels=False): symbols = SymbolTable(symbols_path) species = parse_const_block( os.path.join(pokegold, "constants/pokemon_constants.asm"), - stop_at="NUM_POKEMON") + stop_at="NUM_POKEMON", defines=defines) species_order = [n or "UNUSED" for n in species[1:]] - map_order, map_groups = extract_map_groups(pokegold) + map_order, map_groups = extract_map_groups(pokegold, defines) tilesets = [n for n in parse_const_block( os.path.join(pokegold, "constants/tileset_constants.asm"), - stop_at="NUM_TILESETS") if n] + stop_at="NUM_TILESETS", defines=defines) if n] moves = parse_const_block( os.path.join(pokegold, "constants/move_constants.asm"), - stop_at="NUM_ATTACKS") + stop_at="NUM_ATTACKS", defines=defines) move_order = [n or "UNUSED" for n in moves[1:]] # sprite_constants.asm stacks the ids of two different tables. $01.. @@ -1019,26 +1065,29 @@ def generate(pokegold, symbols_path): # wVariableSprites, so naming them here would let World:resolveSprite hand # a slot id back as though it were something that could be drawn. sprite_path = os.path.join(pokegold, "constants/sprite_constants.asm") - sprites = parse_const_block(sprite_path, stop_at="NUM_POKEMON_SPRITES") + sprites = parse_const_block( + sprite_path, stop_at="NUM_POKEMON_SPRITES", defines=defines) sprite_order = [n or "UNUSED" for n in sprites[1:]] # The two DEFs the block itself ends its halves on. The $60..$7f hole # between them stays in sprite_order as UNUSED rows so the ids line up. num_overworld_sprites = len( - parse_const_block(sprite_path, stop_at="NUM_OVERWORLD_SPRITES")) - 1 + parse_const_block( + sprite_path, stop_at="NUM_OVERWORLD_SPRITES", + defines=defines)) - 1 sprite_pokemon = sprites.index("SPRITE_UNOWN") if len(sprites) - sprite_pokemon != 35: raise SystemExit( f"{sprite_path}: expected 35 SpriteMons ids, got " f"{len(sprites) - sprite_pokemon}") - types = extract_types(pokegold) + types = extract_types(pokegold, defines) # Environment (1-based), palette (0-based), fish-group (0-based) name # tables -- scraped by prefix so the mixed const_def blocks in # map_data_constants.asm cannot collide with each other. environments, palettes, fish_groups, spawns = [], [], [], [] path = os.path.join(pokegold, "constants/map_data_constants.asm") - for _, line in read_asm(path): + for _, line in read_asm(path, defines): s = line.strip() m = re.match(r"const\s+(\w+)", s) if not m: @@ -1060,7 +1109,8 @@ def generate(pokegold, symbols_path): # Moves' effect byte into one of these names so the battle engine can # switch on a readable id instead of a raw number. move_effects = parse_const_block( - os.path.join(pokegold, "constants/move_effect_constants.asm")) + os.path.join(pokegold, "constants/move_effect_constants.asm"), + defines=defines) move_effect_order = [n or "EFFECT_UNUSED" for n in move_effects] # Battle animations. constants/battle_anim_constants.asm stacks nine @@ -1070,55 +1120,66 @@ def generate(pokegold, symbols_path): # framesets, OAM sets, GFX sheets) plus the BG-effect and palette enums a # disassembled animation refers to by number. battle_anim = os.path.join(pokegold, "constants/battle_anim_constants.asm") - battle_anim_objects = parse_prefixed_consts(battle_anim, ("BATTLE_ANIM_OBJ_",)) - battle_anim_funcs = parse_prefixed_consts(battle_anim, ("BATTLE_ANIM_FUNC_",)) + battle_anim_objects = parse_prefixed_consts( + battle_anim, ("BATTLE_ANIM_OBJ_",), defines=defines) + battle_anim_funcs = parse_prefixed_consts( + battle_anim, ("BATTLE_ANIM_FUNC_",), defines=defines) battle_anim_framesets = parse_prefixed_consts( - battle_anim, ("BATTLE_ANIM_FRAMESET_",)) - battle_anim_oamsets = parse_prefixed_consts(battle_anim, ("BATTLE_ANIM_OAMSET_",)) + battle_anim, ("BATTLE_ANIM_FRAMESET_",), defines=defines) + battle_anim_oamsets = parse_prefixed_consts( + battle_anim, ("BATTLE_ANIM_OAMSET_",), defines=defines) # BATTLE_ANIM_GFX_* is the one block here that does not start at zero # (`const_def 1`, because AnimObjGFX row 0 is the empty AnimObj00GFX). # A placeholder in front keeps every list in this group indexable as # value + 1, so the extractor never has to remember which is which. battle_anim_gfx = ["BATTLE_ANIM_GFX_NONE"] + parse_prefixed_consts( - battle_anim, ("BATTLE_ANIM_GFX_",)) - battle_bg_effects = parse_prefixed_consts(battle_anim, ("BATTLE_BG_EFFECT_",)) + battle_anim, ("BATTLE_ANIM_GFX_",), defines=defines) + battle_bg_effects = parse_prefixed_consts( + battle_anim, ("BATTLE_BG_EFFECT_",), defines=defines) # Two separate blocks, each starting at zero: a BG palette 4 and an OBJ # palette 4 are different colours, so they cannot share one list. - battle_anim_bg_pals = parse_prefixed_consts(battle_anim, ("PAL_BATTLE_BG_",)) - battle_anim_ob_pals = parse_prefixed_consts(battle_anim, ("PAL_BATTLE_OB_",)) + battle_anim_bg_pals = parse_prefixed_consts( + battle_anim, ("PAL_BATTLE_BG_",), defines=defines) + battle_anim_ob_pals = parse_prefixed_consts( + battle_anim, ("PAL_BATTLE_OB_",), defines=defines) item_data = os.path.join(pokegold, "constants/item_data_constants.asm") # Pocket ids are 0-based (ITEM, KEY_ITEM, BALL, TM_HM); the ITEM_* / # ITEMMENU_* / HELD_* blocks share the file, hence the prefix scrape. pocket_order = parse_prefixed_consts( - item_data, (), exact=("ITEM", "KEY_ITEM", "BALL", "TM_HM")) - item_menu_order = parse_prefixed_consts(item_data, ("ITEMMENU_",)) + item_data, (), exact=("ITEM", "KEY_ITEM", "BALL", "TM_HM"), + defines=defines) + item_menu_order = parse_prefixed_consts( + item_data, ("ITEMMENU_",), defines=defines) # The HELD_* block is sparse (const_skip / const_next), so the index of # this list must BE the ItemAttributes effect byte or every held effect # past HELD_CLEANSE_TAG lands on the wrong item. - held_effect_order = parse_sparse_consts(item_data, ("HELD_",)) + held_effect_order = parse_sparse_consts( + item_data, ("HELD_",), defines=defines) mon_data = os.path.join(pokegold, "constants/pokemon_data_constants.asm") - growth_order = parse_prefixed_consts(mon_data, ("GROWTH_",)) - egg_group_order = parse_prefixed_consts(mon_data, ("EGG_",)) - evolve_order = parse_prefixed_consts(mon_data, ("EVOLVE_",)) + growth_order = parse_prefixed_consts(mon_data, ("GROWTH_",), defines=defines) + egg_group_order = parse_prefixed_consts(mon_data, ("EGG_",), defines=defines) + evolve_order = parse_prefixed_consts(mon_data, ("EVOLVE_",), defines=defines) # Map callbacks (constants/map_setup_constants.asm). That block is # `const_def 1`, so MAPCALLBACK_TILES is 1 and index 0 of this list is a # placeholder -- the same "value + 1" indexing BATTLE_ANIM_GFX_* uses. map_setup = os.path.join(pokegold, "constants/map_setup_constants.asm") map_callback_order = ["MAPCALLBACK_NONE"] + parse_prefixed_consts( - map_setup, ("MAPCALLBACK_",)) + map_setup, ("MAPCALLBACK_",), defines=defines) # constants/script_constants.asm stacks a dozen unrelated blocks, so each # of these is found by the name its own block opens with. script_consts = os.path.join(pokegold, "constants/script_constants.asm") cmd_queue_order = parse_const_block_at( - script_consts, "CMDQUEUE_NULL", stop_at="NUM_CMDQUEUE_TYPES") + script_consts, "CMDQUEUE_NULL", stop_at="NUM_CMDQUEUE_TYPES", + defines=defines) floor_order = parse_const_block_at( - script_consts, "FLOOR_B4F", stop_at="NUM_FLOORS") + script_consts, "FLOOR_B4F", stop_at="NUM_FLOORS", defines=defines) deco_desc_order = parse_const_block_at( - script_consts, "DECODESC_POSTER", stop_at="NUM_DECODESCS") + script_consts, "DECODESC_POSTER", stop_at="NUM_DECODESCS", + defines=defines) # The phone. PHONE_* doubles as the PhoneContacts row index and carries # four const_skip holes that are real rows (the wrong-number fillers), so @@ -1129,33 +1190,41 @@ def generate(pokegold, symbols_path): # every contact past a hole four rows down its own table. phone_contact_order = [ n or "PHONE_UNUSED" for n in parse_const_block_at( - phone_consts, "PHONE_00", stop_at="NUM_PHONE_CONTACTS")] + phone_consts, "PHONE_00", stop_at="NUM_PHONE_CONTACTS", + defines=defines)] special_call_order = parse_const_block_at( - phone_consts, "SPECIALCALL_NONE", stop_at="NUM_SPECIALCALLS") + phone_consts, "SPECIALCALL_NONE", stop_at="NUM_SPECIALCALLS", + defines=defines) npc_trade = os.path.join(pokegold, "constants/npc_trade_constants.asm") - trade_gender_order = parse_prefixed_consts(npc_trade, ("TRADE_GENDER_",)) - trade_dialog_order = parse_prefixed_consts(npc_trade, ("TRADE_DIALOGSET_",)) + trade_gender_order = parse_prefixed_consts( + npc_trade, ("TRADE_GENDER_",), defines=defines) + trade_dialog_order = parse_prefixed_consts( + npc_trade, ("TRADE_DIALOGSET_",), defines=defines) - trainer_classes, trainer_members = extract_trainer_classes(pokegold) + trainer_classes, trainer_members = extract_trainer_classes( + pokegold, defines) trainer_types = parse_prefixed_consts( os.path.join(pokegold, "constants/trainer_data_constants.asm"), - ("TRAINERTYPE_",)) + ("TRAINERTYPE_",), defines=defines) landmarks = parse_const_block( os.path.join(pokegold, "constants/landmark_constants.asm"), - stop_at="NUM_LANDMARKS") + stop_at="NUM_LANDMARKS", defines=defines) landmark_order = [n or "UNUSED" for n in landmarks] icons = parse_prefixed_consts( - os.path.join(pokegold, "constants/icon_constants.asm"), ("ICON_",)) + os.path.join(pokegold, "constants/icon_constants.asm"), ("ICON_",), + defines=defines) - tree_sets = parse_prefixed_consts(mon_data, ("TREEMON_SET_",)) + tree_sets = parse_prefixed_consts( + mon_data, ("TREEMON_SET_",), defines=defines) - std_scripts = extract_std_scripts(pokegold) - specials = extract_specials(pokegold) + std_scripts = extract_std_scripts(pokegold, defines) + specials = extract_specials(pokegold, defines) - assets = pokemon_assets(pokegold, species_order, symbols) + assets = pokemon_assets(pokegold, species_order, symbols, defines, + anim_labels=anim_labels) pokemon_labels = [] for asset in assets.values(): if asset["frontLabel"]: @@ -1163,19 +1232,19 @@ def generate(pokegold, symbols_path): if asset["backLabel"]: pokemon_labels.append(asset["backLabel"]) - songs = music_order(pokegold) - text_label_names = text_labels(pokegold) - sfx = sfx_order(pokegold) + songs = music_order(pokegold, defines) + text_label_names = text_labels(pokegold, defines) + sfx = sfx_order(pokegold, defines) # Index 0 is NO_ITEM, so the parsed list is already 1-based on item id. # ItemNames only has rows for the first `item_name_count` of them; the TM # and HM items past that are named from their TM number instead. - item_order, item_name_count = extract_items(pokegold) + item_order, item_name_count = extract_items(pokegold, defines) data = { "format": 3, "generation": 2, - "romSha1": CANONICAL_GOLD_SHA1, + "romSha1": sha1 or CANONICAL_GOLD_SHA1, "constants": { "source": "pret/pokegold constants/*.asm", "speciesOrder": species_order, @@ -1259,7 +1328,7 @@ def generate(pokegold, symbols_path): # Label -> decoded string is built at import time from these, the # same way Gen 1 builds data/generated/text.lua from its own list. "text": {"labels": text_label_names}, - "charmap": charmap(pokegold), + "charmap": charmap(pokegold, defines), "fontCharmap": font_extract.parse_charmap(pokegold), "pokemonAssets": assets, # Per-map metadata (group/map/width/height/name). RomExtractorGen2 @@ -1268,7 +1337,7 @@ def generate(pokegold, symbols_path): "tilesets": {name: {} for name in tilesets}, } data["symbols"] = embedded_symbols( - symbols, pokemon_labels, songs, text_label_names) + symbols, pokemon_labels, songs, text_label_names, required=required) return data diff --git a/tools/modkit.py b/tools/modkit.py index 6b934c61..bae76b15 100644 --- a/tools/modkit.py +++ b/tools/modkit.py @@ -741,7 +741,7 @@ def imported_data_dir(repo): return path if _generated_data_dir_ok(path) else None version = (os.environ.get("POKEPORT_VERSION") or "red").lower() - if version not in ("red", "blue", "yellow", "gold", "silver"): + if version not in ("red", "blue", "yellow", "gold", "silver", "crystal"): version = "red" candidates = [ @@ -2404,8 +2404,8 @@ UPVALUE_CALL = re.compile( UPVALUE_ARGS = re.compile( r"""^\s*([A-Za-z_]\w*)\s*\.\s*(\w+)\s*,\s*["'](\w+)["']""") VERSION_MATCH = re.compile( - r"""[=~]=\s*["'](red|blue|yellow|gold|silver)["']""" - r"""|["'](red|blue|yellow|gold|silver)["']\s*[=~]=""") + r"""[=~]=\s*["'](red|blue|yellow|gold|silver|crystal)["']""" + r"""|["'](red|blue|yellow|gold|silver|crystal)["']\s*[=~]=""") def _line_of(body, offset): diff --git a/tools/rom_data.py b/tools/rom_data.py index 2e9375d5..bfb3d084 100644 --- a/tools/rom_data.py +++ b/tools/rom_data.py @@ -14,6 +14,9 @@ CANONICAL_YELLOW_SHA1 = "cc7d03262ebfaf2f06772c1a480c7d9d5f4a38e1" # Gold and Silver are Gen 2: 2 MiB carts, twice the size of the Gen 1 ROMs above. CANONICAL_GOLD_SHA1 = "d8b8a3600a465308c9953dfa04f0081c05bdcb94" CANONICAL_SILVER_SHA1 = "49b163f7e57702bc939d642a18f591de55d92dae" +# Crystal is the retail international v1.0 build (pret/pokecrystal's default +# `make` target), also 2 MiB. +CANONICAL_CRYSTAL_SHA1 = "f4cd194bdee0d04ca4eac29e09b8e4e9d818c133" ROM_BANK_SIZE = 0x4000 diff --git a/tools/rom_manifest_crystal.json b/tools/rom_manifest_crystal.json new file mode 100644 index 00000000..260b93f7 --- /dev/null +++ b/tools/rom_manifest_crystal.json @@ -0,0 +1,24675 @@ +{ + "charmap": { + "0": "", + "10": "ザ", + "100": "", + "101": "", + "102": "", + "103": "", + "104": "", + "105": "", + "106": "", + "107": "", + "108": "", + "109": "", + "11": "ジ", + "110": "ぃ", + "111": "ぅ", + "112": "", + "113": "", + "114": "“", + "115": "”", + "116": "·", + "117": "…", + "118": "ぁ", + "119": "ぇ", + "12": "ズ", + "120": "ぉ", + "121": "┌", + "122": "─", + "123": "┐", + "124": "│", + "125": "└", + "126": "┘", + "127": " ", + "128": "A", + "129": "B", + "13": "ゼ", + "130": "C", + "131": "D", + "132": "E", + "133": "F", + "134": "G", + "135": "H", + "136": "I", + "137": "J", + "138": "K", + "139": "L", + "14": "ゾ", + "140": "M", + "141": "N", + "142": "O", + "143": "P", + "144": "Q", + "145": "R", + "146": "S", + "147": "T", + "148": "U", + "149": "V", + "15": "ダ", + "150": "W", + "151": "X", + "152": "Y", + "153": "Z", + "154": "(", + "155": ")", + "156": ":", + "157": ";", + "158": "[", + "159": "]", + "16": "ヂ", + "160": "a", + "161": "b", + "162": "c", + "163": "d", + "164": "e", + "165": "f", + "166": "g", + "167": "h", + "168": "i", + "169": "j", + "17": "ヅ", + "170": "k", + "171": "l", + "172": "m", + "173": "n", + "174": "o", + "175": "p", + "176": "q", + "177": "r", + "178": "s", + "179": "t", + "18": "デ", + "180": "u", + "181": "v", + "182": "w", + "183": "x", + "184": "y", + "185": "z", + "186": "こ", + "187": "さ", + "188": "し", + "189": "す", + "19": "ド", + "190": "せ", + "191": "そ", + "192": "Ä", + "193": "Ö", + "194": "Ü", + "195": "ä", + "196": "ö", + "197": "ü", + "198": "に", + "199": "ぬ", + "20": "", + "200": "ね", + "201": "の", + "202": "は", + "203": "ひ", + "204": "ふ", + "205": "へ", + "206": "ほ", + "207": "ま", + "208": "'d", + "209": "'l", + "21": "", + "210": "'m", + "211": "'r", + "212": "'s", + "213": "'t", + "214": "'v", + "215": "ら", + "216": "り", + "217": "る", + "218": "れ", + "219": "ろ", + "22": "", + "220": "わ", + "221": "を", + "222": "ん", + "223": "←", + "224": "'", + "225": "", + "226": "", + "227": "-", + "228": "゚", + "229": "゙", + "230": "?", + "231": "!", + "232": ".", + "233": "&", + "234": "é", + "235": "→", + "236": "▷", + "237": "▶", + "238": "▼", + "239": "♂", + "24": "", + "240": "¥", + "241": "×", + "242": ".", + "243": "/", + "244": ",", + "245": "♀", + "246": "0", + "247": "1", + "248": "2", + "249": "3", + "25": "バ", + "250": "4", + "251": "5", + "252": "6", + "253": "7", + "254": "8", + "255": "9", + "26": "ビ", + "27": "ブ", + "28": "ボ", + "29": "", + "30": "", + "31": "", + "34": "", + "35": "", + "36": "", + "37": "", + "38": "が", + "39": "ぎ", + "40": "ぐ", + "41": "げ", + "42": "ご", + "43": "ざ", + "44": "じ", + "45": "ず", + "46": "ぜ", + "47": "ぞ", + "48": "だ", + "49": "ぢ", + "5": "ガ", + "50": "づ", + "51": "で", + "52": "ど", + "53": "", + "54": "", + "55": "", + "56": "", + "57": "", + "58": "ば", + "59": "び", + "6": "ギ", + "60": "ぶ", + "61": "べ", + "62": "ぼ", + "63": "", + "64": "パ", + "65": "ピ", + "66": "プ", + "67": "ポ", + "68": "ぱ", + "69": "ぴ", + "7": "グ", + "70": "ぷ", + "71": "ぺ", + "72": "ぽ", + "73": "", + "74": "", + "75": "<_CONT>", + "76": "", + "78": "", + "79": "", + "8": "ゲ", + "80": "@", + "81": "", + "82": "", + "83": "", + "84": "POKé", + "85": "", + "86": "<……>", + "87": "", + "88": "", + "89": "", + "9": "ゴ", + "90": "", + "91": "", + "92": "", + "93": "", + "94": "", + "95": "", + "96": "", + "97": "", + "98": "", + "99": "" + }, + "constants": { + "battleAnimBgPaletteOrder": [ + "PAL_BATTLE_BG_PLAYER", + "PAL_BATTLE_BG_ENEMY", + "PAL_BATTLE_BG_ENEMY_HP", + "PAL_BATTLE_BG_PLAYER_HP", + "PAL_BATTLE_BG_EXP", + "PAL_BATTLE_BG_5", + "PAL_BATTLE_BG_6", + "PAL_BATTLE_BG_TEXT" + ], + "battleAnimFramesetOrder": [ + "BATTLE_ANIM_FRAMESET_HIT_BIG", + "BATTLE_ANIM_FRAMESET_HIT", + "BATTLE_ANIM_FRAMESET_HIT_SMALL", + "BATTLE_ANIM_FRAMESET_PUNCH", + "BATTLE_ANIM_FRAMESET_KICK", + "BATTLE_ANIM_FRAMESET_PALM", + "BATTLE_ANIM_FRAMESET_FANG", + "BATTLE_ANIM_FRAMESET_PUNCH_SHAKE", + "BATTLE_ANIM_FRAMESET_BALL_POOF", + "BATTLE_ANIM_FRAMESET_POKE_BALL_1", + "BATTLE_ANIM_FRAMESET_POKE_BALL_2", + "BATTLE_ANIM_FRAMESET_POKE_BALL_3", + "BATTLE_ANIM_FRAMESET_POKE_BALL_4", + "BATTLE_ANIM_FRAMESET_POKE_BALL_5", + "BATTLE_ANIM_FRAMESET_DRAGON_RAGE", + "BATTLE_ANIM_FRAMESET_FLAMETHROWER", + "BATTLE_ANIM_FRAMESET_EMBER", + "BATTLE_ANIM_FRAMESET_BURNED", + "BATTLE_ANIM_FRAMESET_BLIZZARD", + "BATTLE_ANIM_FRAMESET_ICE", + "BATTLE_ANIM_FRAMESET_ICE_BEAM", + "BATTLE_ANIM_FRAMESET_POWDER_SNOW", + "BATTLE_ANIM_FRAMESET_RAZOR_LEAF_1", + "BATTLE_ANIM_FRAMESET_RAZOR_LEAF_2", + "BATTLE_ANIM_FRAMESET_EXPLOSION", + "BATTLE_ANIM_FRAMESET_BIG_ROCK", + "BATTLE_ANIM_FRAMESET_SMALL_ROCK", + "BATTLE_ANIM_FRAMESET_STRENGTH", + "BATTLE_ANIM_FRAMESET_SKULL_CROSSBONE", + "BATTLE_ANIM_FRAMESET_ACID", + "BATTLE_ANIM_FRAMESET_POISON_DROPLET_UNUSED", + "BATTLE_ANIM_FRAMESET_SLUDGE_BUBBLE", + "BATTLE_ANIM_FRAMESET_SLUDGE_BUBBLE_BURST", + "BATTLE_ANIM_FRAMESET_SMALL_BUBBLE", + "BATTLE_ANIM_FRAMESET_PULSING_BUBBLE", + "BATTLE_ANIM_FRAMESET_SURF", + "BATTLE_ANIM_FRAMESET_MUSIC_NOTE_1", + "BATTLE_ANIM_FRAMESET_MUSIC_NOTE_2", + "BATTLE_ANIM_FRAMESET_MUSIC_NOTE_3", + "BATTLE_ANIM_FRAMESET_WATER_GUN_1", + "BATTLE_ANIM_FRAMESET_WATER_GUN_2", + "BATTLE_ANIM_FRAMESET_WATER_GUN_3", + "BATTLE_ANIM_FRAMESET_HYDRO_PUMP", + "BATTLE_ANIM_FRAMESET_POWDER", + "BATTLE_ANIM_FRAMESET_BEAM", + "BATTLE_ANIM_FRAMESET_BEAM_TIP", + "BATTLE_ANIM_FRAMESET_ICE_BUILDUP", + "BATTLE_ANIM_FRAMESET_FROZEN", + "BATTLE_ANIM_FRAMESET_CIRCLING_SPARKLE", + "BATTLE_ANIM_FRAMESET_THUNDER_CENTER", + "BATTLE_ANIM_FRAMESET_THUNDER_LEFT", + "BATTLE_ANIM_FRAMESET_THUNDER_RIGHT", + "BATTLE_ANIM_FRAMESET_THUNDER_WAVE_DISABLE", + "BATTLE_ANIM_FRAMESET_THUNDER_WAVE_EXTRA", + "BATTLE_ANIM_FRAMESET_THUNDERBOLT_SPARKS", + "BATTLE_ANIM_FRAMESET_THUNDERBOLT_CORE", + "BATTLE_ANIM_FRAMESET_THUNDERSHOCK_SPARKS", + "BATTLE_ANIM_FRAMESET_THUNDERSHOCK_CORE", + "BATTLE_ANIM_FRAMESET_CLAMP", + "BATTLE_ANIM_FRAMESET_CLAMP_FLIPPED", + "BATTLE_ANIM_FRAMESET_BITE_1", + "BATTLE_ANIM_FRAMESET_BITE_2", + "BATTLE_ANIM_FRAMESET_CUT_DOWN_LEFT", + "BATTLE_ANIM_FRAMESET_CUT_DOWN_RIGHT", + "BATTLE_ANIM_FRAMESET_CUT_UP_RIGHT", + "BATTLE_ANIM_FRAMESET_CUT_LONG_DOWN_LEFT", + "BATTLE_ANIM_FRAMESET_CUT_LONG_DOWN_RIGHT", + "BATTLE_ANIM_FRAMESET_CHARGE_ORB_1", + "BATTLE_ANIM_FRAMESET_ABSORB_CENTER", + "BATTLE_ANIM_FRAMESET_GUST", + "BATTLE_ANIM_FRAMESET_VINE_WHIP_1", + "BATTLE_ANIM_FRAMESET_VINE_WHIP_2", + "BATTLE_ANIM_FRAMESET_RAZOR_WIND_1", + "BATTLE_ANIM_FRAMESET_RAZOR_WIND_2", + "BATTLE_ANIM_FRAMESET_SONICBOOM_JP", + "BATTLE_ANIM_FRAMESET_WARP", + "BATTLE_ANIM_FRAMESET_CHARGE_ORB_2", + "BATTLE_ANIM_FRAMESET_EGG", + "BATTLE_ANIM_FRAMESET_EGG_WOBBLE", + "BATTLE_ANIM_FRAMESET_EGG_CRACKED_TOP", + "BATTLE_ANIM_FRAMESET_EGG_CRACKED_BOTTOM", + "BATTLE_ANIM_FRAMESET_FOCUS", + "BATTLE_ANIM_FRAMESET_BIND_1", + "BATTLE_ANIM_FRAMESET_BIND_2", + "BATTLE_ANIM_FRAMESET_BIND_3", + "BATTLE_ANIM_FRAMESET_BIND_4", + "BATTLE_ANIM_FRAMESET_LEECH_SEED_1", + "BATTLE_ANIM_FRAMESET_LEECH_SEED_2", + "BATTLE_ANIM_FRAMESET_LEECH_SEED_3", + "BATTLE_ANIM_FRAMESET_SOUND_1", + "BATTLE_ANIM_FRAMESET_SOUND_2", + "BATTLE_ANIM_FRAMESET_SOUND_3", + "BATTLE_ANIM_FRAMESET_WAVE", + "BATTLE_ANIM_FRAMESET_CONFUSE_RAY_1", + "BATTLE_ANIM_FRAMESET_CONFUSE_RAY_2", + "BATTLE_ANIM_FRAMESET_LEER", + "BATTLE_ANIM_FRAMESET_REFLECT", + "BATTLE_ANIM_FRAMESET_CHICK_1", + "BATTLE_ANIM_FRAMESET_CHICK_2", + "BATTLE_ANIM_FRAMESET_AMNESIA_1", + "BATTLE_ANIM_FRAMESET_AMNESIA_2", + "BATTLE_ANIM_FRAMESET_AMNESIA_3", + "BATTLE_ANIM_FRAMESET_ASLEEP", + "BATTLE_ANIM_FRAMESET_DIG_SAND", + "BATTLE_ANIM_FRAMESET_DIG_PILE", + "BATTLE_ANIM_FRAMESET_SAND", + "BATTLE_ANIM_FRAMESET_STRING_SHOT_1", + "BATTLE_ANIM_FRAMESET_STRING_SHOT_2", + "BATTLE_ANIM_FRAMESET_STRING_SHOT_3", + "BATTLE_ANIM_FRAMESET_PARALYZED", + "BATTLE_ANIM_FRAMESET_PARALYZED_FLIPPED", + "BATTLE_ANIM_FRAMESET_HAZE", + "BATTLE_ANIM_FRAMESET_MIST", + "BATTLE_ANIM_FRAMESET_HORN", + "BATTLE_ANIM_FRAMESET_NEEDLE", + "BATTLE_ANIM_FRAMESET_FLOWER", + "BATTLE_ANIM_FRAMESET_BARRAGE_BALL", + "BATTLE_ANIM_FRAMESET_PAY_DAY", + "BATTLE_ANIM_FRAMESET_HEART", + "BATTLE_ANIM_FRAMESET_SPINNING_BONE", + "BATTLE_ANIM_FRAMESET_STAR", + "BATTLE_ANIM_FRAMESET_SPOON", + "BATTLE_ANIM_FRAMESET_SPARKLE", + "BATTLE_ANIM_FRAMESET_SKY_ATTACK", + "BATTLE_ANIM_FRAMESET_LICK", + "BATTLE_ANIM_FRAMESET_WITHDRAW_SHELL", + "BATTLE_ANIM_FRAMESET_SHRINKING_CHARGE_ORB", + "BATTLE_ANIM_FRAMESET_CONVERSION", + "BATTLE_ANIM_FRAMESET_SWORD", + "BATTLE_ANIM_FRAMESET_SPEED_LINE_1", + "BATTLE_ANIM_FRAMESET_SPEED_LINE_2", + "BATTLE_ANIM_FRAMESET_SPEED_LINE_3", + "BATTLE_ANIM_FRAMESET_SEISMIC_TOSS", + "BATTLE_ANIM_FRAMESET_SHARPEN", + "BATTLE_ANIM_FRAMESET_DEFENSE_CURL", + "BATTLE_ANIM_FRAMESET_METRONOME_HAND", + "BATTLE_ANIM_FRAMESET_AGILITY", + "BATTLE_ANIM_FRAMESET_COTTON", + "BATTLE_ANIM_FRAMESET_MILK_BOTTLE", + "BATTLE_ANIM_FRAMESET_SPIKE", + "BATTLE_ANIM_FRAMESET_ANGER_VEIN", + "BATTLE_ANIM_FRAMESET_HEAL_BELL", + "BATTLE_ANIM_FRAMESET_BATON_PASS", + "BATTLE_ANIM_FRAMESET_LOCK_ON_1", + "BATTLE_ANIM_FRAMESET_LOCK_ON_2", + "BATTLE_ANIM_FRAMESET_LOCK_ON_3", + "BATTLE_ANIM_FRAMESET_LOCK_ON_4", + "BATTLE_ANIM_FRAMESET_MIND_READER_1", + "BATTLE_ANIM_FRAMESET_MIND_READER_2", + "BATTLE_ANIM_FRAMESET_MIND_READER_3", + "BATTLE_ANIM_FRAMESET_MIND_READER_4", + "BATTLE_ANIM_FRAMESET_SAFEGUARD", + "BATTLE_ANIM_FRAMESET_MEGAPHONE_UNUSED", + "BATTLE_ANIM_FRAMESET_ITEM_BAG", + "BATTLE_ANIM_FRAMESET_SPIDER_WEB", + "BATTLE_ANIM_FRAMESET_UNUSED_CAKE", + "BATTLE_ANIM_FRAMESET_IMP", + "BATTLE_ANIM_FRAMESET_IMP_FLIPPED", + "BATTLE_ANIM_FRAMESET_CHERUB", + "BATTLE_ANIM_FRAMESET_PENCIL", + "BATTLE_ANIM_FRAMESET_ENCORE_HAND", + "BATTLE_ANIM_FRAMESET_ENCORE_HAND_FLIPPED", + "BATTLE_ANIM_FRAMESET_DESTINY_BOND", + "BATTLE_ANIM_FRAMESET_MORNING_SUN", + "BATTLE_ANIM_FRAMESET_GLIMMER", + "BATTLE_ANIM_FRAMESET_MOONLIGHT", + "BATTLE_ANIM_FRAMESET_CROSS_CHOP_1", + "BATTLE_ANIM_FRAMESET_CROSS_CHOP_2", + "BATTLE_ANIM_FRAMESET_ZAP_CANNON", + "BATTLE_ANIM_FRAMESET_CURSE_NAIL", + "BATTLE_ANIM_FRAMESET_FORESIGHT_SHINE", + "BATTLE_ANIM_FRAMESET_RAPID_SPIN", + "BATTLE_ANIM_FRAMESET_SWAGGER", + "BATTLE_ANIM_FRAMESET_MEAN_LOOK", + "BATTLE_ANIM_FRAMESET_UPSIDE_DOWN_PUNCH", + "BATTLE_ANIM_FRAMESET_GROWING_SPARKLE", + "BATTLE_ANIM_FRAMESET_RAIN", + "BATTLE_ANIM_FRAMESET_PSYCH_UP", + "BATTLE_ANIM_FRAMESET_SPIRAL_UNUSED", + "BATTLE_ANIM_FRAMESET_AEROBLAST", + "BATTLE_ANIM_FRAMESET_SANDSTORM", + "BATTLE_ANIM_FRAMESET_ENEMYFEET_1ROW", + "BATTLE_ANIM_FRAMESET_PLAYERHEAD_1ROW", + "BATTLE_ANIM_FRAMESET_ENEMYFEET_2ROW", + "BATTLE_ANIM_FRAMESET_PLAYERHEAD_2ROW" + ], + "battleAnimFuncOrder": [ + "BATTLE_ANIM_FUNC_NULL", + "BATTLE_ANIM_FUNC_USER_TO_TARGET", + "BATTLE_ANIM_FUNC_USER_TO_TARGET_DISAPPEAR", + "BATTLE_ANIM_FUNC_MOVE_IN_CIRCLE", + "BATTLE_ANIM_FUNC_WAVE_TO_TARGET", + "BATTLE_ANIM_FUNC_THROW_TO_TARGET", + "BATTLE_ANIM_FUNC_THROW_TO_TARGET_DISAPPEAR", + "BATTLE_ANIM_FUNC_DROP", + "BATTLE_ANIM_FUNC_USER_TO_TARGET_SPIN", + "BATTLE_ANIM_FUNC_SHAKE", + "BATTLE_ANIM_FUNC_FIRE_BLAST", + "BATTLE_ANIM_FUNC_RAZOR_LEAF", + "BATTLE_ANIM_FUNC_BUBBLE", + "BATTLE_ANIM_FUNC_SURF", + "BATTLE_ANIM_FUNC_SING", + "BATTLE_ANIM_FUNC_WATER_GUN", + "BATTLE_ANIM_FUNC_EMBER", + "BATTLE_ANIM_FUNC_POWDER", + "BATTLE_ANIM_FUNC_POKEBALL", + "BATTLE_ANIM_FUNC_POKEBALL_BLOCKED", + "BATTLE_ANIM_FUNC_RECOVER", + "BATTLE_ANIM_FUNC_THUNDER_WAVE", + "BATTLE_ANIM_FUNC_CLAMP_ENCORE", + "BATTLE_ANIM_FUNC_BITE", + "BATTLE_ANIM_FUNC_SOLAR_BEAM", + "BATTLE_ANIM_FUNC_GUST", + "BATTLE_ANIM_FUNC_RAZOR_WIND", + "BATTLE_ANIM_FUNC_KICK", + "BATTLE_ANIM_FUNC_ABSORB", + "BATTLE_ANIM_FUNC_EGG", + "BATTLE_ANIM_FUNC_MOVE_UP", + "BATTLE_ANIM_FUNC_WRAP", + "BATTLE_ANIM_FUNC_LEECH_SEED", + "BATTLE_ANIM_FUNC_SOUND", + "BATTLE_ANIM_FUNC_CONFUSE_RAY", + "BATTLE_ANIM_FUNC_DIZZY", + "BATTLE_ANIM_FUNC_AMNESIA", + "BATTLE_ANIM_FUNC_FLOAT_UP", + "BATTLE_ANIM_FUNC_DIG", + "BATTLE_ANIM_FUNC_STRING", + "BATTLE_ANIM_FUNC_PARALYZED", + "BATTLE_ANIM_FUNC_SPIRAL_DESCENT", + "BATTLE_ANIM_FUNC_POISON_GAS", + "BATTLE_ANIM_FUNC_HORN", + "BATTLE_ANIM_FUNC_NEEDLE", + "BATTLE_ANIM_FUNC_PETAL_DANCE", + "BATTLE_ANIM_FUNC_THIEF_PAYDAY", + "BATTLE_ANIM_FUNC_ABSORB_CIRCLE", + "BATTLE_ANIM_FUNC_BONEMERANG", + "BATTLE_ANIM_FUNC_SHINY", + "BATTLE_ANIM_FUNC_SKY_ATTACK", + "BATTLE_ANIM_FUNC_GROWTH_SWORDS_DANCE", + "BATTLE_ANIM_FUNC_SMOKE_FLAME_WHEEL", + "BATTLE_ANIM_FUNC_PRESENT_SMOKESCREEN", + "BATTLE_ANIM_FUNC_STRENGTH_SEISMIC_TOSS", + "BATTLE_ANIM_FUNC_SPEED_LINE", + "BATTLE_ANIM_FUNC_SLUDGE", + "BATTLE_ANIM_FUNC_METRONOME_HAND", + "BATTLE_ANIM_FUNC_METRONOME_SPARKLE_SKETCH", + "BATTLE_ANIM_FUNC_AGILITY", + "BATTLE_ANIM_FUNC_SACRED_FIRE", + "BATTLE_ANIM_FUNC_SAFEGUARD_PROTECT", + "BATTLE_ANIM_FUNC_LOCK_ON_MIND_READER", + "BATTLE_ANIM_FUNC_SPIKES", + "BATTLE_ANIM_FUNC_HEAL_BELL_NOTES", + "BATTLE_ANIM_FUNC_BATON_PASS", + "BATTLE_ANIM_FUNC_CONVERSION", + "BATTLE_ANIM_FUNC_ENCORE_BELLY_DRUM", + "BATTLE_ANIM_FUNC_SWAGGER_MORNING_SUN", + "BATTLE_ANIM_FUNC_HIDDEN_POWER", + "BATTLE_ANIM_FUNC_CURSE", + "BATTLE_ANIM_FUNC_PERISH_SONG", + "BATTLE_ANIM_FUNC_RAPID_SPIN", + "BATTLE_ANIM_FUNC_BETA_PURSUIT", + "BATTLE_ANIM_FUNC_RAIN_SANDSTORM", + "BATTLE_ANIM_FUNC_BATTLE_ANIM_OBJ_B0", + "BATTLE_ANIM_FUNC_PSYCH_UP", + "BATTLE_ANIM_FUNC_ANCIENT_POWER", + "BATTLE_ANIM_FUNC_ROCK_SMASH", + "BATTLE_ANIM_FUNC_COTTON" + ], + "battleAnimGfxOrder": [ + "BATTLE_ANIM_GFX_NONE", + "BATTLE_ANIM_GFX_HIT", + "BATTLE_ANIM_GFX_CUT", + "BATTLE_ANIM_GFX_FIRE", + "BATTLE_ANIM_GFX_WATER", + "BATTLE_ANIM_GFX_LIGHTNING", + "BATTLE_ANIM_GFX_PLANT", + "BATTLE_ANIM_GFX_SMOKE", + "BATTLE_ANIM_GFX_EXPLOSION", + "BATTLE_ANIM_GFX_ROCKS", + "BATTLE_ANIM_GFX_ICE", + "BATTLE_ANIM_GFX_POKE_BALL", + "BATTLE_ANIM_GFX_POISON", + "BATTLE_ANIM_GFX_BUBBLE", + "BATTLE_ANIM_GFX_NOISE", + "BATTLE_ANIM_GFX_POWDER", + "BATTLE_ANIM_GFX_BEAM", + "BATTLE_ANIM_GFX_SPEED", + "BATTLE_ANIM_GFX_CHARGE", + "BATTLE_ANIM_GFX_WIND", + "BATTLE_ANIM_GFX_WHIP", + "BATTLE_ANIM_GFX_EGG", + "BATTLE_ANIM_GFX_ROPE", + "BATTLE_ANIM_GFX_PSYCHIC", + "BATTLE_ANIM_GFX_REFLECT", + "BATTLE_ANIM_GFX_STATUS", + "BATTLE_ANIM_GFX_SAND", + "BATTLE_ANIM_GFX_WEB", + "BATTLE_ANIM_GFX_HAZE", + "BATTLE_ANIM_GFX_HORN", + "BATTLE_ANIM_GFX_FLOWER", + "BATTLE_ANIM_GFX_MISC", + "BATTLE_ANIM_GFX_SKY_ATTACK", + "BATTLE_ANIM_GFX_GLOBE", + "BATTLE_ANIM_GFX_SHAPES", + "BATTLE_ANIM_GFX_OBJECTS", + "BATTLE_ANIM_GFX_SHINE", + "BATTLE_ANIM_GFX_ANGELS", + "BATTLE_ANIM_GFX_WAVE", + "BATTLE_ANIM_GFX_AEROBLAST", + "BATTLE_ANIM_GFX_PLAYERHEAD", + "BATTLE_ANIM_GFX_ENEMYFEET" + ], + "battleAnimOamsetOrder": [ + "BATTLE_ANIM_OAMSET_00", + "BATTLE_ANIM_OAMSET_01", + "BATTLE_ANIM_OAMSET_02", + "BATTLE_ANIM_OAMSET_03", + "BATTLE_ANIM_OAMSET_04", + "BATTLE_ANIM_OAMSET_05", + "BATTLE_ANIM_OAMSET_06", + "BATTLE_ANIM_OAMSET_07", + "BATTLE_ANIM_OAMSET_08", + "BATTLE_ANIM_OAMSET_09", + "BATTLE_ANIM_OAMSET_0A", + "BATTLE_ANIM_OAMSET_0B", + "BATTLE_ANIM_OAMSET_0C", + "BATTLE_ANIM_OAMSET_0D", + "BATTLE_ANIM_OAMSET_0E", + "BATTLE_ANIM_OAMSET_0F", + "BATTLE_ANIM_OAMSET_10", + "BATTLE_ANIM_OAMSET_11", + "BATTLE_ANIM_OAMSET_12", + "BATTLE_ANIM_OAMSET_13", + "BATTLE_ANIM_OAMSET_14", + "BATTLE_ANIM_OAMSET_15", + "BATTLE_ANIM_OAMSET_16", + "BATTLE_ANIM_OAMSET_17", + "BATTLE_ANIM_OAMSET_18", + "BATTLE_ANIM_OAMSET_19", + "BATTLE_ANIM_OAMSET_1A", + "BATTLE_ANIM_OAMSET_1B", + "BATTLE_ANIM_OAMSET_1C", + "BATTLE_ANIM_OAMSET_1D", + "BATTLE_ANIM_OAMSET_1E", + "BATTLE_ANIM_OAMSET_1F", + "BATTLE_ANIM_OAMSET_20", + "BATTLE_ANIM_OAMSET_21", + "BATTLE_ANIM_OAMSET_22", + "BATTLE_ANIM_OAMSET_23", + "BATTLE_ANIM_OAMSET_24", + "BATTLE_ANIM_OAMSET_25", + "BATTLE_ANIM_OAMSET_26", + "BATTLE_ANIM_OAMSET_27", + "BATTLE_ANIM_OAMSET_28", + "BATTLE_ANIM_OAMSET_29", + "BATTLE_ANIM_OAMSET_2A", + "BATTLE_ANIM_OAMSET_2B", + "BATTLE_ANIM_OAMSET_2C", + "BATTLE_ANIM_OAMSET_2D", + "BATTLE_ANIM_OAMSET_2E", + "BATTLE_ANIM_OAMSET_2F", + "BATTLE_ANIM_OAMSET_30", + "BATTLE_ANIM_OAMSET_31", + "BATTLE_ANIM_OAMSET_32", + "BATTLE_ANIM_OAMSET_33", + "BATTLE_ANIM_OAMSET_34", + "BATTLE_ANIM_OAMSET_35", + "BATTLE_ANIM_OAMSET_36", + "BATTLE_ANIM_OAMSET_37", + "BATTLE_ANIM_OAMSET_38", + "BATTLE_ANIM_OAMSET_39", + "BATTLE_ANIM_OAMSET_3A", + "BATTLE_ANIM_OAMSET_3B", + "BATTLE_ANIM_OAMSET_3C", + "BATTLE_ANIM_OAMSET_3D", + "BATTLE_ANIM_OAMSET_3E", + "BATTLE_ANIM_OAMSET_3F", + "BATTLE_ANIM_OAMSET_40", + "BATTLE_ANIM_OAMSET_41", + "BATTLE_ANIM_OAMSET_42", + "BATTLE_ANIM_OAMSET_43", + "BATTLE_ANIM_OAMSET_44", + "BATTLE_ANIM_OAMSET_45", + "BATTLE_ANIM_OAMSET_46", + "BATTLE_ANIM_OAMSET_47", + "BATTLE_ANIM_OAMSET_48", + "BATTLE_ANIM_OAMSET_49", + "BATTLE_ANIM_OAMSET_4A", + "BATTLE_ANIM_OAMSET_4B", + "BATTLE_ANIM_OAMSET_4C", + "BATTLE_ANIM_OAMSET_4D", + "BATTLE_ANIM_OAMSET_4E", + "BATTLE_ANIM_OAMSET_4F", + "BATTLE_ANIM_OAMSET_50", + "BATTLE_ANIM_OAMSET_51", + "BATTLE_ANIM_OAMSET_52", + "BATTLE_ANIM_OAMSET_53", + "BATTLE_ANIM_OAMSET_54", + "BATTLE_ANIM_OAMSET_55", + "BATTLE_ANIM_OAMSET_56", + "BATTLE_ANIM_OAMSET_57", + "BATTLE_ANIM_OAMSET_58", + "BATTLE_ANIM_OAMSET_59", + "BATTLE_ANIM_OAMSET_5A", + "BATTLE_ANIM_OAMSET_5B", + "BATTLE_ANIM_OAMSET_5C", + "BATTLE_ANIM_OAMSET_5D", + "BATTLE_ANIM_OAMSET_5E", + "BATTLE_ANIM_OAMSET_5F", + "BATTLE_ANIM_OAMSET_60", + "BATTLE_ANIM_OAMSET_61", + "BATTLE_ANIM_OAMSET_62", + "BATTLE_ANIM_OAMSET_63", + "BATTLE_ANIM_OAMSET_64", + "BATTLE_ANIM_OAMSET_65", + "BATTLE_ANIM_OAMSET_66", + "BATTLE_ANIM_OAMSET_67", + "BATTLE_ANIM_OAMSET_68", + "BATTLE_ANIM_OAMSET_69", + "BATTLE_ANIM_OAMSET_6A", + "BATTLE_ANIM_OAMSET_6B", + "BATTLE_ANIM_OAMSET_6C", + "BATTLE_ANIM_OAMSET_6D", + "BATTLE_ANIM_OAMSET_6E", + "BATTLE_ANIM_OAMSET_6F", + "BATTLE_ANIM_OAMSET_70", + "BATTLE_ANIM_OAMSET_71", + "BATTLE_ANIM_OAMSET_72", + "BATTLE_ANIM_OAMSET_73", + "BATTLE_ANIM_OAMSET_74", + "BATTLE_ANIM_OAMSET_75", + "BATTLE_ANIM_OAMSET_76", + "BATTLE_ANIM_OAMSET_77", + "BATTLE_ANIM_OAMSET_78", + "BATTLE_ANIM_OAMSET_79", + "BATTLE_ANIM_OAMSET_7A", + "BATTLE_ANIM_OAMSET_7B", + "BATTLE_ANIM_OAMSET_7C", + "BATTLE_ANIM_OAMSET_7D", + "BATTLE_ANIM_OAMSET_7E", + "BATTLE_ANIM_OAMSET_7F", + "BATTLE_ANIM_OAMSET_80", + "BATTLE_ANIM_OAMSET_81", + "BATTLE_ANIM_OAMSET_82", + "BATTLE_ANIM_OAMSET_83", + "BATTLE_ANIM_OAMSET_84", + "BATTLE_ANIM_OAMSET_85", + "BATTLE_ANIM_OAMSET_86", + "BATTLE_ANIM_OAMSET_87", + "BATTLE_ANIM_OAMSET_88", + "BATTLE_ANIM_OAMSET_89", + "BATTLE_ANIM_OAMSET_8A", + "BATTLE_ANIM_OAMSET_8B", + "BATTLE_ANIM_OAMSET_8C", + "BATTLE_ANIM_OAMSET_8D", + "BATTLE_ANIM_OAMSET_8E", + "BATTLE_ANIM_OAMSET_8F", + "BATTLE_ANIM_OAMSET_90", + "BATTLE_ANIM_OAMSET_91", + "BATTLE_ANIM_OAMSET_92", + "BATTLE_ANIM_OAMSET_93", + "BATTLE_ANIM_OAMSET_94", + "BATTLE_ANIM_OAMSET_95", + "BATTLE_ANIM_OAMSET_96", + "BATTLE_ANIM_OAMSET_97", + "BATTLE_ANIM_OAMSET_98", + "BATTLE_ANIM_OAMSET_99", + "BATTLE_ANIM_OAMSET_9A", + "BATTLE_ANIM_OAMSET_9B", + "BATTLE_ANIM_OAMSET_9C", + "BATTLE_ANIM_OAMSET_9D", + "BATTLE_ANIM_OAMSET_9E", + "BATTLE_ANIM_OAMSET_9F", + "BATTLE_ANIM_OAMSET_A0", + "BATTLE_ANIM_OAMSET_A1", + "BATTLE_ANIM_OAMSET_A2", + "BATTLE_ANIM_OAMSET_A3", + "BATTLE_ANIM_OAMSET_A4", + "BATTLE_ANIM_OAMSET_A5", + "BATTLE_ANIM_OAMSET_A6", + "BATTLE_ANIM_OAMSET_A7", + "BATTLE_ANIM_OAMSET_A8", + "BATTLE_ANIM_OAMSET_A9", + "BATTLE_ANIM_OAMSET_AA", + "BATTLE_ANIM_OAMSET_AB", + "BATTLE_ANIM_OAMSET_AC", + "BATTLE_ANIM_OAMSET_AD", + "BATTLE_ANIM_OAMSET_AE", + "BATTLE_ANIM_OAMSET_AF", + "BATTLE_ANIM_OAMSET_B0", + "BATTLE_ANIM_OAMSET_B1", + "BATTLE_ANIM_OAMSET_B2", + "BATTLE_ANIM_OAMSET_B3", + "BATTLE_ANIM_OAMSET_B4", + "BATTLE_ANIM_OAMSET_B5", + "BATTLE_ANIM_OAMSET_B6", + "BATTLE_ANIM_OAMSET_B7", + "BATTLE_ANIM_OAMSET_B8", + "BATTLE_ANIM_OAMSET_B9", + "BATTLE_ANIM_OAMSET_BA", + "BATTLE_ANIM_OAMSET_BB", + "BATTLE_ANIM_OAMSET_BC", + "BATTLE_ANIM_OAMSET_BD", + "BATTLE_ANIM_OAMSET_BE", + "BATTLE_ANIM_OAMSET_BF", + "BATTLE_ANIM_OAMSET_C0", + "BATTLE_ANIM_OAMSET_C1", + "BATTLE_ANIM_OAMSET_C2", + "BATTLE_ANIM_OAMSET_C3", + "BATTLE_ANIM_OAMSET_C4", + "BATTLE_ANIM_OAMSET_C5", + "BATTLE_ANIM_OAMSET_C6", + "BATTLE_ANIM_OAMSET_C7", + "BATTLE_ANIM_OAMSET_C8", + "BATTLE_ANIM_OAMSET_C9", + "BATTLE_ANIM_OAMSET_CA", + "BATTLE_ANIM_OAMSET_CB", + "BATTLE_ANIM_OAMSET_CC", + "BATTLE_ANIM_OAMSET_CD", + "BATTLE_ANIM_OAMSET_CE", + "BATTLE_ANIM_OAMSET_CF", + "BATTLE_ANIM_OAMSET_D0", + "BATTLE_ANIM_OAMSET_D1", + "BATTLE_ANIM_OAMSET_D2", + "BATTLE_ANIM_OAMSET_D3", + "BATTLE_ANIM_OAMSET_D4", + "BATTLE_ANIM_OAMSET_D5", + "BATTLE_ANIM_OAMSET_D6", + "BATTLE_ANIM_OAMSET_D7" + ], + "battleAnimObPaletteOrder": [ + "PAL_BATTLE_OB_ENEMY", + "PAL_BATTLE_OB_PLAYER", + "PAL_BATTLE_OB_GRAY", + "PAL_BATTLE_OB_YELLOW", + "PAL_BATTLE_OB_RED", + "PAL_BATTLE_OB_GREEN", + "PAL_BATTLE_OB_BLUE", + "PAL_BATTLE_OB_BROWN" + ], + "battleAnimObjectOrder": [ + "BATTLE_ANIM_OBJ_HIT_BIG_YFIX", + "BATTLE_ANIM_OBJ_HIT_YFIX", + "BATTLE_ANIM_OBJ_HIT_SMALL_YFIX", + "BATTLE_ANIM_OBJ_HIT_BIG", + "BATTLE_ANIM_OBJ_HIT", + "BATTLE_ANIM_OBJ_HIT_SMALL", + "BATTLE_ANIM_OBJ_PUNCH", + "BATTLE_ANIM_OBJ_KICK", + "BATTLE_ANIM_OBJ_PALM", + "BATTLE_ANIM_OBJ_FANG", + "BATTLE_ANIM_OBJ_PUNCH_SHAKE", + "BATTLE_ANIM_OBJ_EMBER", + "BATTLE_ANIM_OBJ_DRAGON_RAGE", + "BATTLE_ANIM_OBJ_FLAMETHROWER", + "BATTLE_ANIM_OBJ_FIRE_SPIN", + "BATTLE_ANIM_OBJ_FIRE_BLAST", + "BATTLE_ANIM_OBJ_BURNED", + "BATTLE_ANIM_OBJ_BLIZZARD", + "BATTLE_ANIM_OBJ_ICE", + "BATTLE_ANIM_OBJ_ICE_BEAM", + "BATTLE_ANIM_OBJ_RAZOR_LEAF", + "BATTLE_ANIM_OBJ_POKE_BALL", + "BATTLE_ANIM_OBJ_POKE_BALL_BLOCKED", + "BATTLE_ANIM_OBJ_EXPLOSION1", + "BATTLE_ANIM_OBJ_EXPLOSION2", + "BATTLE_ANIM_OBJ_ACID", + "BATTLE_ANIM_OBJ_SLUDGE", + "BATTLE_ANIM_OBJ_BETA_BALL_POOF", + "BATTLE_ANIM_OBJ_BALL_POOF", + "BATTLE_ANIM_OBJ_BIG_ROCK", + "BATTLE_ANIM_OBJ_SMALL_ROCK", + "BATTLE_ANIM_OBJ_STRENGTH", + "BATTLE_ANIM_OBJ_SEISMIC_TOSS", + "BATTLE_ANIM_OBJ_BUBBLE", + "BATTLE_ANIM_OBJ_SURF", + "BATTLE_ANIM_OBJ_SING", + "BATTLE_ANIM_OBJ_WATER_GUN", + "BATTLE_ANIM_OBJ_HYDRO_PUMP", + "BATTLE_ANIM_OBJ_POWDER", + "BATTLE_ANIM_OBJ_BEAM", + "BATTLE_ANIM_OBJ_BEAM_TIP", + "BATTLE_ANIM_OBJ_ICE_BUILDUP", + "BATTLE_ANIM_OBJ_FROZEN", + "BATTLE_ANIM_OBJ_MASTER_BALL_SPARKLE", + "BATTLE_ANIM_OBJ_RECOVER", + "BATTLE_ANIM_OBJ_THUNDER_CENTER", + "BATTLE_ANIM_OBJ_THUNDER_LEFT", + "BATTLE_ANIM_OBJ_THUNDER_RIGHT", + "BATTLE_ANIM_OBJ_THUNDER_WAVE", + "BATTLE_ANIM_OBJ_SPARKS_CIRCLE_BIG", + "BATTLE_ANIM_OBJ_THUNDERBOLT_BALL", + "BATTLE_ANIM_OBJ_SPARKS_CIRCLE", + "BATTLE_ANIM_OBJ_THUNDERSHOCK_BALL", + "BATTLE_ANIM_OBJ_CLAMP", + "BATTLE_ANIM_OBJ_BITE", + "BATTLE_ANIM_OBJ_CUT_DOWN_LEFT", + "BATTLE_ANIM_OBJ_CUT_DOWN_RIGHT", + "BATTLE_ANIM_OBJ_CUT_UP_RIGHT", + "BATTLE_ANIM_OBJ_CUT_LONG_DOWN_LEFT", + "BATTLE_ANIM_OBJ_CUT_LONG_DOWN_RIGHT", + "BATTLE_ANIM_OBJ_SOLAR_BEAM_CHARGE", + "BATTLE_ANIM_OBJ_ABSORB_CENTER", + "BATTLE_ANIM_OBJ_GUST", + "BATTLE_ANIM_OBJ_VINE_WHIP1", + "BATTLE_ANIM_OBJ_VINE_WHIP2", + "BATTLE_ANIM_OBJ_RAZOR_WIND1", + "BATTLE_ANIM_OBJ_RAZOR_WIND2", + "BATTLE_ANIM_OBJ_SONICBOOM_JP", + "BATTLE_ANIM_OBJ_WARP", + "BATTLE_ANIM_OBJ_ABSORB", + "BATTLE_ANIM_OBJ_EGG", + "BATTLE_ANIM_OBJ_FOCUS", + "BATTLE_ANIM_OBJ_BIND1", + "BATTLE_ANIM_OBJ_BIND2", + "BATTLE_ANIM_OBJ_LEECH_SEED", + "BATTLE_ANIM_OBJ_SOUND", + "BATTLE_ANIM_OBJ_WAVE", + "BATTLE_ANIM_OBJ_CONFUSE_RAY", + "BATTLE_ANIM_OBJ_LEER", + "BATTLE_ANIM_OBJ_LEER_TIP", + "BATTLE_ANIM_OBJ_SCREEN", + "BATTLE_ANIM_OBJ_HARDEN", + "BATTLE_ANIM_OBJ_CHICK", + "BATTLE_ANIM_OBJ_AMNESIA", + "BATTLE_ANIM_OBJ_ASLEEP", + "BATTLE_ANIM_OBJ_SKULL", + "BATTLE_ANIM_OBJ_DIG_SAND", + "BATTLE_ANIM_OBJ_DIG_PILE", + "BATTLE_ANIM_OBJ_SAND", + "BATTLE_ANIM_OBJ_PARALYZED", + "BATTLE_ANIM_OBJ_STRING_SHOT", + "BATTLE_ANIM_OBJ_HAZE", + "BATTLE_ANIM_OBJ_MIST", + "BATTLE_ANIM_OBJ_SMOG", + "BATTLE_ANIM_OBJ_POISON_GAS", + "BATTLE_ANIM_OBJ_HORN", + "BATTLE_ANIM_OBJ_NEEDLE", + "BATTLE_ANIM_OBJ_PETAL_DANCE", + "BATTLE_ANIM_OBJ_SLUDGE_BOMB", + "BATTLE_ANIM_OBJ_PAY_DAY", + "BATTLE_ANIM_OBJ_SONICBOOM_JP_UNUSED", + "BATTLE_ANIM_OBJ_MIMIC", + "BATTLE_ANIM_OBJ_ATTRACT", + "BATTLE_ANIM_OBJ_BONEMERANG", + "BATTLE_ANIM_OBJ_BONE_CLUB", + "BATTLE_ANIM_OBJ_BONE_RUSH", + "BATTLE_ANIM_OBJ_SWIFT", + "BATTLE_ANIM_OBJ_KINESIS", + "BATTLE_ANIM_OBJ_FLASH", + "BATTLE_ANIM_OBJ_SHINY", + "BATTLE_ANIM_OBJ_SKY_ATTACK", + "BATTLE_ANIM_OBJ_LICK", + "BATTLE_ANIM_OBJ_WITHDRAW", + "BATTLE_ANIM_OBJ_DRAIN", + "BATTLE_ANIM_OBJ_GROWTH", + "BATTLE_ANIM_OBJ_CONVERSION2", + "BATTLE_ANIM_OBJ_SMOKE", + "BATTLE_ANIM_OBJ_SMOKESCREEN", + "BATTLE_ANIM_OBJ_SWORDS_DANCE", + "BATTLE_ANIM_OBJ_SPEED_LINE", + "BATTLE_ANIM_OBJ_SHARPEN", + "BATTLE_ANIM_OBJ_DEFENSE_CURL", + "BATTLE_ANIM_OBJ_METRONOME_HAND", + "BATTLE_ANIM_OBJ_METRONOME_SPARKLE", + "BATTLE_ANIM_OBJ_DISABLE", + "BATTLE_ANIM_OBJ_AGILITY", + "BATTLE_ANIM_OBJ_HEART", + "BATTLE_ANIM_OBJ_FLAME_WHEEL", + "BATTLE_ANIM_OBJ_SACRED_FIRE", + "BATTLE_ANIM_OBJ_COTTON_SPORE", + "BATTLE_ANIM_OBJ_MILK_DRINK", + "BATTLE_ANIM_OBJ_ANGER", + "BATTLE_ANIM_OBJ_HEAL_BELL", + "BATTLE_ANIM_OBJ_HEAL_BELL_NOTE", + "BATTLE_ANIM_OBJ_BATON_PASS", + "BATTLE_ANIM_OBJ_LOCK_ON", + "BATTLE_ANIM_OBJ_MIND_READER", + "BATTLE_ANIM_OBJ_SAFEGUARD", + "BATTLE_ANIM_OBJ_PROTECT", + "BATTLE_ANIM_OBJ_THIEF", + "BATTLE_ANIM_OBJ_OCTAZOOKA", + "BATTLE_ANIM_OBJ_PRESENT", + "BATTLE_ANIM_OBJ_SPIKES", + "BATTLE_ANIM_OBJ_POWDER_SNOW", + "BATTLE_ANIM_OBJ_DRAGONBREATH", + "BATTLE_ANIM_OBJ_CONVERSION", + "BATTLE_ANIM_OBJ_SPIDER_WEB", + "BATTLE_ANIM_OBJ_CAKE_UNUSED", + "BATTLE_ANIM_OBJ_NIGHTMARE", + "BATTLE_ANIM_OBJ_IN_NIGHTMARE", + "BATTLE_ANIM_OBJ_LOVELY_KISS", + "BATTLE_ANIM_OBJ_SWEET_KISS", + "BATTLE_ANIM_OBJ_SKETCH", + "BATTLE_ANIM_OBJ_ENCORE_HAND", + "BATTLE_ANIM_OBJ_ENCORE_STAR", + "BATTLE_ANIM_OBJ_DESTINY_BOND", + "BATTLE_ANIM_OBJ_MORNING_SUN", + "BATTLE_ANIM_OBJ_GLIMMER", + "BATTLE_ANIM_OBJ_MOONLIGHT", + "BATTLE_ANIM_OBJ_HIDDEN_POWER", + "BATTLE_ANIM_OBJ_CROSS_CHOP1", + "BATTLE_ANIM_OBJ_CROSS_CHOP2", + "BATTLE_ANIM_OBJ_SANDSTORM", + "BATTLE_ANIM_OBJ_ZAP_CANNON", + "BATTLE_ANIM_OBJ_SPITE", + "BATTLE_ANIM_OBJ_CURSE", + "BATTLE_ANIM_OBJ_PERISH_SONG", + "BATTLE_ANIM_OBJ_FORESIGHT", + "BATTLE_ANIM_OBJ_RAPID_SPIN", + "BATTLE_ANIM_OBJ_SWAGGER", + "BATTLE_ANIM_OBJ_BELLY_DRUM_HAND", + "BATTLE_ANIM_OBJ_BELLY_DRUM_NOTE", + "BATTLE_ANIM_OBJ_MEAN_LOOK", + "BATTLE_ANIM_OBJ_BETA_PURSUIT", + "BATTLE_ANIM_OBJ_SHOOTING_SPARKLE", + "BATTLE_ANIM_OBJ_RAIN", + "BATTLE_ANIM_OBJ_B0", + "BATTLE_ANIM_OBJ_PSYCH_UP", + "BATTLE_ANIM_OBJ_ANCIENTPOWER", + "BATTLE_ANIM_OBJ_AEROBLAST", + "BATTLE_ANIM_OBJ_SHADOW_BALL", + "BATTLE_ANIM_OBJ_ROCK_SMASH", + "BATTLE_ANIM_OBJ_FLOWER", + "BATTLE_ANIM_OBJ_COTTON", + "BATTLE_ANIM_OBJ_ENEMYFEET_1ROW", + "BATTLE_ANIM_OBJ_PLAYERHEAD_1ROW", + "BATTLE_ANIM_OBJ_ENEMYFEET_2ROW", + "BATTLE_ANIM_OBJ_PLAYERHEAD_2ROW" + ], + "battleBgEffectOrder": [ + "BATTLE_BG_EFFECT_END", + "BATTLE_BG_EFFECT_FLASH_INVERTED", + "BATTLE_BG_EFFECT_FLASH_WHITE", + "BATTLE_BG_EFFECT_WHITE_HUES", + "BATTLE_BG_EFFECT_BLACK_HUES", + "BATTLE_BG_EFFECT_ALTERNATE_HUES", + "BATTLE_BG_EFFECT_CYCLE_OBPALS_GRAY_AND_YELLOW", + "BATTLE_BG_EFFECT_CYCLE_MID_OBPALS_GRAY_AND_YELLOW", + "BATTLE_BG_EFFECT_CYCLE_BGPALS_INVERTED", + "BATTLE_BG_EFFECT_HIDE_MON", + "BATTLE_BG_EFFECT_SHOW_MON", + "BATTLE_BG_EFFECT_ENTER_MON", + "BATTLE_BG_EFFECT_RETURN_MON", + "BATTLE_BG_EFFECT_SURF", + "BATTLE_BG_EFFECT_WHIRLPOOL", + "BATTLE_BG_EFFECT_TELEPORT", + "BATTLE_BG_EFFECT_NIGHT_SHADE", + "BATTLE_BG_EFFECT_BATTLEROBJ_1ROW", + "BATTLE_BG_EFFECT_BATTLEROBJ_2ROW", + "BATTLE_BG_EFFECT_DOUBLE_TEAM", + "BATTLE_BG_EFFECT_ACID_ARMOR", + "BATTLE_BG_EFFECT_RAPID_FLASH", + "BATTLE_BG_EFFECT_FADE_MON_TO_LIGHT", + "BATTLE_BG_EFFECT_FADE_MON_TO_BLACK", + "BATTLE_BG_EFFECT_FADE_MON_TO_LIGHT_REPEATING", + "BATTLE_BG_EFFECT_FADE_MON_TO_BLACK_REPEATING", + "BATTLE_BG_EFFECT_CYCLE_MON_LIGHT_DARK_REPEATING", + "BATTLE_BG_EFFECT_FLASH_MON_REPEATING", + "BATTLE_BG_EFFECT_FADE_MONS_TO_BLACK_REPEATING", + "BATTLE_BG_EFFECT_FADE_MON_TO_WHITE_WAIT_FADE_BACK", + "BATTLE_BG_EFFECT_FADE_MON_FROM_WHITE", + "BATTLE_BG_EFFECT_SHAKE_SCREEN_X", + "BATTLE_BG_EFFECT_SHAKE_SCREEN_Y", + "BATTLE_BG_EFFECT_WITHDRAW", + "BATTLE_BG_EFFECT_BOUNCE_DOWN", + "BATTLE_BG_EFFECT_DIG", + "BATTLE_BG_EFFECT_TACKLE", + "BATTLE_BG_EFFECT_BODY_SLAM", + "BATTLE_BG_EFFECT_WOBBLE_MON", + "BATTLE_BG_EFFECT_REMOVE_MON", + "BATTLE_BG_EFFECT_WAVE_DEFORM_MON", + "BATTLE_BG_EFFECT_PSYCHIC", + "BATTLE_BG_EFFECT_BETA_SEND_OUT_MON1", + "BATTLE_BG_EFFECT_BETA_SEND_OUT_MON2", + "BATTLE_BG_EFFECT_FLAIL", + "BATTLE_BG_EFFECT_BETA_PURSUIT", + "BATTLE_BG_EFFECT_ROLLOUT", + "BATTLE_BG_EFFECT_VITAL_THROW", + "BATTLE_BG_EFFECT_START_WATER", + "BATTLE_BG_EFFECT_WATER", + "BATTLE_BG_EFFECT_END_WATER", + "BATTLE_BG_EFFECT_VIBRATE_MON", + "BATTLE_BG_EFFECT_WOBBLE_PLAYER", + "BATTLE_BG_EFFECT_WOBBLE_SCREEN" + ], + "cmdQueueOrder": [ + "CMDQUEUE_NULL", + "CMDQUEUE_TYPE1", + "CMDQUEUE_STONETABLE", + "CMDQUEUE_TYPE3", + "CMDQUEUE_TYPE4" + ], + "decoDescOrder": [ + "DECODESC_POSTER", + "DECODESC_LEFT_DOLL", + "DECODESC_RIGHT_DOLL", + "DECODESC_BIG_DOLL", + "DECODESC_CONSOLE" + ], + "eggGroupOrder": [ + "EGG_MONSTER", + "EGG_WATER_1", + "EGG_BUG", + "EGG_FLYING", + "EGG_GROUND", + "EGG_FAIRY", + "EGG_PLANT", + "EGG_HUMANSHAPE", + "EGG_WATER_3", + "EGG_MINERAL", + "EGG_INDETERMINATE", + "EGG_WATER_2", + "EGG_DITTO", + "EGG_DRAGON", + "EGG_NONE" + ], + "engineFlagOrder": [ + "ENGINE_RADIO_CARD", + "ENGINE_MAP_CARD", + "ENGINE_PHONE_CARD", + "ENGINE_EXPN_CARD", + "ENGINE_POKEGEAR", + "ENGINE_DAY_CARE_MAN_HAS_EGG", + "ENGINE_DAY_CARE_MAN_HAS_MON", + "ENGINE_DAY_CARE_LADY_HAS_MON", + "ENGINE_MOM_SAVING_MONEY", + "ENGINE_MOM_ACTIVE", + "ENGINE_UNUSED_TWO_DAY_TIMER_ON", + "ENGINE_POKEDEX", + "ENGINE_UNOWN_DEX", + "ENGINE_CAUGHT_POKERUS", + "ENGINE_ROCKET_SIGNAL_ON_CH20", + "ENGINE_CREDITS_SKIP", + "ENGINE_MOBILE_SYSTEM", + "ENGINE_BUG_CONTEST_TIMER", + "ENGINE_SAFARI_ZONE", + "ENGINE_ROCKETS_IN_RADIO_TOWER", + "ENGINE_BIKE_SHOP_CALL_ENABLED", + "ENGINE_UNUSED_STATUSFLAGS2_5", + "ENGINE_REACHED_GOLDENROD", + "ENGINE_ROCKETS_IN_MAHOGANY", + "ENGINE_STRENGTH_ACTIVE", + "ENGINE_ALWAYS_ON_BIKE", + "ENGINE_DOWNHILL", + "ENGINE_ZEPHYRBADGE", + "ENGINE_HIVEBADGE", + "ENGINE_PLAINBADGE", + "ENGINE_FOGBADGE", + "ENGINE_MINERALBADGE", + "ENGINE_STORMBADGE", + "ENGINE_GLACIERBADGE", + "ENGINE_RISINGBADGE", + "ENGINE_BOULDERBADGE", + "ENGINE_CASCADEBADGE", + "ENGINE_THUNDERBADGE", + "ENGINE_RAINBOWBADGE", + "ENGINE_SOULBADGE", + "ENGINE_MARSHBADGE", + "ENGINE_VOLCANOBADGE", + "ENGINE_EARTHBADGE", + "ENGINE_UNLOCKED_UNOWNS_A_TO_K", + "ENGINE_UNLOCKED_UNOWNS_L_TO_R", + "ENGINE_UNLOCKED_UNOWNS_S_TO_W", + "ENGINE_UNLOCKED_UNOWNS_X_TO_Z", + "ENGINE_UNLOCKED_UNOWNS_UNUSED_4", + "ENGINE_UNLOCKED_UNOWNS_UNUSED_5", + "ENGINE_UNLOCKED_UNOWNS_UNUSED_6", + "ENGINE_UNLOCKED_UNOWNS_UNUSED_7", + "ENGINE_FLYPOINT_PLAYERS_HOUSE", + "ENGINE_FLYPOINT_DEBUG", + "ENGINE_FLYPOINT_PALLET", + "ENGINE_FLYPOINT_VIRIDIAN", + "ENGINE_FLYPOINT_PEWTER", + "ENGINE_FLYPOINT_CERULEAN", + "ENGINE_FLYPOINT_ROCK_TUNNEL", + "ENGINE_FLYPOINT_VERMILION", + "ENGINE_FLYPOINT_LAVENDER", + "ENGINE_FLYPOINT_SAFFRON", + "ENGINE_FLYPOINT_CELADON", + "ENGINE_FLYPOINT_FUCHSIA", + "ENGINE_FLYPOINT_CINNABAR", + "ENGINE_FLYPOINT_INDIGO_PLATEAU", + "ENGINE_FLYPOINT_NEW_BARK", + "ENGINE_FLYPOINT_CHERRYGROVE", + "ENGINE_FLYPOINT_VIOLET", + "ENGINE_FLYPOINT_AZALEA", + "ENGINE_FLYPOINT_CIANWOOD", + "ENGINE_FLYPOINT_GOLDENROD", + "ENGINE_FLYPOINT_OLIVINE", + "ENGINE_FLYPOINT_ECRUTEAK", + "ENGINE_FLYPOINT_MAHOGANY", + "ENGINE_FLYPOINT_LAKE_OF_RAGE", + "ENGINE_FLYPOINT_BLACKTHORN", + "ENGINE_FLYPOINT_SILVER_CAVE", + "ENGINE_FLYPOINT_UNUSED", + "ENGINE_LUCKY_NUMBER_SHOW", + "ENGINE_UNUSED_STATUSFLAGS2_3", + "ENGINE_KURT_MAKING_BALLS", + "ENGINE_DAILY_BUG_CONTEST", + "ENGINE_QWILFISH_SWARM", + "ENGINE_TIME_CAPSULE", + "ENGINE_ALL_FRUIT_TREES", + "ENGINE_GOT_SHUCKIE_TODAY", + "ENGINE_GOLDENROD_UNDERGROUND_MERCHANT_CLOSED", + "ENGINE_FOUGHT_IN_TRAINER_HALL_TODAY", + "ENGINE_MT_MOON_SQUARE_CLEFAIRY", + "ENGINE_UNION_CAVE_LAPRAS", + "ENGINE_GOLDENROD_UNDERGROUND_GOT_HAIRCUT", + "ENGINE_GOLDENROD_DEPT_STORE_TM27_RETURN", + "ENGINE_DAISYS_GROOMING", + "ENGINE_INDIGO_PLATEAU_RIVAL_FIGHT", + "ENGINE_DAILY_MOVE_TUTOR", + "ENGINE_BUENAS_PASSWORD", + "ENGINE_BUENAS_PASSWORD_2", + "ENGINE_GOLDENROD_DEPT_STORE_SALE_IS_ON", + "ENGINE_GAME_TIMER_MOBILE", + "ENGINE_PLAYER_IS_FEMALE", + "ENGINE_FOREST_IS_RESTLESS", + "ENGINE_JACK_READY_FOR_REMATCH", + "ENGINE_HUEY_READY_FOR_REMATCH", + "ENGINE_GAVEN_READY_FOR_REMATCH", + "ENGINE_BETH_READY_FOR_REMATCH", + "ENGINE_JOSE_READY_FOR_REMATCH", + "ENGINE_REENA_READY_FOR_REMATCH", + "ENGINE_JOEY_READY_FOR_REMATCH", + "ENGINE_WADE_READY_FOR_REMATCH", + "ENGINE_RALPH_READY_FOR_REMATCH", + "ENGINE_LIZ_READY_FOR_REMATCH", + "ENGINE_ANTHONY_READY_FOR_REMATCH", + "ENGINE_TODD_READY_FOR_REMATCH", + "ENGINE_GINA_READY_FOR_REMATCH", + "ENGINE_ARNIE_READY_FOR_REMATCH", + "ENGINE_ALAN_READY_FOR_REMATCH", + "ENGINE_DANA_READY_FOR_REMATCH", + "ENGINE_CHAD_READY_FOR_REMATCH", + "ENGINE_TULLY_READY_FOR_REMATCH", + "ENGINE_BRENT_READY_FOR_REMATCH", + "ENGINE_TIFFANY_READY_FOR_REMATCH", + "ENGINE_VANCE_READY_FOR_REMATCH", + "ENGINE_WILTON_READY_FOR_REMATCH", + "ENGINE_PARRY_READY_FOR_REMATCH", + "ENGINE_ERIN_READY_FOR_REMATCH", + "ENGINE_BEVERLY_HAS_NUGGET", + "ENGINE_JOSE_HAS_STAR_PIECE", + "ENGINE_WADE_HAS_ITEM", + "ENGINE_GINA_HAS_LEAF_STONE", + "ENGINE_ALAN_HAS_FIRE_STONE", + "ENGINE_DANA_HAS_THUNDERSTONE", + "ENGINE_DEREK_HAS_NUGGET", + "ENGINE_TULLY_HAS_WATER_STONE", + "ENGINE_TIFFANY_HAS_PINK_BOW", + "ENGINE_WILTON_HAS_ITEM", + "ENGINE_JACK_MONDAY_MORNING", + "ENGINE_HUEY_WEDNESDAY_NIGHT", + "ENGINE_GAVEN_THURSDAY_MORNING", + "ENGINE_BETH_FRIDAY_AFTERNOON", + "ENGINE_JOSE_SATURDAY_NIGHT", + "ENGINE_REENA_SUNDAY_MORNING", + "ENGINE_JOEY_MONDAY_AFTERNOON", + "ENGINE_WADE_TUESDAY_NIGHT", + "ENGINE_RALPH_WEDNESDAY_MORNING", + "ENGINE_LIZ_THURSDAY_AFTERNOON", + "ENGINE_ANTHONY_FRIDAY_NIGHT", + "ENGINE_TODD_SATURDAY_MORNING", + "ENGINE_GINA_SUNDAY_AFTERNOON", + "ENGINE_ARNIE_TUESDAY_MORNING", + "ENGINE_ALAN_WEDNESDAY_AFTERNOON", + "ENGINE_DANA_THURSDAY_NIGHT", + "ENGINE_CHAD_FRIDAY_MORNING", + "ENGINE_TULLY_SUNDAY_NIGHT", + "ENGINE_BRENT_MONDAY_MORNING", + "ENGINE_TIFFANY_TUESDAY_AFTERNOON", + "ENGINE_VANCE_WEDNESDAY_NIGHT", + "ENGINE_WILTON_THURSDAY_MORNING", + "ENGINE_PARRY_FRIDAY_AFTERNOON", + "ENGINE_ERIN_SATURDAY_NIGHT", + "ENGINE_KRIS_IN_CABLE_CLUB", + "ENGINE_DUNSPARCE_SWARM", + "ENGINE_YANMA_SWARM" + ], + "environmentOrder": [ + "TOWN", + "ROUTE", + "INDOOR", + "CAVE", + "ENVIRONMENT_5", + "GATE", + "DUNGEON" + ], + "evolveMethodOrder": [ + "EVOLVE_LEVEL", + "EVOLVE_ITEM", + "EVOLVE_TRADE", + "EVOLVE_HAPPINESS", + "EVOLVE_STAT" + ], + "fishGroupOrder": [ + "FISHGROUP_NONE", + "FISHGROUP_SHORE", + "FISHGROUP_OCEAN", + "FISHGROUP_LAKE", + "FISHGROUP_POND", + "FISHGROUP_DRATINI", + "FISHGROUP_QWILFISH_SWARM", + "FISHGROUP_REMORAID_SWARM", + "FISHGROUP_GYARADOS", + "FISHGROUP_DRATINI_2", + "FISHGROUP_WHIRL_ISLANDS", + "FISHGROUP_QWILFISH", + "FISHGROUP_REMORAID", + "FISHGROUP_QWILFISH_NO_SWARM" + ], + "floorOrder": [ + "FLOOR_B4F", + "FLOOR_B3F", + "FLOOR_B2F", + "FLOOR_B1F", + "FLOOR_1F", + "FLOOR_2F", + "FLOOR_3F", + "FLOOR_4F", + "FLOOR_5F", + "FLOOR_6F", + "FLOOR_7F", + "FLOOR_8F", + "FLOOR_9F", + "FLOOR_10F", + "FLOOR_11F", + "FLOOR_ROOF" + ], + "growthRateOrder": [ + "GROWTH_MEDIUM_FAST", + "GROWTH_SLIGHTLY_FAST", + "GROWTH_SLIGHTLY_SLOW", + "GROWTH_MEDIUM_SLOW", + "GROWTH_FAST", + "GROWTH_SLOW" + ], + "heldEffectOrder": [ + "HELD_NONE", + "HELD_BERRY", + "HELD_2", + "HELD_LEFTOVERS", + "HELD_UNUSED_4", + "HELD_5", + "HELD_RESTORE_PP", + "HELD_UNUSED_7", + "HELD_CLEANSE_TAG", + "HELD_UNUSED_9", + "HELD_HEAL_POISON", + "HELD_HEAL_FREEZE", + "HELD_HEAL_BURN", + "HELD_HEAL_SLEEP", + "HELD_HEAL_PARALYZE", + "HELD_HEAL_STATUS", + "HELD_HEAL_CONFUSION", + "HELD_UNUSED_17", + "HELD_UNUSED_18", + "HELD_UNUSED_19", + "HELD_PREVENT_POISON", + "HELD_PREVENT_BURN", + "HELD_PREVENT_FREEZE", + "HELD_PREVENT_SLEEP", + "HELD_PREVENT_PARALYZE", + "HELD_PREVENT_CONFUSE", + "HELD_UNUSED_26", + "HELD_UNUSED_27", + "HELD_UNUSED_28", + "HELD_UNUSED_29", + "HELD_30", + "HELD_ATTACK_UP", + "HELD_DEFENSE_UP", + "HELD_SPEED_UP", + "HELD_SP_ATTACK_UP", + "HELD_SP_DEFENSE_UP", + "HELD_ACCURACY_UP", + "HELD_EVASION_UP", + "HELD_38", + "HELD_UNUSED_39", + "HELD_UNUSED_40", + "HELD_UNUSED_41", + "HELD_METAL_POWDER", + "HELD_UNUSED_43", + "HELD_UNUSED_44", + "HELD_UNUSED_45", + "HELD_UNUSED_46", + "HELD_UNUSED_47", + "HELD_UNUSED_48", + "HELD_UNUSED_49", + "HELD_NORMAL_BOOST", + "HELD_FIGHTING_BOOST", + "HELD_FLYING_BOOST", + "HELD_POISON_BOOST", + "HELD_GROUND_BOOST", + "HELD_ROCK_BOOST", + "HELD_BUG_BOOST", + "HELD_GHOST_BOOST", + "HELD_FIRE_BOOST", + "HELD_WATER_BOOST", + "HELD_GRASS_BOOST", + "HELD_ELECTRIC_BOOST", + "HELD_PSYCHIC_BOOST", + "HELD_ICE_BOOST", + "HELD_DRAGON_BOOST", + "HELD_DARK_BOOST", + "HELD_STEEL_BOOST", + "HELD_UNUSED_67", + "HELD_UNUSED_68", + "HELD_UNUSED_69", + "HELD_CATCH_CHANCE", + "HELD_71", + "HELD_ESCAPE", + "HELD_CRITICAL_UP", + "HELD_QUICK_CLAW", + "HELD_FLINCH", + "HELD_AMULET_COIN", + "HELD_BRIGHTPOWDER", + "HELD_UNUSED_78", + "HELD_FOCUS_BAND" + ], + "iconOrder": [ + "ICON_NULL", + "ICON_POLIWAG", + "ICON_JIGGLYPUFF", + "ICON_DIGLETT", + "ICON_PIKACHU", + "ICON_STARYU", + "ICON_FISH", + "ICON_BIRD", + "ICON_MONSTER", + "ICON_CLEFAIRY", + "ICON_ODDISH", + "ICON_BUG", + "ICON_GHOST", + "ICON_LAPRAS", + "ICON_HUMANSHAPE", + "ICON_FOX", + "ICON_EQUINE", + "ICON_SHELL", + "ICON_BLOB", + "ICON_SERPENT", + "ICON_VOLTORB", + "ICON_SQUIRTLE", + "ICON_BULBASAUR", + "ICON_CHARMANDER", + "ICON_CATERPILLAR", + "ICON_UNOWN", + "ICON_GEODUDE", + "ICON_FIGHTER", + "ICON_EGG", + "ICON_JELLYFISH", + "ICON_MOTH", + "ICON_BAT", + "ICON_SNORLAX", + "ICON_HO_OH", + "ICON_LUGIA", + "ICON_GYARADOS", + "ICON_SLOWPOKE", + "ICON_SUDOWOODO", + "ICON_BIGMON" + ], + "itemMenuOrder": [ + "ITEMMENU_NOUSE", + "ITEMMENU_CURRENT", + "ITEMMENU_PARTY", + "ITEMMENU_CLOSE" + ], + "itemNameCount": 190, + "itemOrder": [ + "MASTER_BALL", + "ULTRA_BALL", + "BRIGHTPOWDER", + "GREAT_BALL", + "POKE_BALL", + "TOWN_MAP", + "BICYCLE", + "MOON_STONE", + "ANTIDOTE", + "BURN_HEAL", + "ICE_HEAL", + "AWAKENING", + "PARLYZ_HEAL", + "FULL_RESTORE", + "MAX_POTION", + "HYPER_POTION", + "SUPER_POTION", + "POTION", + "ESCAPE_ROPE", + "REPEL", + "MAX_ELIXER", + "FIRE_STONE", + "THUNDERSTONE", + "WATER_STONE", + "ITEM_19", + "HP_UP", + "PROTEIN", + "IRON", + "CARBOS", + "LUCKY_PUNCH", + "CALCIUM", + "RARE_CANDY", + "X_ACCURACY", + "LEAF_STONE", + "METAL_POWDER", + "NUGGET", + "POKE_DOLL", + "FULL_HEAL", + "REVIVE", + "MAX_REVIVE", + "GUARD_SPEC", + "SUPER_REPEL", + "MAX_REPEL", + "DIRE_HIT", + "ITEM_2D", + "FRESH_WATER", + "SODA_POP", + "LEMONADE", + "X_ATTACK", + "ITEM_32", + "X_DEFEND", + "X_SPEED", + "X_SPECIAL", + "COIN_CASE", + "ITEMFINDER", + "POKE_FLUTE", + "EXP_SHARE", + "OLD_ROD", + "GOOD_ROD", + "SILVER_LEAF", + "SUPER_ROD", + "PP_UP", + "ETHER", + "MAX_ETHER", + "ELIXER", + "RED_SCALE", + "SECRETPOTION", + "S_S_TICKET", + "MYSTERY_EGG", + "CLEAR_BELL", + "SILVER_WING", + "MOOMOO_MILK", + "QUICK_CLAW", + "PSNCUREBERRY", + "GOLD_LEAF", + "SOFT_SAND", + "SHARP_BEAK", + "PRZCUREBERRY", + "BURNT_BERRY", + "ICE_BERRY", + "POISON_BARB", + "KINGS_ROCK", + "BITTER_BERRY", + "MINT_BERRY", + "RED_APRICORN", + "TINYMUSHROOM", + "BIG_MUSHROOM", + "SILVERPOWDER", + "BLU_APRICORN", + "ITEM_5A", + "AMULET_COIN", + "YLW_APRICORN", + "GRN_APRICORN", + "CLEANSE_TAG", + "MYSTIC_WATER", + "TWISTEDSPOON", + "WHT_APRICORN", + "BLACKBELT_I", + "BLK_APRICORN", + "ITEM_64", + "PNK_APRICORN", + "BLACKGLASSES", + "SLOWPOKETAIL", + "PINK_BOW", + "STICK", + "SMOKE_BALL", + "NEVERMELTICE", + "MAGNET", + "MIRACLEBERRY", + "PEARL", + "BIG_PEARL", + "EVERSTONE", + "SPELL_TAG", + "RAGECANDYBAR", + "GS_BALL", + "BLUE_CARD", + "MIRACLE_SEED", + "THICK_CLUB", + "FOCUS_BAND", + "ITEM_78", + "ENERGYPOWDER", + "ENERGY_ROOT", + "HEAL_POWDER", + "REVIVAL_HERB", + "HARD_STONE", + "LUCKY_EGG", + "CARD_KEY", + "MACHINE_PART", + "EGG_TICKET", + "LOST_ITEM", + "STARDUST", + "STAR_PIECE", + "BASEMENT_KEY", + "PASS", + "ITEM_87", + "ITEM_88", + "ITEM_89", + "CHARCOAL", + "BERRY_JUICE", + "SCOPE_LENS", + "ITEM_8D", + "ITEM_8E", + "METAL_COAT", + "DRAGON_FANG", + "ITEM_91", + "LEFTOVERS", + "ITEM_93", + "ITEM_94", + "ITEM_95", + "MYSTERYBERRY", + "DRAGON_SCALE", + "BERSERK_GENE", + "ITEM_99", + "ITEM_9A", + "ITEM_9B", + "SACRED_ASH", + "HEAVY_BALL", + "FLOWER_MAIL", + "LEVEL_BALL", + "LURE_BALL", + "FAST_BALL", + "ITEM_A2", + "LIGHT_BALL", + "FRIEND_BALL", + "MOON_BALL", + "LOVE_BALL", + "NORMAL_BOX", + "GORGEOUS_BOX", + "SUN_STONE", + "POLKADOT_BOW", + "ITEM_AB", + "UP_GRADE", + "BERRY", + "GOLD_BERRY", + "SQUIRTBOTTLE", + "ITEM_B0", + "PARK_BALL", + "RAINBOW_WING", + "ITEM_B3", + "BRICK_PIECE", + "SURF_MAIL", + "LITEBLUEMAIL", + "PORTRAITMAIL", + "LOVELY_MAIL", + "EON_MAIL", + "MORPH_MAIL", + "BLUESKY_MAIL", + "MUSIC_MAIL", + "MIRAGE_MAIL", + "ITEM_BE", + "TM_DYNAMICPUNCH", + "TM_HEADBUTT", + "TM_CURSE", + "TM_ROLLOUT", + "ITEM_C3", + "TM_ROAR", + "TM_TOXIC", + "TM_ZAP_CANNON", + "TM_ROCK_SMASH", + "TM_PSYCH_UP", + "TM_HIDDEN_POWER", + "TM_SUNNY_DAY", + "TM_SWEET_SCENT", + "TM_SNORE", + "TM_BLIZZARD", + "TM_HYPER_BEAM", + "TM_ICY_WIND", + "TM_PROTECT", + "TM_RAIN_DANCE", + "TM_GIGA_DRAIN", + "TM_ENDURE", + "TM_FRUSTRATION", + "TM_SOLARBEAM", + "TM_IRON_TAIL", + "TM_DRAGONBREATH", + "TM_THUNDER", + "TM_EARTHQUAKE", + "TM_RETURN", + "TM_DIG", + "ITEM_DC", + "TM_PSYCHIC_M", + "TM_SHADOW_BALL", + "TM_MUD_SLAP", + "TM_DOUBLE_TEAM", + "TM_ICE_PUNCH", + "TM_SWAGGER", + "TM_SLEEP_TALK", + "TM_SLUDGE_BOMB", + "TM_SANDSTORM", + "TM_FIRE_BLAST", + "TM_SWIFT", + "TM_DEFENSE_CURL", + "TM_THUNDERPUNCH", + "TM_DREAM_EATER", + "TM_DETECT", + "TM_REST", + "TM_ATTRACT", + "TM_THIEF", + "TM_STEEL_WING", + "TM_FIRE_PUNCH", + "TM_FURY_CUTTER", + "TM_NIGHTMARE", + "HM_CUT", + "HM_FLY", + "HM_SURF", + "HM_STRENGTH", + "HM_FLASH", + "HM_WHIRLPOOL", + "HM_WATERFALL", + "ITEM_FA" + ], + "landmarkOrder": [ + "LANDMARK_SPECIAL", + "LANDMARK_NEW_BARK_TOWN", + "LANDMARK_ROUTE_29", + "LANDMARK_CHERRYGROVE_CITY", + "LANDMARK_ROUTE_30", + "LANDMARK_ROUTE_31", + "LANDMARK_VIOLET_CITY", + "LANDMARK_SPROUT_TOWER", + "LANDMARK_ROUTE_32", + "LANDMARK_RUINS_OF_ALPH", + "LANDMARK_UNION_CAVE", + "LANDMARK_ROUTE_33", + "LANDMARK_AZALEA_TOWN", + "LANDMARK_SLOWPOKE_WELL", + "LANDMARK_ILEX_FOREST", + "LANDMARK_ROUTE_34", + "LANDMARK_GOLDENROD_CITY", + "LANDMARK_RADIO_TOWER", + "LANDMARK_ROUTE_35", + "LANDMARK_NATIONAL_PARK", + "LANDMARK_ROUTE_36", + "LANDMARK_ROUTE_37", + "LANDMARK_ECRUTEAK_CITY", + "LANDMARK_TIN_TOWER", + "LANDMARK_BURNED_TOWER", + "LANDMARK_ROUTE_38", + "LANDMARK_ROUTE_39", + "LANDMARK_OLIVINE_CITY", + "LANDMARK_LIGHTHOUSE", + "LANDMARK_BATTLE_TOWER", + "LANDMARK_ROUTE_40", + "LANDMARK_WHIRL_ISLANDS", + "LANDMARK_ROUTE_41", + "LANDMARK_CIANWOOD_CITY", + "LANDMARK_ROUTE_42", + "LANDMARK_MT_MORTAR", + "LANDMARK_MAHOGANY_TOWN", + "LANDMARK_ROUTE_43", + "LANDMARK_LAKE_OF_RAGE", + "LANDMARK_ROUTE_44", + "LANDMARK_ICE_PATH", + "LANDMARK_BLACKTHORN_CITY", + "LANDMARK_DRAGONS_DEN", + "LANDMARK_ROUTE_45", + "LANDMARK_DARK_CAVE", + "LANDMARK_ROUTE_46", + "LANDMARK_SILVER_CAVE", + "LANDMARK_PALLET_TOWN", + "LANDMARK_ROUTE_1", + "LANDMARK_VIRIDIAN_CITY", + "LANDMARK_ROUTE_2", + "LANDMARK_PEWTER_CITY", + "LANDMARK_ROUTE_3", + "LANDMARK_MT_MOON", + "LANDMARK_ROUTE_4", + "LANDMARK_CERULEAN_CITY", + "LANDMARK_ROUTE_24", + "LANDMARK_ROUTE_25", + "LANDMARK_ROUTE_5", + "LANDMARK_UNDERGROUND_PATH", + "LANDMARK_ROUTE_6", + "LANDMARK_VERMILION_CITY", + "LANDMARK_DIGLETTS_CAVE", + "LANDMARK_ROUTE_7", + "LANDMARK_ROUTE_8", + "LANDMARK_ROUTE_9", + "LANDMARK_ROCK_TUNNEL", + "LANDMARK_ROUTE_10", + "LANDMARK_POWER_PLANT", + "LANDMARK_LAVENDER_TOWN", + "LANDMARK_LAV_RADIO_TOWER", + "LANDMARK_CELADON_CITY", + "LANDMARK_SAFFRON_CITY", + "LANDMARK_ROUTE_11", + "LANDMARK_ROUTE_12", + "LANDMARK_ROUTE_13", + "LANDMARK_ROUTE_14", + "LANDMARK_ROUTE_15", + "LANDMARK_ROUTE_16", + "LANDMARK_ROUTE_17", + "LANDMARK_ROUTE_18", + "LANDMARK_FUCHSIA_CITY", + "LANDMARK_ROUTE_19", + "LANDMARK_ROUTE_20", + "LANDMARK_SEAFOAM_ISLANDS", + "LANDMARK_CINNABAR_ISLAND", + "LANDMARK_ROUTE_21", + "LANDMARK_ROUTE_22", + "LANDMARK_VICTORY_ROAD", + "LANDMARK_ROUTE_23", + "LANDMARK_INDIGO_PLATEAU", + "LANDMARK_ROUTE_26", + "LANDMARK_ROUTE_27", + "LANDMARK_TOHJO_FALLS", + "LANDMARK_ROUTE_28", + "LANDMARK_FAST_SHIP" + ], + "mapCallbackOrder": [ + "MAPCALLBACK_NONE", + "MAPCALLBACK_TILES", + "MAPCALLBACK_OBJECTS", + "MAPCALLBACK_CMDQUEUE", + "MAPCALLBACK_SPRITES", + "MAPCALLBACK_NEWMAP" + ], + "mapGroups": [ + { + "group": 1, + "height": 4, + "map": 1, + "name": "OLIVINE_POKECENTER_1F", + "width": 5 + }, + { + "group": 1, + "height": 8, + "map": 2, + "name": "OLIVINE_GYM", + "width": 5 + }, + { + "group": 1, + "height": 4, + "map": 3, + "name": "OLIVINE_TIMS_HOUSE", + "width": 4 + }, + { + "group": 1, + "height": 4, + "map": 4, + "name": "OLIVINE_HOUSE_BETA", + "width": 4 + }, + { + "group": 1, + "height": 4, + "map": 5, + "name": "OLIVINE_PUNISHMENT_SPEECH_HOUSE", + "width": 4 + }, + { + "group": 1, + "height": 4, + "map": 6, + "name": "OLIVINE_GOOD_ROD_HOUSE", + "width": 4 + }, + { + "group": 1, + "height": 4, + "map": 7, + "name": "OLIVINE_CAFE", + "width": 4 + }, + { + "group": 1, + "height": 4, + "map": 8, + "name": "OLIVINE_MART", + "width": 6 + }, + { + "group": 1, + "height": 4, + "map": 9, + "name": "ROUTE_38_ECRUTEAK_GATE", + "width": 5 + }, + { + "group": 1, + "height": 4, + "map": 10, + "name": "ROUTE_39_BARN", + "width": 4 + }, + { + "group": 1, + "height": 4, + "map": 11, + "name": "ROUTE_39_FARMHOUSE", + "width": 4 + }, + { + "group": 1, + "height": 9, + "map": 12, + "name": "ROUTE_38", + "width": 20 + }, + { + "group": 1, + "height": 18, + "map": 13, + "name": "ROUTE_39", + "width": 10 + }, + { + "group": 1, + "height": 18, + "map": 14, + "name": "OLIVINE_CITY", + "width": 20 + }, + { + "group": 2, + "height": 4, + "map": 1, + "name": "MAHOGANY_RED_GYARADOS_SPEECH_HOUSE", + "width": 4 + }, + { + "group": 2, + "height": 9, + "map": 2, + "name": "MAHOGANY_GYM", + "width": 5 + }, + { + "group": 2, + "height": 4, + "map": 3, + "name": "MAHOGANY_POKECENTER_1F", + "width": 5 + }, + { + "group": 2, + "height": 4, + "map": 4, + "name": "ROUTE_42_ECRUTEAK_GATE", + "width": 5 + }, + { + "group": 2, + "height": 9, + "map": 5, + "name": "ROUTE_42", + "width": 30 + }, + { + "group": 2, + "height": 9, + "map": 6, + "name": "ROUTE_44", + "width": 30 + }, + { + "group": 2, + "height": 9, + "map": 7, + "name": "MAHOGANY_TOWN", + "width": 10 + }, + { + "group": 3, + "height": 8, + "map": 1, + "name": "SPROUT_TOWER_1F", + "width": 10 + }, + { + "group": 3, + "height": 8, + "map": 2, + "name": "SPROUT_TOWER_2F", + "width": 10 + }, + { + "group": 3, + "height": 8, + "map": 3, + "name": "SPROUT_TOWER_3F", + "width": 10 + }, + { + "group": 3, + "height": 9, + "map": 4, + "name": "TIN_TOWER_1F", + "width": 10 + }, + { + "group": 3, + "height": 9, + "map": 5, + "name": "TIN_TOWER_2F", + "width": 10 + }, + { + "group": 3, + "height": 9, + "map": 6, + "name": "TIN_TOWER_3F", + "width": 10 + }, + { + "group": 3, + "height": 9, + "map": 7, + "name": "TIN_TOWER_4F", + "width": 10 + }, + { + "group": 3, + "height": 9, + "map": 8, + "name": "TIN_TOWER_5F", + "width": 10 + }, + { + "group": 3, + "height": 9, + "map": 9, + "name": "TIN_TOWER_6F", + "width": 10 + }, + { + "group": 3, + "height": 9, + "map": 10, + "name": "TIN_TOWER_7F", + "width": 10 + }, + { + "group": 3, + "height": 9, + "map": 11, + "name": "TIN_TOWER_8F", + "width": 10 + }, + { + "group": 3, + "height": 9, + "map": 12, + "name": "TIN_TOWER_9F", + "width": 10 + }, + { + "group": 3, + "height": 9, + "map": 13, + "name": "BURNED_TOWER_1F", + "width": 10 + }, + { + "group": 3, + "height": 9, + "map": 14, + "name": "BURNED_TOWER_B1F", + "width": 10 + }, + { + "group": 3, + "height": 27, + "map": 15, + "name": "NATIONAL_PARK", + "width": 20 + }, + { + "group": 3, + "height": 27, + "map": 16, + "name": "NATIONAL_PARK_BUG_CONTEST", + "width": 20 + }, + { + "group": 3, + "height": 4, + "map": 17, + "name": "RADIO_TOWER_1F", + "width": 9 + }, + { + "group": 3, + "height": 4, + "map": 18, + "name": "RADIO_TOWER_2F", + "width": 9 + }, + { + "group": 3, + "height": 4, + "map": 19, + "name": "RADIO_TOWER_3F", + "width": 9 + }, + { + "group": 3, + "height": 4, + "map": 20, + "name": "RADIO_TOWER_4F", + "width": 9 + }, + { + "group": 3, + "height": 4, + "map": 21, + "name": "RADIO_TOWER_5F", + "width": 9 + }, + { + "group": 3, + "height": 18, + "map": 22, + "name": "RUINS_OF_ALPH_OUTSIDE", + "width": 10 + }, + { + "group": 3, + "height": 5, + "map": 23, + "name": "RUINS_OF_ALPH_HO_OH_CHAMBER", + "width": 4 + }, + { + "group": 3, + "height": 5, + "map": 24, + "name": "RUINS_OF_ALPH_KABUTO_CHAMBER", + "width": 4 + }, + { + "group": 3, + "height": 5, + "map": 25, + "name": "RUINS_OF_ALPH_OMANYTE_CHAMBER", + "width": 4 + }, + { + "group": 3, + "height": 5, + "map": 26, + "name": "RUINS_OF_ALPH_AERODACTYL_CHAMBER", + "width": 4 + }, + { + "group": 3, + "height": 14, + "map": 27, + "name": "RUINS_OF_ALPH_INNER_CHAMBER", + "width": 10 + }, + { + "group": 3, + "height": 4, + "map": 28, + "name": "RUINS_OF_ALPH_RESEARCH_CENTER", + "width": 4 + }, + { + "group": 3, + "height": 5, + "map": 29, + "name": "RUINS_OF_ALPH_HO_OH_ITEM_ROOM", + "width": 4 + }, + { + "group": 3, + "height": 5, + "map": 30, + "name": "RUINS_OF_ALPH_KABUTO_ITEM_ROOM", + "width": 4 + }, + { + "group": 3, + "height": 5, + "map": 31, + "name": "RUINS_OF_ALPH_OMANYTE_ITEM_ROOM", + "width": 4 + }, + { + "group": 3, + "height": 5, + "map": 32, + "name": "RUINS_OF_ALPH_AERODACTYL_ITEM_ROOM", + "width": 4 + }, + { + "group": 3, + "height": 12, + "map": 33, + "name": "RUINS_OF_ALPH_HO_OH_WORD_ROOM", + "width": 10 + }, + { + "group": 3, + "height": 7, + "map": 34, + "name": "RUINS_OF_ALPH_KABUTO_WORD_ROOM", + "width": 10 + }, + { + "group": 3, + "height": 8, + "map": 35, + "name": "RUINS_OF_ALPH_OMANYTE_WORD_ROOM", + "width": 10 + }, + { + "group": 3, + "height": 7, + "map": 36, + "name": "RUINS_OF_ALPH_AERODACTYL_WORD_ROOM", + "width": 10 + }, + { + "group": 3, + "height": 18, + "map": 37, + "name": "UNION_CAVE_1F", + "width": 10 + }, + { + "group": 3, + "height": 18, + "map": 38, + "name": "UNION_CAVE_B1F", + "width": 10 + }, + { + "group": 3, + "height": 18, + "map": 39, + "name": "UNION_CAVE_B2F", + "width": 10 + }, + { + "group": 3, + "height": 9, + "map": 40, + "name": "SLOWPOKE_WELL_B1F", + "width": 10 + }, + { + "group": 3, + "height": 9, + "map": 41, + "name": "SLOWPOKE_WELL_B2F", + "width": 10 + }, + { + "group": 3, + "height": 9, + "map": 42, + "name": "OLIVINE_LIGHTHOUSE_1F", + "width": 10 + }, + { + "group": 3, + "height": 9, + "map": 43, + "name": "OLIVINE_LIGHTHOUSE_2F", + "width": 10 + }, + { + "group": 3, + "height": 9, + "map": 44, + "name": "OLIVINE_LIGHTHOUSE_3F", + "width": 10 + }, + { + "group": 3, + "height": 9, + "map": 45, + "name": "OLIVINE_LIGHTHOUSE_4F", + "width": 10 + }, + { + "group": 3, + "height": 9, + "map": 46, + "name": "OLIVINE_LIGHTHOUSE_5F", + "width": 10 + }, + { + "group": 3, + "height": 9, + "map": 47, + "name": "OLIVINE_LIGHTHOUSE_6F", + "width": 10 + }, + { + "group": 3, + "height": 4, + "map": 48, + "name": "MAHOGANY_MART_1F", + "width": 4 + }, + { + "group": 3, + "height": 9, + "map": 49, + "name": "TEAM_ROCKET_BASE_B1F", + "width": 15 + }, + { + "group": 3, + "height": 9, + "map": 50, + "name": "TEAM_ROCKET_BASE_B2F", + "width": 15 + }, + { + "group": 3, + "height": 9, + "map": 51, + "name": "TEAM_ROCKET_BASE_B3F", + "width": 15 + }, + { + "group": 3, + "height": 27, + "map": 52, + "name": "ILEX_FOREST", + "width": 15 + }, + { + "group": 3, + "height": 18, + "map": 53, + "name": "GOLDENROD_UNDERGROUND", + "width": 15 + }, + { + "group": 3, + "height": 18, + "map": 54, + "name": "GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES", + "width": 15 + }, + { + "group": 3, + "height": 9, + "map": 55, + "name": "GOLDENROD_DEPT_STORE_B1F", + "width": 10 + }, + { + "group": 3, + "height": 9, + "map": 56, + "name": "GOLDENROD_UNDERGROUND_WAREHOUSE", + "width": 10 + }, + { + "group": 3, + "height": 18, + "map": 57, + "name": "MOUNT_MORTAR_1F_OUTSIDE", + "width": 20 + }, + { + "group": 3, + "height": 27, + "map": 58, + "name": "MOUNT_MORTAR_1F_INSIDE", + "width": 20 + }, + { + "group": 3, + "height": 18, + "map": 59, + "name": "MOUNT_MORTAR_2F_INSIDE", + "width": 20 + }, + { + "group": 3, + "height": 18, + "map": 60, + "name": "MOUNT_MORTAR_B1F", + "width": 20 + }, + { + "group": 3, + "height": 18, + "map": 61, + "name": "ICE_PATH_1F", + "width": 20 + }, + { + "group": 3, + "height": 18, + "map": 62, + "name": "ICE_PATH_B1F", + "width": 10 + }, + { + "group": 3, + "height": 9, + "map": 63, + "name": "ICE_PATH_B2F_MAHOGANY_SIDE", + "width": 10 + }, + { + "group": 3, + "height": 9, + "map": 64, + "name": "ICE_PATH_B2F_BLACKTHORN_SIDE", + "width": 5 + }, + { + "group": 3, + "height": 9, + "map": 65, + "name": "ICE_PATH_B3F", + "width": 10 + }, + { + "group": 3, + "height": 9, + "map": 66, + "name": "WHIRL_ISLAND_NW", + "width": 5 + }, + { + "group": 3, + "height": 9, + "map": 67, + "name": "WHIRL_ISLAND_NE", + "width": 10 + }, + { + "group": 3, + "height": 9, + "map": 68, + "name": "WHIRL_ISLAND_SW", + "width": 10 + }, + { + "group": 3, + "height": 9, + "map": 69, + "name": "WHIRL_ISLAND_CAVE", + "width": 5 + }, + { + "group": 3, + "height": 9, + "map": 70, + "name": "WHIRL_ISLAND_SE", + "width": 5 + }, + { + "group": 3, + "height": 18, + "map": 71, + "name": "WHIRL_ISLAND_B1F", + "width": 20 + }, + { + "group": 3, + "height": 18, + "map": 72, + "name": "WHIRL_ISLAND_B2F", + "width": 10 + }, + { + "group": 3, + "height": 9, + "map": 73, + "name": "WHIRL_ISLAND_LUGIA_CHAMBER", + "width": 10 + }, + { + "group": 3, + "height": 18, + "map": 74, + "name": "SILVER_CAVE_ROOM_1", + "width": 10 + }, + { + "group": 3, + "height": 18, + "map": 75, + "name": "SILVER_CAVE_ROOM_2", + "width": 15 + }, + { + "group": 3, + "height": 18, + "map": 76, + "name": "SILVER_CAVE_ROOM_3", + "width": 10 + }, + { + "group": 3, + "height": 9, + "map": 77, + "name": "SILVER_CAVE_ITEM_ROOMS", + "width": 10 + }, + { + "group": 3, + "height": 18, + "map": 78, + "name": "DARK_CAVE_VIOLET_ENTRANCE", + "width": 20 + }, + { + "group": 3, + "height": 18, + "map": 79, + "name": "DARK_CAVE_BLACKTHORN_ENTRANCE", + "width": 15 + }, + { + "group": 3, + "height": 9, + "map": 80, + "name": "DRAGONS_DEN_1F", + "width": 5 + }, + { + "group": 3, + "height": 18, + "map": 81, + "name": "DRAGONS_DEN_B1F", + "width": 20 + }, + { + "group": 3, + "height": 5, + "map": 82, + "name": "DRAGON_SHRINE", + "width": 5 + }, + { + "group": 3, + "height": 9, + "map": 83, + "name": "TOHJO_FALLS", + "width": 15 + }, + { + "group": 3, + "height": 18, + "map": 84, + "name": "DIGLETTS_CAVE", + "width": 10 + }, + { + "group": 3, + "height": 9, + "map": 85, + "name": "MOUNT_MOON", + "width": 15 + }, + { + "group": 3, + "height": 14, + "map": 86, + "name": "UNDERGROUND_PATH", + "width": 3 + }, + { + "group": 3, + "height": 18, + "map": 87, + "name": "ROCK_TUNNEL_1F", + "width": 15 + }, + { + "group": 3, + "height": 18, + "map": 88, + "name": "ROCK_TUNNEL_B1F", + "width": 15 + }, + { + "group": 3, + "height": 4, + "map": 89, + "name": "SAFARI_ZONE_FUCHSIA_GATE_BETA", + "width": 5 + }, + { + "group": 3, + "height": 18, + "map": 90, + "name": "SAFARI_ZONE_BETA", + "width": 10 + }, + { + "group": 3, + "height": 36, + "map": 91, + "name": "VICTORY_ROAD", + "width": 10 + }, + { + "group": 4, + "height": 9, + "map": 1, + "name": "ECRUTEAK_TIN_TOWER_ENTRANCE", + "width": 10 + }, + { + "group": 4, + "height": 4, + "map": 2, + "name": "WISE_TRIOS_ROOM", + "width": 4 + }, + { + "group": 4, + "height": 4, + "map": 3, + "name": "ECRUTEAK_POKECENTER_1F", + "width": 5 + }, + { + "group": 4, + "height": 4, + "map": 4, + "name": "ECRUTEAK_LUGIA_SPEECH_HOUSE", + "width": 4 + }, + { + "group": 4, + "height": 7, + "map": 5, + "name": "DANCE_THEATER", + "width": 6 + }, + { + "group": 4, + "height": 4, + "map": 6, + "name": "ECRUTEAK_MART", + "width": 6 + }, + { + "group": 4, + "height": 9, + "map": 7, + "name": "ECRUTEAK_GYM", + "width": 5 + }, + { + "group": 4, + "height": 4, + "map": 8, + "name": "ECRUTEAK_ITEMFINDER_HOUSE", + "width": 4 + }, + { + "group": 4, + "height": 18, + "map": 9, + "name": "ECRUTEAK_CITY", + "width": 20 + }, + { + "group": 5, + "height": 9, + "map": 1, + "name": "BLACKTHORN_GYM_1F", + "width": 5 + }, + { + "group": 5, + "height": 9, + "map": 2, + "name": "BLACKTHORN_GYM_2F", + "width": 5 + }, + { + "group": 5, + "height": 4, + "map": 3, + "name": "BLACKTHORN_DRAGON_SPEECH_HOUSE", + "width": 4 + }, + { + "group": 5, + "height": 4, + "map": 4, + "name": "BLACKTHORN_EMYS_HOUSE", + "width": 4 + }, + { + "group": 5, + "height": 4, + "map": 5, + "name": "BLACKTHORN_MART", + "width": 6 + }, + { + "group": 5, + "height": 4, + "map": 6, + "name": "BLACKTHORN_POKECENTER_1F", + "width": 5 + }, + { + "group": 5, + "height": 4, + "map": 7, + "name": "MOVE_DELETERS_HOUSE", + "width": 4 + }, + { + "group": 5, + "height": 45, + "map": 8, + "name": "ROUTE_45", + "width": 10 + }, + { + "group": 5, + "height": 18, + "map": 9, + "name": "ROUTE_46", + "width": 10 + }, + { + "group": 5, + "height": 18, + "map": 10, + "name": "BLACKTHORN_CITY", + "width": 20 + }, + { + "group": 6, + "height": 4, + "map": 1, + "name": "CINNABAR_POKECENTER_1F", + "width": 5 + }, + { + "group": 6, + "height": 4, + "map": 2, + "name": "CINNABAR_POKECENTER_2F_BETA", + "width": 8 + }, + { + "group": 6, + "height": 4, + "map": 3, + "name": "ROUTE_19_FUCHSIA_GATE", + "width": 5 + }, + { + "group": 6, + "height": 4, + "map": 4, + "name": "SEAFOAM_GYM", + "width": 5 + }, + { + "group": 6, + "height": 18, + "map": 5, + "name": "ROUTE_19", + "width": 10 + }, + { + "group": 6, + "height": 9, + "map": 6, + "name": "ROUTE_20", + "width": 30 + }, + { + "group": 6, + "height": 18, + "map": 7, + "name": "ROUTE_21", + "width": 10 + }, + { + "group": 6, + "height": 9, + "map": 8, + "name": "CINNABAR_ISLAND", + "width": 10 + }, + { + "group": 7, + "height": 4, + "map": 1, + "name": "CERULEAN_GYM_BADGE_SPEECH_HOUSE", + "width": 4 + }, + { + "group": 7, + "height": 4, + "map": 2, + "name": "CERULEAN_POLICE_STATION", + "width": 4 + }, + { + "group": 7, + "height": 4, + "map": 3, + "name": "CERULEAN_TRADE_SPEECH_HOUSE", + "width": 4 + }, + { + "group": 7, + "height": 4, + "map": 4, + "name": "CERULEAN_POKECENTER_1F", + "width": 5 + }, + { + "group": 7, + "height": 4, + "map": 5, + "name": "CERULEAN_POKECENTER_2F_BETA", + "width": 8 + }, + { + "group": 7, + "height": 8, + "map": 6, + "name": "CERULEAN_GYM", + "width": 5 + }, + { + "group": 7, + "height": 4, + "map": 7, + "name": "CERULEAN_MART", + "width": 6 + }, + { + "group": 7, + "height": 4, + "map": 8, + "name": "ROUTE_10_POKECENTER_1F", + "width": 5 + }, + { + "group": 7, + "height": 4, + "map": 9, + "name": "ROUTE_10_POKECENTER_2F_BETA", + "width": 8 + }, + { + "group": 7, + "height": 9, + "map": 10, + "name": "POWER_PLANT", + "width": 10 + }, + { + "group": 7, + "height": 4, + "map": 11, + "name": "BILLS_HOUSE", + "width": 4 + }, + { + "group": 7, + "height": 9, + "map": 12, + "name": "ROUTE_4", + "width": 20 + }, + { + "group": 7, + "height": 9, + "map": 13, + "name": "ROUTE_9", + "width": 30 + }, + { + "group": 7, + "height": 9, + "map": 14, + "name": "ROUTE_10_NORTH", + "width": 10 + }, + { + "group": 7, + "height": 9, + "map": 15, + "name": "ROUTE_24", + "width": 10 + }, + { + "group": 7, + "height": 9, + "map": 16, + "name": "ROUTE_25", + "width": 30 + }, + { + "group": 7, + "height": 18, + "map": 17, + "name": "CERULEAN_CITY", + "width": 20 + }, + { + "group": 8, + "height": 4, + "map": 1, + "name": "AZALEA_POKECENTER_1F", + "width": 5 + }, + { + "group": 8, + "height": 4, + "map": 2, + "name": "CHARCOAL_KILN", + "width": 4 + }, + { + "group": 8, + "height": 4, + "map": 3, + "name": "AZALEA_MART", + "width": 6 + }, + { + "group": 8, + "height": 4, + "map": 4, + "name": "KURTS_HOUSE", + "width": 8 + }, + { + "group": 8, + "height": 8, + "map": 5, + "name": "AZALEA_GYM", + "width": 5 + }, + { + "group": 8, + "height": 9, + "map": 6, + "name": "ROUTE_33", + "width": 10 + }, + { + "group": 8, + "height": 9, + "map": 7, + "name": "AZALEA_TOWN", + "width": 20 + }, + { + "group": 9, + "height": 4, + "map": 1, + "name": "LAKE_OF_RAGE_HIDDEN_POWER_HOUSE", + "width": 4 + }, + { + "group": 9, + "height": 4, + "map": 2, + "name": "LAKE_OF_RAGE_MAGIKARP_HOUSE", + "width": 4 + }, + { + "group": 9, + "height": 4, + "map": 3, + "name": "ROUTE_43_MAHOGANY_GATE", + "width": 5 + }, + { + "group": 9, + "height": 4, + "map": 4, + "name": "ROUTE_43_GATE", + "width": 5 + }, + { + "group": 9, + "height": 27, + "map": 5, + "name": "ROUTE_43", + "width": 10 + }, + { + "group": 9, + "height": 18, + "map": 6, + "name": "LAKE_OF_RAGE", + "width": 20 + }, + { + "group": 10, + "height": 45, + "map": 1, + "name": "ROUTE_32", + "width": 10 + }, + { + "group": 10, + "height": 18, + "map": 2, + "name": "ROUTE_35", + "width": 10 + }, + { + "group": 10, + "height": 9, + "map": 3, + "name": "ROUTE_36", + "width": 30 + }, + { + "group": 10, + "height": 9, + "map": 4, + "name": "ROUTE_37", + "width": 10 + }, + { + "group": 10, + "height": 18, + "map": 5, + "name": "VIOLET_CITY", + "width": 20 + }, + { + "group": 10, + "height": 4, + "map": 6, + "name": "VIOLET_MART", + "width": 6 + }, + { + "group": 10, + "height": 8, + "map": 7, + "name": "VIOLET_GYM", + "width": 5 + }, + { + "group": 10, + "height": 8, + "map": 8, + "name": "EARLS_POKEMON_ACADEMY", + "width": 4 + }, + { + "group": 10, + "height": 4, + "map": 9, + "name": "VIOLET_NICKNAME_SPEECH_HOUSE", + "width": 4 + }, + { + "group": 10, + "height": 4, + "map": 10, + "name": "VIOLET_POKECENTER_1F", + "width": 5 + }, + { + "group": 10, + "height": 4, + "map": 11, + "name": "VIOLET_KYLES_HOUSE", + "width": 4 + }, + { + "group": 10, + "height": 4, + "map": 12, + "name": "ROUTE_32_RUINS_OF_ALPH_GATE", + "width": 5 + }, + { + "group": 10, + "height": 4, + "map": 13, + "name": "ROUTE_32_POKECENTER_1F", + "width": 5 + }, + { + "group": 10, + "height": 4, + "map": 14, + "name": "ROUTE_35_GOLDENROD_GATE", + "width": 5 + }, + { + "group": 10, + "height": 4, + "map": 15, + "name": "ROUTE_35_NATIONAL_PARK_GATE", + "width": 4 + }, + { + "group": 10, + "height": 4, + "map": 16, + "name": "ROUTE_36_RUINS_OF_ALPH_GATE", + "width": 5 + }, + { + "group": 10, + "height": 4, + "map": 17, + "name": "ROUTE_36_NATIONAL_PARK_GATE", + "width": 5 + }, + { + "group": 11, + "height": 27, + "map": 1, + "name": "ROUTE_34", + "width": 10 + }, + { + "group": 11, + "height": 18, + "map": 2, + "name": "GOLDENROD_CITY", + "width": 20 + }, + { + "group": 11, + "height": 9, + "map": 3, + "name": "GOLDENROD_GYM", + "width": 10 + }, + { + "group": 11, + "height": 4, + "map": 4, + "name": "GOLDENROD_BIKE_SHOP", + "width": 4 + }, + { + "group": 11, + "height": 4, + "map": 5, + "name": "GOLDENROD_HAPPINESS_RATER", + "width": 4 + }, + { + "group": 11, + "height": 4, + "map": 6, + "name": "BILLS_FAMILYS_HOUSE", + "width": 4 + }, + { + "group": 11, + "height": 9, + "map": 7, + "name": "GOLDENROD_MAGNET_TRAIN_STATION", + "width": 10 + }, + { + "group": 11, + "height": 4, + "map": 8, + "name": "GOLDENROD_FLOWER_SHOP", + "width": 4 + }, + { + "group": 11, + "height": 4, + "map": 9, + "name": "GOLDENROD_PP_SPEECH_HOUSE", + "width": 4 + }, + { + "group": 11, + "height": 4, + "map": 10, + "name": "GOLDENROD_NAME_RATER", + "width": 4 + }, + { + "group": 11, + "height": 4, + "map": 11, + "name": "GOLDENROD_DEPT_STORE_1F", + "width": 8 + }, + { + "group": 11, + "height": 4, + "map": 12, + "name": "GOLDENROD_DEPT_STORE_2F", + "width": 8 + }, + { + "group": 11, + "height": 4, + "map": 13, + "name": "GOLDENROD_DEPT_STORE_3F", + "width": 8 + }, + { + "group": 11, + "height": 4, + "map": 14, + "name": "GOLDENROD_DEPT_STORE_4F", + "width": 8 + }, + { + "group": 11, + "height": 4, + "map": 15, + "name": "GOLDENROD_DEPT_STORE_5F", + "width": 8 + }, + { + "group": 11, + "height": 4, + "map": 16, + "name": "GOLDENROD_DEPT_STORE_6F", + "width": 8 + }, + { + "group": 11, + "height": 2, + "map": 17, + "name": "GOLDENROD_DEPT_STORE_ELEVATOR", + "width": 2 + }, + { + "group": 11, + "height": 4, + "map": 18, + "name": "GOLDENROD_DEPT_STORE_ROOF", + "width": 8 + }, + { + "group": 11, + "height": 7, + "map": 19, + "name": "GOLDENROD_GAME_CORNER", + "width": 10 + }, + { + "group": 11, + "height": 4, + "map": 20, + "name": "GOLDENROD_POKECENTER_1F", + "width": 5 + }, + { + "group": 11, + "height": 16, + "map": 21, + "name": "POKECOM_CENTER_ADMIN_OFFICE_MOBILE", + "width": 16 + }, + { + "group": 11, + "height": 4, + "map": 22, + "name": "ILEX_FOREST_AZALEA_GATE", + "width": 5 + }, + { + "group": 11, + "height": 4, + "map": 23, + "name": "ROUTE_34_ILEX_FOREST_GATE", + "width": 5 + }, + { + "group": 11, + "height": 4, + "map": 24, + "name": "DAY_CARE", + "width": 5 + }, + { + "group": 12, + "height": 9, + "map": 1, + "name": "ROUTE_6", + "width": 10 + }, + { + "group": 12, + "height": 9, + "map": 2, + "name": "ROUTE_11", + "width": 20 + }, + { + "group": 12, + "height": 18, + "map": 3, + "name": "VERMILION_CITY", + "width": 20 + }, + { + "group": 12, + "height": 4, + "map": 4, + "name": "VERMILION_FISHING_SPEECH_HOUSE", + "width": 4 + }, + { + "group": 12, + "height": 4, + "map": 5, + "name": "VERMILION_POKECENTER_1F", + "width": 5 + }, + { + "group": 12, + "height": 4, + "map": 6, + "name": "VERMILION_POKECENTER_2F_BETA", + "width": 8 + }, + { + "group": 12, + "height": 4, + "map": 7, + "name": "POKEMON_FAN_CLUB", + "width": 5 + }, + { + "group": 12, + "height": 4, + "map": 8, + "name": "VERMILION_MAGNET_TRAIN_SPEECH_HOUSE", + "width": 4 + }, + { + "group": 12, + "height": 4, + "map": 9, + "name": "VERMILION_MART", + "width": 6 + }, + { + "group": 12, + "height": 4, + "map": 10, + "name": "VERMILION_DIGLETTS_CAVE_SPEECH_HOUSE", + "width": 4 + }, + { + "group": 12, + "height": 9, + "map": 11, + "name": "VERMILION_GYM", + "width": 5 + }, + { + "group": 12, + "height": 4, + "map": 12, + "name": "ROUTE_6_SAFFRON_GATE", + "width": 5 + }, + { + "group": 12, + "height": 4, + "map": 13, + "name": "ROUTE_6_UNDERGROUND_PATH_ENTRANCE", + "width": 4 + }, + { + "group": 13, + "height": 18, + "map": 1, + "name": "ROUTE_1", + "width": 10 + }, + { + "group": 13, + "height": 9, + "map": 2, + "name": "PALLET_TOWN", + "width": 10 + }, + { + "group": 13, + "height": 4, + "map": 3, + "name": "REDS_HOUSE_1F", + "width": 4 + }, + { + "group": 13, + "height": 4, + "map": 4, + "name": "REDS_HOUSE_2F", + "width": 4 + }, + { + "group": 13, + "height": 4, + "map": 5, + "name": "BLUES_HOUSE", + "width": 4 + }, + { + "group": 13, + "height": 6, + "map": 6, + "name": "OAKS_LAB", + "width": 5 + }, + { + "group": 14, + "height": 9, + "map": 1, + "name": "ROUTE_3", + "width": 30 + }, + { + "group": 14, + "height": 18, + "map": 2, + "name": "PEWTER_CITY", + "width": 20 + }, + { + "group": 14, + "height": 4, + "map": 3, + "name": "PEWTER_NIDORAN_SPEECH_HOUSE", + "width": 4 + }, + { + "group": 14, + "height": 7, + "map": 4, + "name": "PEWTER_GYM", + "width": 5 + }, + { + "group": 14, + "height": 4, + "map": 5, + "name": "PEWTER_MART", + "width": 6 + }, + { + "group": 14, + "height": 4, + "map": 6, + "name": "PEWTER_POKECENTER_1F", + "width": 5 + }, + { + "group": 14, + "height": 4, + "map": 7, + "name": "PEWTER_POKECENTER_2F_BETA", + "width": 8 + }, + { + "group": 14, + "height": 4, + "map": 8, + "name": "PEWTER_SNOOZE_SPEECH_HOUSE", + "width": 4 + }, + { + "group": 15, + "height": 18, + "map": 1, + "name": "OLIVINE_PORT", + "width": 10 + }, + { + "group": 15, + "height": 18, + "map": 2, + "name": "VERMILION_PORT", + "width": 10 + }, + { + "group": 15, + "height": 9, + "map": 3, + "name": "FAST_SHIP_1F", + "width": 16 + }, + { + "group": 15, + "height": 16, + "map": 4, + "name": "FAST_SHIP_CABINS_NNW_NNE_NE", + "width": 4 + }, + { + "group": 15, + "height": 16, + "map": 5, + "name": "FAST_SHIP_CABINS_SW_SSW_NW", + "width": 4 + }, + { + "group": 15, + "height": 17, + "map": 6, + "name": "FAST_SHIP_CABINS_SE_SSE_CAPTAINS_CABIN", + "width": 5 + }, + { + "group": 15, + "height": 8, + "map": 7, + "name": "FAST_SHIP_B1F", + "width": 16 + }, + { + "group": 15, + "height": 9, + "map": 8, + "name": "OLIVINE_PORT_PASSAGE", + "width": 10 + }, + { + "group": 15, + "height": 9, + "map": 9, + "name": "VERMILION_PORT_PASSAGE", + "width": 10 + }, + { + "group": 15, + "height": 9, + "map": 10, + "name": "MOUNT_MOON_SQUARE", + "width": 15 + }, + { + "group": 15, + "height": 4, + "map": 11, + "name": "MOUNT_MOON_GIFT_SHOP", + "width": 4 + }, + { + "group": 15, + "height": 9, + "map": 12, + "name": "TIN_TOWER_ROOF", + "width": 10 + }, + { + "group": 16, + "height": 9, + "map": 1, + "name": "ROUTE_23", + "width": 10 + }, + { + "group": 16, + "height": 7, + "map": 2, + "name": "INDIGO_PLATEAU_POKECENTER_1F", + "width": 9 + }, + { + "group": 16, + "height": 9, + "map": 3, + "name": "WILLS_ROOM", + "width": 5 + }, + { + "group": 16, + "height": 9, + "map": 4, + "name": "KOGAS_ROOM", + "width": 5 + }, + { + "group": 16, + "height": 9, + "map": 5, + "name": "BRUNOS_ROOM", + "width": 5 + }, + { + "group": 16, + "height": 9, + "map": 6, + "name": "KARENS_ROOM", + "width": 5 + }, + { + "group": 16, + "height": 12, + "map": 7, + "name": "LANCES_ROOM", + "width": 5 + }, + { + "group": 16, + "height": 7, + "map": 8, + "name": "HALL_OF_FAME", + "width": 5 + }, + { + "group": 17, + "height": 9, + "map": 1, + "name": "ROUTE_13", + "width": 30 + }, + { + "group": 17, + "height": 18, + "map": 2, + "name": "ROUTE_14", + "width": 10 + }, + { + "group": 17, + "height": 9, + "map": 3, + "name": "ROUTE_15", + "width": 20 + }, + { + "group": 17, + "height": 9, + "map": 4, + "name": "ROUTE_18", + "width": 10 + }, + { + "group": 17, + "height": 18, + "map": 5, + "name": "FUCHSIA_CITY", + "width": 20 + }, + { + "group": 17, + "height": 4, + "map": 6, + "name": "FUCHSIA_MART", + "width": 6 + }, + { + "group": 17, + "height": 4, + "map": 7, + "name": "SAFARI_ZONE_MAIN_OFFICE", + "width": 4 + }, + { + "group": 17, + "height": 9, + "map": 8, + "name": "FUCHSIA_GYM", + "width": 5 + }, + { + "group": 17, + "height": 4, + "map": 9, + "name": "BILLS_OLDER_SISTERS_HOUSE", + "width": 4 + }, + { + "group": 17, + "height": 4, + "map": 10, + "name": "FUCHSIA_POKECENTER_1F", + "width": 5 + }, + { + "group": 17, + "height": 4, + "map": 11, + "name": "FUCHSIA_POKECENTER_2F_BETA", + "width": 8 + }, + { + "group": 17, + "height": 4, + "map": 12, + "name": "SAFARI_ZONE_WARDENS_HOME", + "width": 5 + }, + { + "group": 17, + "height": 4, + "map": 13, + "name": "ROUTE_15_FUCHSIA_GATE", + "width": 5 + }, + { + "group": 18, + "height": 9, + "map": 1, + "name": "ROUTE_8", + "width": 20 + }, + { + "group": 18, + "height": 27, + "map": 2, + "name": "ROUTE_12", + "width": 10 + }, + { + "group": 18, + "height": 9, + "map": 3, + "name": "ROUTE_10_SOUTH", + "width": 10 + }, + { + "group": 18, + "height": 9, + "map": 4, + "name": "LAVENDER_TOWN", + "width": 10 + }, + { + "group": 18, + "height": 4, + "map": 5, + "name": "LAVENDER_POKECENTER_1F", + "width": 5 + }, + { + "group": 18, + "height": 4, + "map": 6, + "name": "LAVENDER_POKECENTER_2F_BETA", + "width": 8 + }, + { + "group": 18, + "height": 4, + "map": 7, + "name": "MR_FUJIS_HOUSE", + "width": 5 + }, + { + "group": 18, + "height": 4, + "map": 8, + "name": "LAVENDER_SPEECH_HOUSE", + "width": 4 + }, + { + "group": 18, + "height": 4, + "map": 9, + "name": "LAVENDER_NAME_RATER", + "width": 4 + }, + { + "group": 18, + "height": 4, + "map": 10, + "name": "LAVENDER_MART", + "width": 6 + }, + { + "group": 18, + "height": 4, + "map": 11, + "name": "SOUL_HOUSE", + "width": 5 + }, + { + "group": 18, + "height": 4, + "map": 12, + "name": "LAV_RADIO_TOWER_1F", + "width": 10 + }, + { + "group": 18, + "height": 4, + "map": 13, + "name": "ROUTE_8_SAFFRON_GATE", + "width": 5 + }, + { + "group": 18, + "height": 4, + "map": 14, + "name": "ROUTE_12_SUPER_ROD_HOUSE", + "width": 4 + }, + { + "group": 19, + "height": 9, + "map": 1, + "name": "ROUTE_28", + "width": 20 + }, + { + "group": 19, + "height": 18, + "map": 2, + "name": "SILVER_CAVE_OUTSIDE", + "width": 20 + }, + { + "group": 19, + "height": 4, + "map": 3, + "name": "SILVER_CAVE_POKECENTER_1F", + "width": 5 + }, + { + "group": 19, + "height": 4, + "map": 4, + "name": "ROUTE_28_STEEL_WING_HOUSE", + "width": 4 + }, + { + "group": 20, + "height": 4, + "map": 1, + "name": "POKECENTER_2F", + "width": 8 + }, + { + "group": 20, + "height": 4, + "map": 2, + "name": "TRADE_CENTER", + "width": 5 + }, + { + "group": 20, + "height": 4, + "map": 3, + "name": "COLOSSEUM", + "width": 5 + }, + { + "group": 20, + "height": 4, + "map": 4, + "name": "TIME_CAPSULE", + "width": 5 + }, + { + "group": 20, + "height": 4, + "map": 5, + "name": "MOBILE_TRADE_ROOM", + "width": 5 + }, + { + "group": 20, + "height": 4, + "map": 6, + "name": "MOBILE_BATTLE_ROOM", + "width": 5 + }, + { + "group": 21, + "height": 9, + "map": 1, + "name": "ROUTE_7", + "width": 10 + }, + { + "group": 21, + "height": 9, + "map": 2, + "name": "ROUTE_16", + "width": 10 + }, + { + "group": 21, + "height": 45, + "map": 3, + "name": "ROUTE_17", + "width": 10 + }, + { + "group": 21, + "height": 18, + "map": 4, + "name": "CELADON_CITY", + "width": 20 + }, + { + "group": 21, + "height": 4, + "map": 5, + "name": "CELADON_DEPT_STORE_1F", + "width": 8 + }, + { + "group": 21, + "height": 4, + "map": 6, + "name": "CELADON_DEPT_STORE_2F", + "width": 8 + }, + { + "group": 21, + "height": 4, + "map": 7, + "name": "CELADON_DEPT_STORE_3F", + "width": 8 + }, + { + "group": 21, + "height": 4, + "map": 8, + "name": "CELADON_DEPT_STORE_4F", + "width": 8 + }, + { + "group": 21, + "height": 4, + "map": 9, + "name": "CELADON_DEPT_STORE_5F", + "width": 8 + }, + { + "group": 21, + "height": 4, + "map": 10, + "name": "CELADON_DEPT_STORE_6F", + "width": 8 + }, + { + "group": 21, + "height": 2, + "map": 11, + "name": "CELADON_DEPT_STORE_ELEVATOR", + "width": 2 + }, + { + "group": 21, + "height": 5, + "map": 12, + "name": "CELADON_MANSION_1F", + "width": 4 + }, + { + "group": 21, + "height": 5, + "map": 13, + "name": "CELADON_MANSION_2F", + "width": 4 + }, + { + "group": 21, + "height": 5, + "map": 14, + "name": "CELADON_MANSION_3F", + "width": 4 + }, + { + "group": 21, + "height": 5, + "map": 15, + "name": "CELADON_MANSION_ROOF", + "width": 4 + }, + { + "group": 21, + "height": 4, + "map": 16, + "name": "CELADON_MANSION_ROOF_HOUSE", + "width": 4 + }, + { + "group": 21, + "height": 4, + "map": 17, + "name": "CELADON_POKECENTER_1F", + "width": 5 + }, + { + "group": 21, + "height": 4, + "map": 18, + "name": "CELADON_POKECENTER_2F_BETA", + "width": 8 + }, + { + "group": 21, + "height": 7, + "map": 19, + "name": "CELADON_GAME_CORNER", + "width": 10 + }, + { + "group": 21, + "height": 3, + "map": 20, + "name": "CELADON_GAME_CORNER_PRIZE_ROOM", + "width": 3 + }, + { + "group": 21, + "height": 9, + "map": 21, + "name": "CELADON_GYM", + "width": 5 + }, + { + "group": 21, + "height": 4, + "map": 22, + "name": "CELADON_CAFE", + "width": 6 + }, + { + "group": 21, + "height": 4, + "map": 23, + "name": "ROUTE_16_FUCHSIA_SPEECH_HOUSE", + "width": 4 + }, + { + "group": 21, + "height": 4, + "map": 24, + "name": "ROUTE_16_GATE", + "width": 5 + }, + { + "group": 21, + "height": 4, + "map": 25, + "name": "ROUTE_7_SAFFRON_GATE", + "width": 5 + }, + { + "group": 21, + "height": 4, + "map": 26, + "name": "ROUTE_17_ROUTE_18_GATE", + "width": 5 + }, + { + "group": 22, + "height": 18, + "map": 1, + "name": "ROUTE_40", + "width": 10 + }, + { + "group": 22, + "height": 27, + "map": 2, + "name": "ROUTE_41", + "width": 25 + }, + { + "group": 22, + "height": 27, + "map": 3, + "name": "CIANWOOD_CITY", + "width": 15 + }, + { + "group": 22, + "height": 4, + "map": 4, + "name": "MANIAS_HOUSE", + "width": 4 + }, + { + "group": 22, + "height": 9, + "map": 5, + "name": "CIANWOOD_GYM", + "width": 5 + }, + { + "group": 22, + "height": 4, + "map": 6, + "name": "CIANWOOD_POKECENTER_1F", + "width": 5 + }, + { + "group": 22, + "height": 4, + "map": 7, + "name": "CIANWOOD_PHARMACY", + "width": 4 + }, + { + "group": 22, + "height": 4, + "map": 8, + "name": "CIANWOOD_PHOTO_STUDIO", + "width": 4 + }, + { + "group": 22, + "height": 4, + "map": 9, + "name": "CIANWOOD_LUGIA_SPEECH_HOUSE", + "width": 4 + }, + { + "group": 22, + "height": 4, + "map": 10, + "name": "POKE_SEERS_HOUSE", + "width": 4 + }, + { + "group": 22, + "height": 5, + "map": 11, + "name": "BATTLE_TOWER_1F", + "width": 8 + }, + { + "group": 22, + "height": 4, + "map": 12, + "name": "BATTLE_TOWER_BATTLE_ROOM", + "width": 4 + }, + { + "group": 22, + "height": 2, + "map": 13, + "name": "BATTLE_TOWER_ELEVATOR", + "width": 2 + }, + { + "group": 22, + "height": 2, + "map": 14, + "name": "BATTLE_TOWER_HALLWAY", + "width": 11 + }, + { + "group": 22, + "height": 4, + "map": 15, + "name": "ROUTE_40_BATTLE_TOWER_GATE", + "width": 5 + }, + { + "group": 22, + "height": 14, + "map": 16, + "name": "BATTLE_TOWER_OUTSIDE", + "width": 10 + }, + { + "group": 23, + "height": 27, + "map": 1, + "name": "ROUTE_2", + "width": 10 + }, + { + "group": 23, + "height": 9, + "map": 2, + "name": "ROUTE_22", + "width": 20 + }, + { + "group": 23, + "height": 18, + "map": 3, + "name": "VIRIDIAN_CITY", + "width": 20 + }, + { + "group": 23, + "height": 9, + "map": 4, + "name": "VIRIDIAN_GYM", + "width": 5 + }, + { + "group": 23, + "height": 4, + "map": 5, + "name": "VIRIDIAN_NICKNAME_SPEECH_HOUSE", + "width": 4 + }, + { + "group": 23, + "height": 7, + "map": 6, + "name": "TRAINER_HOUSE_1F", + "width": 5 + }, + { + "group": 23, + "height": 8, + "map": 7, + "name": "TRAINER_HOUSE_B1F", + "width": 5 + }, + { + "group": 23, + "height": 4, + "map": 8, + "name": "VIRIDIAN_MART", + "width": 6 + }, + { + "group": 23, + "height": 4, + "map": 9, + "name": "VIRIDIAN_POKECENTER_1F", + "width": 5 + }, + { + "group": 23, + "height": 4, + "map": 10, + "name": "VIRIDIAN_POKECENTER_2F_BETA", + "width": 8 + }, + { + "group": 23, + "height": 4, + "map": 11, + "name": "ROUTE_2_NUGGET_HOUSE", + "width": 4 + }, + { + "group": 23, + "height": 4, + "map": 12, + "name": "ROUTE_2_GATE", + "width": 5 + }, + { + "group": 23, + "height": 9, + "map": 13, + "name": "VICTORY_ROAD_GATE", + "width": 10 + }, + { + "group": 24, + "height": 54, + "map": 1, + "name": "ROUTE_26", + "width": 10 + }, + { + "group": 24, + "height": 9, + "map": 2, + "name": "ROUTE_27", + "width": 40 + }, + { + "group": 24, + "height": 9, + "map": 3, + "name": "ROUTE_29", + "width": 30 + }, + { + "group": 24, + "height": 9, + "map": 4, + "name": "NEW_BARK_TOWN", + "width": 10 + }, + { + "group": 24, + "height": 6, + "map": 5, + "name": "ELMS_LAB", + "width": 5 + }, + { + "group": 24, + "height": 4, + "map": 6, + "name": "PLAYERS_HOUSE_1F", + "width": 5 + }, + { + "group": 24, + "height": 3, + "map": 7, + "name": "PLAYERS_HOUSE_2F", + "width": 4 + }, + { + "group": 24, + "height": 4, + "map": 8, + "name": "PLAYERS_NEIGHBORS_HOUSE", + "width": 4 + }, + { + "group": 24, + "height": 4, + "map": 9, + "name": "ELMS_HOUSE", + "width": 4 + }, + { + "group": 24, + "height": 4, + "map": 10, + "name": "ROUTE_26_HEAL_HOUSE", + "width": 4 + }, + { + "group": 24, + "height": 4, + "map": 11, + "name": "DAY_OF_WEEK_SIBLINGS_HOUSE", + "width": 4 + }, + { + "group": 24, + "height": 4, + "map": 12, + "name": "ROUTE_27_SANDSTORM_HOUSE", + "width": 4 + }, + { + "group": 24, + "height": 4, + "map": 13, + "name": "ROUTE_29_ROUTE_46_GATE", + "width": 5 + }, + { + "group": 25, + "height": 9, + "map": 1, + "name": "ROUTE_5", + "width": 10 + }, + { + "group": 25, + "height": 18, + "map": 2, + "name": "SAFFRON_CITY", + "width": 20 + }, + { + "group": 25, + "height": 6, + "map": 3, + "name": "FIGHTING_DOJO", + "width": 5 + }, + { + "group": 25, + "height": 9, + "map": 4, + "name": "SAFFRON_GYM", + "width": 10 + }, + { + "group": 25, + "height": 4, + "map": 5, + "name": "SAFFRON_MART", + "width": 6 + }, + { + "group": 25, + "height": 4, + "map": 6, + "name": "SAFFRON_POKECENTER_1F", + "width": 5 + }, + { + "group": 25, + "height": 4, + "map": 7, + "name": "SAFFRON_POKECENTER_2F_BETA", + "width": 8 + }, + { + "group": 25, + "height": 4, + "map": 8, + "name": "MR_PSYCHICS_HOUSE", + "width": 4 + }, + { + "group": 25, + "height": 9, + "map": 9, + "name": "SAFFRON_MAGNET_TRAIN_STATION", + "width": 10 + }, + { + "group": 25, + "height": 4, + "map": 10, + "name": "SILPH_CO_1F", + "width": 8 + }, + { + "group": 25, + "height": 4, + "map": 11, + "name": "COPYCATS_HOUSE_1F", + "width": 4 + }, + { + "group": 25, + "height": 3, + "map": 12, + "name": "COPYCATS_HOUSE_2F", + "width": 5 + }, + { + "group": 25, + "height": 4, + "map": 13, + "name": "ROUTE_5_UNDERGROUND_PATH_ENTRANCE", + "width": 4 + }, + { + "group": 25, + "height": 4, + "map": 14, + "name": "ROUTE_5_SAFFRON_GATE", + "width": 5 + }, + { + "group": 25, + "height": 4, + "map": 15, + "name": "ROUTE_5_CLEANSE_TAG_HOUSE", + "width": 4 + }, + { + "group": 26, + "height": 27, + "map": 1, + "name": "ROUTE_30", + "width": 10 + }, + { + "group": 26, + "height": 9, + "map": 2, + "name": "ROUTE_31", + "width": 20 + }, + { + "group": 26, + "height": 9, + "map": 3, + "name": "CHERRYGROVE_CITY", + "width": 20 + }, + { + "group": 26, + "height": 4, + "map": 4, + "name": "CHERRYGROVE_MART", + "width": 6 + }, + { + "group": 26, + "height": 4, + "map": 5, + "name": "CHERRYGROVE_POKECENTER_1F", + "width": 5 + }, + { + "group": 26, + "height": 4, + "map": 6, + "name": "CHERRYGROVE_GYM_SPEECH_HOUSE", + "width": 4 + }, + { + "group": 26, + "height": 4, + "map": 7, + "name": "GUIDE_GENTS_HOUSE", + "width": 4 + }, + { + "group": 26, + "height": 4, + "map": 8, + "name": "CHERRYGROVE_EVOLUTION_SPEECH_HOUSE", + "width": 4 + }, + { + "group": 26, + "height": 4, + "map": 9, + "name": "ROUTE_30_BERRY_HOUSE", + "width": 4 + }, + { + "group": 26, + "height": 4, + "map": 10, + "name": "MR_POKEMONS_HOUSE", + "width": 4 + }, + { + "group": 26, + "height": 4, + "map": 11, + "name": "ROUTE_31_VIOLET_GATE", + "width": 5 + } + ], + "mapOrder": [ + "OLIVINE_POKECENTER_1F", + "OLIVINE_GYM", + "OLIVINE_TIMS_HOUSE", + "OLIVINE_HOUSE_BETA", + "OLIVINE_PUNISHMENT_SPEECH_HOUSE", + "OLIVINE_GOOD_ROD_HOUSE", + "OLIVINE_CAFE", + "OLIVINE_MART", + "ROUTE_38_ECRUTEAK_GATE", + "ROUTE_39_BARN", + "ROUTE_39_FARMHOUSE", + "ROUTE_38", + "ROUTE_39", + "OLIVINE_CITY", + "MAHOGANY_RED_GYARADOS_SPEECH_HOUSE", + "MAHOGANY_GYM", + "MAHOGANY_POKECENTER_1F", + "ROUTE_42_ECRUTEAK_GATE", + "ROUTE_42", + "ROUTE_44", + "MAHOGANY_TOWN", + "SPROUT_TOWER_1F", + "SPROUT_TOWER_2F", + "SPROUT_TOWER_3F", + "TIN_TOWER_1F", + "TIN_TOWER_2F", + "TIN_TOWER_3F", + "TIN_TOWER_4F", + "TIN_TOWER_5F", + "TIN_TOWER_6F", + "TIN_TOWER_7F", + "TIN_TOWER_8F", + "TIN_TOWER_9F", + "BURNED_TOWER_1F", + "BURNED_TOWER_B1F", + "NATIONAL_PARK", + "NATIONAL_PARK_BUG_CONTEST", + "RADIO_TOWER_1F", + "RADIO_TOWER_2F", + "RADIO_TOWER_3F", + "RADIO_TOWER_4F", + "RADIO_TOWER_5F", + "RUINS_OF_ALPH_OUTSIDE", + "RUINS_OF_ALPH_HO_OH_CHAMBER", + "RUINS_OF_ALPH_KABUTO_CHAMBER", + "RUINS_OF_ALPH_OMANYTE_CHAMBER", + "RUINS_OF_ALPH_AERODACTYL_CHAMBER", + "RUINS_OF_ALPH_INNER_CHAMBER", + "RUINS_OF_ALPH_RESEARCH_CENTER", + "RUINS_OF_ALPH_HO_OH_ITEM_ROOM", + "RUINS_OF_ALPH_KABUTO_ITEM_ROOM", + "RUINS_OF_ALPH_OMANYTE_ITEM_ROOM", + "RUINS_OF_ALPH_AERODACTYL_ITEM_ROOM", + "RUINS_OF_ALPH_HO_OH_WORD_ROOM", + "RUINS_OF_ALPH_KABUTO_WORD_ROOM", + "RUINS_OF_ALPH_OMANYTE_WORD_ROOM", + "RUINS_OF_ALPH_AERODACTYL_WORD_ROOM", + "UNION_CAVE_1F", + "UNION_CAVE_B1F", + "UNION_CAVE_B2F", + "SLOWPOKE_WELL_B1F", + "SLOWPOKE_WELL_B2F", + "OLIVINE_LIGHTHOUSE_1F", + "OLIVINE_LIGHTHOUSE_2F", + "OLIVINE_LIGHTHOUSE_3F", + "OLIVINE_LIGHTHOUSE_4F", + "OLIVINE_LIGHTHOUSE_5F", + "OLIVINE_LIGHTHOUSE_6F", + "MAHOGANY_MART_1F", + "TEAM_ROCKET_BASE_B1F", + "TEAM_ROCKET_BASE_B2F", + "TEAM_ROCKET_BASE_B3F", + "ILEX_FOREST", + "GOLDENROD_UNDERGROUND", + "GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES", + "GOLDENROD_DEPT_STORE_B1F", + "GOLDENROD_UNDERGROUND_WAREHOUSE", + "MOUNT_MORTAR_1F_OUTSIDE", + "MOUNT_MORTAR_1F_INSIDE", + "MOUNT_MORTAR_2F_INSIDE", + "MOUNT_MORTAR_B1F", + "ICE_PATH_1F", + "ICE_PATH_B1F", + "ICE_PATH_B2F_MAHOGANY_SIDE", + "ICE_PATH_B2F_BLACKTHORN_SIDE", + "ICE_PATH_B3F", + "WHIRL_ISLAND_NW", + "WHIRL_ISLAND_NE", + "WHIRL_ISLAND_SW", + "WHIRL_ISLAND_CAVE", + "WHIRL_ISLAND_SE", + "WHIRL_ISLAND_B1F", + "WHIRL_ISLAND_B2F", + "WHIRL_ISLAND_LUGIA_CHAMBER", + "SILVER_CAVE_ROOM_1", + "SILVER_CAVE_ROOM_2", + "SILVER_CAVE_ROOM_3", + "SILVER_CAVE_ITEM_ROOMS", + "DARK_CAVE_VIOLET_ENTRANCE", + "DARK_CAVE_BLACKTHORN_ENTRANCE", + "DRAGONS_DEN_1F", + "DRAGONS_DEN_B1F", + "DRAGON_SHRINE", + "TOHJO_FALLS", + "DIGLETTS_CAVE", + "MOUNT_MOON", + "UNDERGROUND_PATH", + "ROCK_TUNNEL_1F", + "ROCK_TUNNEL_B1F", + "SAFARI_ZONE_FUCHSIA_GATE_BETA", + "SAFARI_ZONE_BETA", + "VICTORY_ROAD", + "ECRUTEAK_TIN_TOWER_ENTRANCE", + "WISE_TRIOS_ROOM", + "ECRUTEAK_POKECENTER_1F", + "ECRUTEAK_LUGIA_SPEECH_HOUSE", + "DANCE_THEATER", + "ECRUTEAK_MART", + "ECRUTEAK_GYM", + "ECRUTEAK_ITEMFINDER_HOUSE", + "ECRUTEAK_CITY", + "BLACKTHORN_GYM_1F", + "BLACKTHORN_GYM_2F", + "BLACKTHORN_DRAGON_SPEECH_HOUSE", + "BLACKTHORN_EMYS_HOUSE", + "BLACKTHORN_MART", + "BLACKTHORN_POKECENTER_1F", + "MOVE_DELETERS_HOUSE", + "ROUTE_45", + "ROUTE_46", + "BLACKTHORN_CITY", + "CINNABAR_POKECENTER_1F", + "CINNABAR_POKECENTER_2F_BETA", + "ROUTE_19_FUCHSIA_GATE", + "SEAFOAM_GYM", + "ROUTE_19", + "ROUTE_20", + "ROUTE_21", + "CINNABAR_ISLAND", + "CERULEAN_GYM_BADGE_SPEECH_HOUSE", + "CERULEAN_POLICE_STATION", + "CERULEAN_TRADE_SPEECH_HOUSE", + "CERULEAN_POKECENTER_1F", + "CERULEAN_POKECENTER_2F_BETA", + "CERULEAN_GYM", + "CERULEAN_MART", + "ROUTE_10_POKECENTER_1F", + "ROUTE_10_POKECENTER_2F_BETA", + "POWER_PLANT", + "BILLS_HOUSE", + "ROUTE_4", + "ROUTE_9", + "ROUTE_10_NORTH", + "ROUTE_24", + "ROUTE_25", + "CERULEAN_CITY", + "AZALEA_POKECENTER_1F", + "CHARCOAL_KILN", + "AZALEA_MART", + "KURTS_HOUSE", + "AZALEA_GYM", + "ROUTE_33", + "AZALEA_TOWN", + "LAKE_OF_RAGE_HIDDEN_POWER_HOUSE", + "LAKE_OF_RAGE_MAGIKARP_HOUSE", + "ROUTE_43_MAHOGANY_GATE", + "ROUTE_43_GATE", + "ROUTE_43", + "LAKE_OF_RAGE", + "ROUTE_32", + "ROUTE_35", + "ROUTE_36", + "ROUTE_37", + "VIOLET_CITY", + "VIOLET_MART", + "VIOLET_GYM", + "EARLS_POKEMON_ACADEMY", + "VIOLET_NICKNAME_SPEECH_HOUSE", + "VIOLET_POKECENTER_1F", + "VIOLET_KYLES_HOUSE", + "ROUTE_32_RUINS_OF_ALPH_GATE", + "ROUTE_32_POKECENTER_1F", + "ROUTE_35_GOLDENROD_GATE", + "ROUTE_35_NATIONAL_PARK_GATE", + "ROUTE_36_RUINS_OF_ALPH_GATE", + "ROUTE_36_NATIONAL_PARK_GATE", + "ROUTE_34", + "GOLDENROD_CITY", + "GOLDENROD_GYM", + "GOLDENROD_BIKE_SHOP", + "GOLDENROD_HAPPINESS_RATER", + "BILLS_FAMILYS_HOUSE", + "GOLDENROD_MAGNET_TRAIN_STATION", + "GOLDENROD_FLOWER_SHOP", + "GOLDENROD_PP_SPEECH_HOUSE", + "GOLDENROD_NAME_RATER", + "GOLDENROD_DEPT_STORE_1F", + "GOLDENROD_DEPT_STORE_2F", + "GOLDENROD_DEPT_STORE_3F", + "GOLDENROD_DEPT_STORE_4F", + "GOLDENROD_DEPT_STORE_5F", + "GOLDENROD_DEPT_STORE_6F", + "GOLDENROD_DEPT_STORE_ELEVATOR", + "GOLDENROD_DEPT_STORE_ROOF", + "GOLDENROD_GAME_CORNER", + "GOLDENROD_POKECENTER_1F", + "POKECOM_CENTER_ADMIN_OFFICE_MOBILE", + "ILEX_FOREST_AZALEA_GATE", + "ROUTE_34_ILEX_FOREST_GATE", + "DAY_CARE", + "ROUTE_6", + "ROUTE_11", + "VERMILION_CITY", + "VERMILION_FISHING_SPEECH_HOUSE", + "VERMILION_POKECENTER_1F", + "VERMILION_POKECENTER_2F_BETA", + "POKEMON_FAN_CLUB", + "VERMILION_MAGNET_TRAIN_SPEECH_HOUSE", + "VERMILION_MART", + "VERMILION_DIGLETTS_CAVE_SPEECH_HOUSE", + "VERMILION_GYM", + "ROUTE_6_SAFFRON_GATE", + "ROUTE_6_UNDERGROUND_PATH_ENTRANCE", + "ROUTE_1", + "PALLET_TOWN", + "REDS_HOUSE_1F", + "REDS_HOUSE_2F", + "BLUES_HOUSE", + "OAKS_LAB", + "ROUTE_3", + "PEWTER_CITY", + "PEWTER_NIDORAN_SPEECH_HOUSE", + "PEWTER_GYM", + "PEWTER_MART", + "PEWTER_POKECENTER_1F", + "PEWTER_POKECENTER_2F_BETA", + "PEWTER_SNOOZE_SPEECH_HOUSE", + "OLIVINE_PORT", + "VERMILION_PORT", + "FAST_SHIP_1F", + "FAST_SHIP_CABINS_NNW_NNE_NE", + "FAST_SHIP_CABINS_SW_SSW_NW", + "FAST_SHIP_CABINS_SE_SSE_CAPTAINS_CABIN", + "FAST_SHIP_B1F", + "OLIVINE_PORT_PASSAGE", + "VERMILION_PORT_PASSAGE", + "MOUNT_MOON_SQUARE", + "MOUNT_MOON_GIFT_SHOP", + "TIN_TOWER_ROOF", + "ROUTE_23", + "INDIGO_PLATEAU_POKECENTER_1F", + "WILLS_ROOM", + "KOGAS_ROOM", + "BRUNOS_ROOM", + "KARENS_ROOM", + "LANCES_ROOM", + "HALL_OF_FAME", + "ROUTE_13", + "ROUTE_14", + "ROUTE_15", + "ROUTE_18", + "FUCHSIA_CITY", + "FUCHSIA_MART", + "SAFARI_ZONE_MAIN_OFFICE", + "FUCHSIA_GYM", + "BILLS_OLDER_SISTERS_HOUSE", + "FUCHSIA_POKECENTER_1F", + "FUCHSIA_POKECENTER_2F_BETA", + "SAFARI_ZONE_WARDENS_HOME", + "ROUTE_15_FUCHSIA_GATE", + "ROUTE_8", + "ROUTE_12", + "ROUTE_10_SOUTH", + "LAVENDER_TOWN", + "LAVENDER_POKECENTER_1F", + "LAVENDER_POKECENTER_2F_BETA", + "MR_FUJIS_HOUSE", + "LAVENDER_SPEECH_HOUSE", + "LAVENDER_NAME_RATER", + "LAVENDER_MART", + "SOUL_HOUSE", + "LAV_RADIO_TOWER_1F", + "ROUTE_8_SAFFRON_GATE", + "ROUTE_12_SUPER_ROD_HOUSE", + "ROUTE_28", + "SILVER_CAVE_OUTSIDE", + "SILVER_CAVE_POKECENTER_1F", + "ROUTE_28_STEEL_WING_HOUSE", + "POKECENTER_2F", + "TRADE_CENTER", + "COLOSSEUM", + "TIME_CAPSULE", + "MOBILE_TRADE_ROOM", + "MOBILE_BATTLE_ROOM", + "ROUTE_7", + "ROUTE_16", + "ROUTE_17", + "CELADON_CITY", + "CELADON_DEPT_STORE_1F", + "CELADON_DEPT_STORE_2F", + "CELADON_DEPT_STORE_3F", + "CELADON_DEPT_STORE_4F", + "CELADON_DEPT_STORE_5F", + "CELADON_DEPT_STORE_6F", + "CELADON_DEPT_STORE_ELEVATOR", + "CELADON_MANSION_1F", + "CELADON_MANSION_2F", + "CELADON_MANSION_3F", + "CELADON_MANSION_ROOF", + "CELADON_MANSION_ROOF_HOUSE", + "CELADON_POKECENTER_1F", + "CELADON_POKECENTER_2F_BETA", + "CELADON_GAME_CORNER", + "CELADON_GAME_CORNER_PRIZE_ROOM", + "CELADON_GYM", + "CELADON_CAFE", + "ROUTE_16_FUCHSIA_SPEECH_HOUSE", + "ROUTE_16_GATE", + "ROUTE_7_SAFFRON_GATE", + "ROUTE_17_ROUTE_18_GATE", + "ROUTE_40", + "ROUTE_41", + "CIANWOOD_CITY", + "MANIAS_HOUSE", + "CIANWOOD_GYM", + "CIANWOOD_POKECENTER_1F", + "CIANWOOD_PHARMACY", + "CIANWOOD_PHOTO_STUDIO", + "CIANWOOD_LUGIA_SPEECH_HOUSE", + "POKE_SEERS_HOUSE", + "BATTLE_TOWER_1F", + "BATTLE_TOWER_BATTLE_ROOM", + "BATTLE_TOWER_ELEVATOR", + "BATTLE_TOWER_HALLWAY", + "ROUTE_40_BATTLE_TOWER_GATE", + "BATTLE_TOWER_OUTSIDE", + "ROUTE_2", + "ROUTE_22", + "VIRIDIAN_CITY", + "VIRIDIAN_GYM", + "VIRIDIAN_NICKNAME_SPEECH_HOUSE", + "TRAINER_HOUSE_1F", + "TRAINER_HOUSE_B1F", + "VIRIDIAN_MART", + "VIRIDIAN_POKECENTER_1F", + "VIRIDIAN_POKECENTER_2F_BETA", + "ROUTE_2_NUGGET_HOUSE", + "ROUTE_2_GATE", + "VICTORY_ROAD_GATE", + "ROUTE_26", + "ROUTE_27", + "ROUTE_29", + "NEW_BARK_TOWN", + "ELMS_LAB", + "PLAYERS_HOUSE_1F", + "PLAYERS_HOUSE_2F", + "PLAYERS_NEIGHBORS_HOUSE", + "ELMS_HOUSE", + "ROUTE_26_HEAL_HOUSE", + "DAY_OF_WEEK_SIBLINGS_HOUSE", + "ROUTE_27_SANDSTORM_HOUSE", + "ROUTE_29_ROUTE_46_GATE", + "ROUTE_5", + "SAFFRON_CITY", + "FIGHTING_DOJO", + "SAFFRON_GYM", + "SAFFRON_MART", + "SAFFRON_POKECENTER_1F", + "SAFFRON_POKECENTER_2F_BETA", + "MR_PSYCHICS_HOUSE", + "SAFFRON_MAGNET_TRAIN_STATION", + "SILPH_CO_1F", + "COPYCATS_HOUSE_1F", + "COPYCATS_HOUSE_2F", + "ROUTE_5_UNDERGROUND_PATH_ENTRANCE", + "ROUTE_5_SAFFRON_GATE", + "ROUTE_5_CLEANSE_TAG_HOUSE", + "ROUTE_30", + "ROUTE_31", + "CHERRYGROVE_CITY", + "CHERRYGROVE_MART", + "CHERRYGROVE_POKECENTER_1F", + "CHERRYGROVE_GYM_SPEECH_HOUSE", + "GUIDE_GENTS_HOUSE", + "CHERRYGROVE_EVOLUTION_SPEECH_HOUSE", + "ROUTE_30_BERRY_HOUSE", + "MR_POKEMONS_HOUSE", + "ROUTE_31_VIOLET_GATE" + ], + "moveEffectOrder": [ + "EFFECT_NORMAL_HIT", + "EFFECT_SLEEP", + "EFFECT_POISON_HIT", + "EFFECT_LEECH_HIT", + "EFFECT_BURN_HIT", + "EFFECT_FREEZE_HIT", + "EFFECT_PARALYZE_HIT", + "EFFECT_SELFDESTRUCT", + "EFFECT_DREAM_EATER", + "EFFECT_MIRROR_MOVE", + "EFFECT_ATTACK_UP", + "EFFECT_DEFENSE_UP", + "EFFECT_SPEED_UP", + "EFFECT_SP_ATK_UP", + "EFFECT_SP_DEF_UP", + "EFFECT_ACCURACY_UP", + "EFFECT_EVASION_UP", + "EFFECT_ALWAYS_HIT", + "EFFECT_ATTACK_DOWN", + "EFFECT_DEFENSE_DOWN", + "EFFECT_SPEED_DOWN", + "EFFECT_SP_ATK_DOWN", + "EFFECT_SP_DEF_DOWN", + "EFFECT_ACCURACY_DOWN", + "EFFECT_EVASION_DOWN", + "EFFECT_RESET_STATS", + "EFFECT_BIDE", + "EFFECT_RAMPAGE", + "EFFECT_FORCE_SWITCH", + "EFFECT_MULTI_HIT", + "EFFECT_CONVERSION", + "EFFECT_FLINCH_HIT", + "EFFECT_HEAL", + "EFFECT_TOXIC", + "EFFECT_PAY_DAY", + "EFFECT_LIGHT_SCREEN", + "EFFECT_TRI_ATTACK", + "EFFECT_UNUSED_25", + "EFFECT_OHKO", + "EFFECT_RAZOR_WIND", + "EFFECT_SUPER_FANG", + "EFFECT_STATIC_DAMAGE", + "EFFECT_TRAP_TARGET", + "EFFECT_UNUSED_2B", + "EFFECT_DOUBLE_HIT", + "EFFECT_JUMP_KICK", + "EFFECT_MIST", + "EFFECT_FOCUS_ENERGY", + "EFFECT_RECOIL_HIT", + "EFFECT_CONFUSE", + "EFFECT_ATTACK_UP_2", + "EFFECT_DEFENSE_UP_2", + "EFFECT_SPEED_UP_2", + "EFFECT_SP_ATK_UP_2", + "EFFECT_SP_DEF_UP_2", + "EFFECT_ACCURACY_UP_2", + "EFFECT_EVASION_UP_2", + "EFFECT_TRANSFORM", + "EFFECT_ATTACK_DOWN_2", + "EFFECT_DEFENSE_DOWN_2", + "EFFECT_SPEED_DOWN_2", + "EFFECT_SP_ATK_DOWN_2", + "EFFECT_SP_DEF_DOWN_2", + "EFFECT_ACCURACY_DOWN_2", + "EFFECT_EVASION_DOWN_2", + "EFFECT_REFLECT", + "EFFECT_POISON", + "EFFECT_PARALYZE", + "EFFECT_ATTACK_DOWN_HIT", + "EFFECT_DEFENSE_DOWN_HIT", + "EFFECT_SPEED_DOWN_HIT", + "EFFECT_SP_ATK_DOWN_HIT", + "EFFECT_SP_DEF_DOWN_HIT", + "EFFECT_ACCURACY_DOWN_HIT", + "EFFECT_EVASION_DOWN_HIT", + "EFFECT_SKY_ATTACK", + "EFFECT_CONFUSE_HIT", + "EFFECT_POISON_MULTI_HIT", + "EFFECT_UNUSED_4E", + "EFFECT_SUBSTITUTE", + "EFFECT_HYPER_BEAM", + "EFFECT_RAGE", + "EFFECT_MIMIC", + "EFFECT_METRONOME", + "EFFECT_LEECH_SEED", + "EFFECT_SPLASH", + "EFFECT_DISABLE", + "EFFECT_LEVEL_DAMAGE", + "EFFECT_PSYWAVE", + "EFFECT_COUNTER", + "EFFECT_ENCORE", + "EFFECT_PAIN_SPLIT", + "EFFECT_SNORE", + "EFFECT_CONVERSION2", + "EFFECT_LOCK_ON", + "EFFECT_SKETCH", + "EFFECT_DEFROST_OPPONENT", + "EFFECT_SLEEP_TALK", + "EFFECT_DESTINY_BOND", + "EFFECT_REVERSAL", + "EFFECT_SPITE", + "EFFECT_FALSE_SWIPE", + "EFFECT_HEAL_BELL", + "EFFECT_PRIORITY_HIT", + "EFFECT_TRIPLE_KICK", + "EFFECT_THIEF", + "EFFECT_MEAN_LOOK", + "EFFECT_NIGHTMARE", + "EFFECT_FLAME_WHEEL", + "EFFECT_CURSE", + "EFFECT_UNUSED_6E", + "EFFECT_PROTECT", + "EFFECT_SPIKES", + "EFFECT_FORESIGHT", + "EFFECT_PERISH_SONG", + "EFFECT_SANDSTORM", + "EFFECT_ENDURE", + "EFFECT_ROLLOUT", + "EFFECT_SWAGGER", + "EFFECT_FURY_CUTTER", + "EFFECT_ATTRACT", + "EFFECT_RETURN", + "EFFECT_PRESENT", + "EFFECT_FRUSTRATION", + "EFFECT_SAFEGUARD", + "EFFECT_SACRED_FIRE", + "EFFECT_MAGNITUDE", + "EFFECT_BATON_PASS", + "EFFECT_PURSUIT", + "EFFECT_RAPID_SPIN", + "EFFECT_UNUSED_82", + "EFFECT_UNUSED_83", + "EFFECT_MORNING_SUN", + "EFFECT_SYNTHESIS", + "EFFECT_MOONLIGHT", + "EFFECT_HIDDEN_POWER", + "EFFECT_RAIN_DANCE", + "EFFECT_SUNNY_DAY", + "EFFECT_DEFENSE_UP_HIT", + "EFFECT_ATTACK_UP_HIT", + "EFFECT_ALL_UP_HIT", + "EFFECT_FAKE_OUT", + "EFFECT_BELLY_DRUM", + "EFFECT_PSYCH_UP", + "EFFECT_MIRROR_COAT", + "EFFECT_SKULL_BASH", + "EFFECT_TWISTER", + "EFFECT_EARTHQUAKE", + "EFFECT_FUTURE_SIGHT", + "EFFECT_GUST", + "EFFECT_STOMP", + "EFFECT_SOLARBEAM", + "EFFECT_THUNDER", + "EFFECT_TELEPORT", + "EFFECT_BEAT_UP", + "EFFECT_FLY", + "EFFECT_DEFENSE_CURL" + ], + "moveOrder": [ + "POUND", + "KARATE_CHOP", + "DOUBLESLAP", + "COMET_PUNCH", + "MEGA_PUNCH", + "PAY_DAY", + "FIRE_PUNCH", + "ICE_PUNCH", + "THUNDERPUNCH", + "SCRATCH", + "VICEGRIP", + "GUILLOTINE", + "RAZOR_WIND", + "SWORDS_DANCE", + "CUT", + "GUST", + "WING_ATTACK", + "WHIRLWIND", + "FLY", + "BIND", + "SLAM", + "VINE_WHIP", + "STOMP", + "DOUBLE_KICK", + "MEGA_KICK", + "JUMP_KICK", + "ROLLING_KICK", + "SAND_ATTACK", + "HEADBUTT", + "HORN_ATTACK", + "FURY_ATTACK", + "HORN_DRILL", + "TACKLE", + "BODY_SLAM", + "WRAP", + "TAKE_DOWN", + "THRASH", + "DOUBLE_EDGE", + "TAIL_WHIP", + "POISON_STING", + "TWINEEDLE", + "PIN_MISSILE", + "LEER", + "BITE", + "GROWL", + "ROAR", + "SING", + "SUPERSONIC", + "SONICBOOM", + "DISABLE", + "ACID", + "EMBER", + "FLAMETHROWER", + "MIST", + "WATER_GUN", + "HYDRO_PUMP", + "SURF", + "ICE_BEAM", + "BLIZZARD", + "PSYBEAM", + "BUBBLEBEAM", + "AURORA_BEAM", + "HYPER_BEAM", + "PECK", + "DRILL_PECK", + "SUBMISSION", + "LOW_KICK", + "COUNTER", + "SEISMIC_TOSS", + "STRENGTH", + "ABSORB", + "MEGA_DRAIN", + "LEECH_SEED", + "GROWTH", + "RAZOR_LEAF", + "SOLARBEAM", + "POISONPOWDER", + "STUN_SPORE", + "SLEEP_POWDER", + "PETAL_DANCE", + "STRING_SHOT", + "DRAGON_RAGE", + "FIRE_SPIN", + "THUNDERSHOCK", + "THUNDERBOLT", + "THUNDER_WAVE", + "THUNDER", + "ROCK_THROW", + "EARTHQUAKE", + "FISSURE", + "DIG", + "TOXIC", + "CONFUSION", + "PSYCHIC_M", + "HYPNOSIS", + "MEDITATE", + "AGILITY", + "QUICK_ATTACK", + "RAGE", + "TELEPORT", + "NIGHT_SHADE", + "MIMIC", + "SCREECH", + "DOUBLE_TEAM", + "RECOVER", + "HARDEN", + "MINIMIZE", + "SMOKESCREEN", + "CONFUSE_RAY", + "WITHDRAW", + "DEFENSE_CURL", + "BARRIER", + "LIGHT_SCREEN", + "HAZE", + "REFLECT", + "FOCUS_ENERGY", + "BIDE", + "METRONOME", + "MIRROR_MOVE", + "SELFDESTRUCT", + "EGG_BOMB", + "LICK", + "SMOG", + "SLUDGE", + "BONE_CLUB", + "FIRE_BLAST", + "WATERFALL", + "CLAMP", + "SWIFT", + "SKULL_BASH", + "SPIKE_CANNON", + "CONSTRICT", + "AMNESIA", + "KINESIS", + "SOFTBOILED", + "HI_JUMP_KICK", + "GLARE", + "DREAM_EATER", + "POISON_GAS", + "BARRAGE", + "LEECH_LIFE", + "LOVELY_KISS", + "SKY_ATTACK", + "TRANSFORM", + "BUBBLE", + "DIZZY_PUNCH", + "SPORE", + "FLASH", + "PSYWAVE", + "SPLASH", + "ACID_ARMOR", + "CRABHAMMER", + "EXPLOSION", + "FURY_SWIPES", + "BONEMERANG", + "REST", + "ROCK_SLIDE", + "HYPER_FANG", + "SHARPEN", + "CONVERSION", + "TRI_ATTACK", + "SUPER_FANG", + "SLASH", + "SUBSTITUTE", + "STRUGGLE", + "SKETCH", + "TRIPLE_KICK", + "THIEF", + "SPIDER_WEB", + "MIND_READER", + "NIGHTMARE", + "FLAME_WHEEL", + "SNORE", + "CURSE", + "FLAIL", + "CONVERSION2", + "AEROBLAST", + "COTTON_SPORE", + "REVERSAL", + "SPITE", + "POWDER_SNOW", + "PROTECT", + "MACH_PUNCH", + "SCARY_FACE", + "FAINT_ATTACK", + "SWEET_KISS", + "BELLY_DRUM", + "SLUDGE_BOMB", + "MUD_SLAP", + "OCTAZOOKA", + "SPIKES", + "ZAP_CANNON", + "FORESIGHT", + "DESTINY_BOND", + "PERISH_SONG", + "ICY_WIND", + "DETECT", + "BONE_RUSH", + "LOCK_ON", + "OUTRAGE", + "SANDSTORM", + "GIGA_DRAIN", + "ENDURE", + "CHARM", + "ROLLOUT", + "FALSE_SWIPE", + "SWAGGER", + "MILK_DRINK", + "SPARK", + "FURY_CUTTER", + "STEEL_WING", + "MEAN_LOOK", + "ATTRACT", + "SLEEP_TALK", + "HEAL_BELL", + "RETURN", + "PRESENT", + "FRUSTRATION", + "SAFEGUARD", + "PAIN_SPLIT", + "SACRED_FIRE", + "MAGNITUDE", + "DYNAMICPUNCH", + "MEGAHORN", + "DRAGONBREATH", + "BATON_PASS", + "ENCORE", + "PURSUIT", + "RAPID_SPIN", + "SWEET_SCENT", + "IRON_TAIL", + "METAL_CLAW", + "VITAL_THROW", + "MORNING_SUN", + "SYNTHESIS", + "MOONLIGHT", + "HIDDEN_POWER", + "CROSS_CHOP", + "TWISTER", + "RAIN_DANCE", + "SUNNY_DAY", + "CRUNCH", + "MIRROR_COAT", + "PSYCH_UP", + "EXTREMESPEED", + "ANCIENTPOWER", + "SHADOW_BALL", + "FUTURE_SIGHT", + "ROCK_SMASH", + "WHIRLPOOL", + "BEAT_UP" + ], + "musicOrder": [ + "Music_Nothing", + "Music_TitleScreen", + "Music_Route1", + "Music_Route3", + "Music_Route12", + "Music_MagnetTrain", + "Music_KantoGymBattle", + "Music_KantoTrainerBattle", + "Music_KantoWildBattle", + "Music_PokemonCenter", + "Music_LookHiker", + "Music_LookLass", + "Music_LookOfficer", + "Music_HealPokemon", + "Music_LavenderTown", + "Music_Route2", + "Music_MtMoon", + "Music_ShowMeAround", + "Music_GameCorner", + "Music_Bicycle", + "Music_HallOfFame", + "Music_ViridianCity", + "Music_CeladonCity", + "Music_TrainerVictory", + "Music_WildPokemonVictory", + "Music_GymLeaderVictory", + "Music_MtMoonSquare", + "Music_Gym", + "Music_PalletTown", + "Music_ProfOaksPokemonTalk", + "Music_ProfOak", + "Music_LookRival", + "Music_AfterTheRivalFight", + "Music_Surf", + "Music_Evolution", + "Music_NationalPark", + "Music_Credits", + "Music_AzaleaTown", + "Music_CherrygroveCity", + "Music_LookKimonoGirl", + "Music_UnionCave", + "Music_JohtoWildBattle", + "Music_JohtoTrainerBattle", + "Music_Route30", + "Music_EcruteakCity", + "Music_VioletCity", + "Music_JohtoGymBattle", + "Music_ChampionBattle", + "Music_RivalBattle", + "Music_RocketBattle", + "Music_ElmsLab", + "Music_DarkCave", + "Music_Route29", + "Music_Route36", + "Music_SSAqua", + "Music_LookYoungster", + "Music_LookBeauty", + "Music_LookRocket", + "Music_LookPokemaniac", + "Music_LookSage", + "Music_NewBarkTown", + "Music_GoldenrodCity", + "Music_VermilionCity", + "Music_PokemonChannel", + "Music_PokeFluteChannel", + "Music_TinTower", + "Music_SproutTower", + "Music_BurnedTower", + "Music_Lighthouse", + "Music_LakeOfRage", + "Music_IndigoPlateau", + "Music_Route37", + "Music_RocketHideout", + "Music_DragonsDen", + "Music_JohtoWildBattleNight", + "Music_RuinsOfAlphRadio", + "Music_SuccessfulCapture", + "Music_Route26", + "Music_Mom", + "Music_VictoryRoad", + "Music_PokemonLullaby", + "Music_PokemonMarch", + "Music_GoldSilverOpening", + "Music_GoldSilverOpening2", + "Music_MainMenu", + "Music_RuinsOfAlphInterior", + "Music_RocketTheme", + "Music_DancingHall", + "Music_ContestResults", + "Music_BugCatchingContest", + "Music_LakeOfRageRocketRadio", + "Music_Printer", + "Music_PostCredits", + "Music_Clair", + "Music_MobileAdapterMenu", + "Music_MobileAdapter", + "Music_BuenasPassword", + "Music_LookMysticalMan", + "Music_CrystalOpening", + "Music_BattleTowerTheme", + "Music_SuicuneBattle", + "Music_BattleTowerLobby", + "Music_MobileCenter" + ], + "numOverworldSprites": 102, + "paletteOrder": [ + "PALETTE_AUTO", + "PALETTE_DAY", + "PALETTE_NITE", + "PALETTE_MORN", + "PALETTE_DARK" + ], + "phoneContactOrder": [ + "PHONE_00", + "PHONE_MOM", + "PHONE_OAK", + "PHONE_BILL", + "PHONE_ELM", + "PHONE_SCHOOLBOY_JACK", + "PHONE_POKEFAN_BEVERLY", + "PHONE_SAILOR_HUEY", + "PHONE_UNUSED", + "PHONE_UNUSED", + "PHONE_UNUSED", + "PHONE_COOLTRAINERM_GAVEN", + "PHONE_COOLTRAINERF_BETH", + "PHONE_BIRDKEEPER_JOSE", + "PHONE_COOLTRAINERF_REENA", + "PHONE_YOUNGSTER_JOEY", + "PHONE_BUG_CATCHER_WADE", + "PHONE_FISHER_RALPH", + "PHONE_PICNICKER_LIZ", + "PHONE_HIKER_ANTHONY", + "PHONE_CAMPER_TODD", + "PHONE_PICNICKER_GINA", + "PHONE_JUGGLER_IRWIN", + "PHONE_BUG_CATCHER_ARNIE", + "PHONE_SCHOOLBOY_ALAN", + "PHONE_UNUSED", + "PHONE_LASS_DANA", + "PHONE_SCHOOLBOY_CHAD", + "PHONE_POKEFANM_DEREK", + "PHONE_FISHER_TULLY", + "PHONE_POKEMANIAC_BRENT", + "PHONE_PICNICKER_TIFFANY", + "PHONE_BIRDKEEPER_VANCE", + "PHONE_FISHER_WILTON", + "PHONE_BLACKBELT_KENJI", + "PHONE_HIKER_PARRY", + "PHONE_PICNICKER_ERIN", + "PHONE_BUENA" + ], + "pocketOrder": [ + "ITEM", + "KEY_ITEM", + "BALL", + "TM_HM" + ], + "sfxOrder": [ + "Sfx_DexFanfare5079", + "Sfx_Item", + "Sfx_CaughtMon", + "Sfx_PokeballsPlacedOnTable", + "Sfx_Potion", + "Sfx_FullHeal", + "Sfx_Menu", + "Sfx_ReadText", + "Sfx_ReadText2", + "Sfx_DexFanfare2049", + "Sfx_DexFanfare80109", + "Sfx_Poison", + "Sfx_GotSafariBalls", + "Sfx_BootPc", + "Sfx_ShutDownPc", + "Sfx_ChoosePcOption", + "Sfx_EscapeRope", + "Sfx_PushButton", + "Sfx_SecondPartOfItemfinder", + "Sfx_WarpTo", + "Sfx_WarpFrom", + "Sfx_ChangeDexMode", + "Sfx_JumpOverLedge", + "Sfx_GrassRustle", + "Sfx_Fly", + "Sfx_Wrong", + "Sfx_Squeak", + "Sfx_Strength", + "Sfx_Boat", + "Sfx_WallOpen", + "Sfx_PlacePuzzlePieceDown", + "Sfx_EnterDoor", + "Sfx_SwitchPokemon", + "Sfx_Tally", + "Sfx_Transaction", + "Sfx_ExitBuilding", + "Sfx_Bump", + "Sfx_Save", + "Sfx_Pokeflute", + "Sfx_ElevatorEnd", + "Sfx_ThrowBall", + "Sfx_BallPoof", + "Sfx_Faint", + "Sfx_Run", + "Sfx_SlotMachineStart", + "Sfx_Fanfare", + "Sfx_Peck", + "Sfx_Kinesis", + "Sfx_Lick", + "Sfx_Pound", + "Sfx_MovePuzzlePiece", + "Sfx_CometPunch", + "Sfx_MegaPunch", + "Sfx_Scratch", + "Sfx_Vicegrip", + "Sfx_RazorWind", + "Sfx_Cut", + "Sfx_WingAttack", + "Sfx_Whirlwind", + "Sfx_Bind", + "Sfx_VineWhip", + "Sfx_DoubleKick", + "Sfx_MegaKick", + "Sfx_Headbutt", + "Sfx_HornAttack", + "Sfx_Tackle", + "Sfx_PoisonSting", + "Sfx_Powder", + "Sfx_Doubleslap", + "Sfx_Bite", + "Sfx_JumpKick", + "Sfx_Stomp", + "Sfx_TailWhip", + "Sfx_KarateChop", + "Sfx_Submission", + "Sfx_WaterGun", + "Sfx_SwordsDance", + "Sfx_Thunder", + "Sfx_Supersonic", + "Sfx_Leer", + "Sfx_Ember", + "Sfx_Bubblebeam", + "Sfx_HydroPump", + "Sfx_Surf", + "Sfx_Psybeam", + "Sfx_Charge", + "Sfx_Thundershock", + "Sfx_Psychic", + "Sfx_Screech", + "Sfx_BoneClub", + "Sfx_Sharpen", + "Sfx_EggBomb", + "Sfx_Sing", + "Sfx_HyperBeam", + "Sfx_Shine", + "Sfx_Unknown5F", + "Sfx_Unknown60", + "Sfx_Unknown61", + "Sfx_SwitchPockets", + "Sfx_Unknown63", + "Sfx_Burn", + "Sfx_TitleScreenEntrance", + "Sfx_Unknown66", + "Sfx_GetCoinFromSlots", + "Sfx_PayDay", + "Sfx_Metronome", + "Sfx_Call", + "Sfx_HangUp", + "Sfx_NoSignal", + "Sfx_Sandstorm", + "Sfx_Elevator", + "Sfx_Protect", + "Sfx_Sketch", + "Sfx_RainDance", + "Sfx_Aeroblast", + "Sfx_Spark", + "Sfx_Curse", + "Sfx_Rage", + "Sfx_Thief", + "Sfx_Thief2", + "Sfx_SpiderWeb", + "Sfx_MindReader", + "Sfx_Nightmare", + "Sfx_Snore", + "Sfx_SweetKiss", + "Sfx_SweetKiss2", + "Sfx_BellyDrum", + "Sfx_Toxic", + "Sfx_SludgeBomb", + "Sfx_Foresight", + "Sfx_Spite", + "Sfx_Outrage", + "Sfx_PerishSong", + "Sfx_GigaDrain", + "Sfx_Attract", + "Sfx_Kinesis2", + "Sfx_ZapCannon", + "Sfx_MeanLook", + "Sfx_HealBell", + "Sfx_Return", + "Sfx_ExpBar", + "Sfx_MilkDrink", + "Sfx_Present", + "Sfx_MorningSun", + "Sfx_LevelUp", + "Sfx_KeyItem", + "Sfx_Fanfare2", + "Sfx_RegisterPhoneNumber", + "Sfx_3rdPlace", + "Sfx_GetEgg", + "Sfx_GetEgg", + "Sfx_MoveDeleted", + "Sfx_2ndPlace", + "Sfx_1stPlace", + "Sfx_ChooseACard", + "Sfx_GetTm", + "Sfx_GetBadge", + "Sfx_QuitSlots", + "Sfx_EggCrack", + "Sfx_DexFanfareLessThan20", + "Sfx_DexFanfare140169", + "Sfx_DexFanfare170199", + "Sfx_DexFanfare200229", + "Sfx_DexFanfare230Plus", + "Sfx_Evolved", + "Sfx_MasterBall", + "Sfx_EggHatch", + "Sfx_GsIntroCharizardFireball", + "Sfx_GsIntroPokemonAppears", + "Sfx_Flash", + "Sfx_GameFreakLogoGs", + "Sfx_NotVeryEffective", + "Sfx_Damage", + "Sfx_SuperEffective", + "Sfx_BallBounce", + "Sfx_Moonlight", + "Sfx_Encore", + "Sfx_BeatUp", + "Sfx_BatonPass", + "Sfx_BallWobble", + "Sfx_SweetScent", + "Sfx_SweetScent2", + "Sfx_HitEndOfExpBar", + "Sfx_GiveTrademon", + "Sfx_GetTrademon", + "Sfx_TrainArrived", + "Sfx_StopSlot", + "Sfx_2Boops", + "Sfx_GlassTing", + "Sfx_GlassTing2", + "Sfx_IntroUnown1", + "Sfx_IntroUnown2", + "Sfx_IntroUnown3", + "Sfx_DittoPopUp", + "Sfx_DittoTransform", + "Sfx_IntroSuicune1", + "Sfx_IntroPichu", + "Sfx_IntroSuicune2", + "Sfx_IntroSuicune3", + "Sfx_DittoBounce", + "Sfx_IntroSuicune4", + "Sfx_GameFreakPresents", + "Sfx_Tingle", + "Sfx_IntroWhoosh", + "Sfx_TwoPcBeeps", + "Sfx_4NoteDitty", + "Sfx_Twinkle" + ], + "source": "pret/pokegold constants/*.asm", + "spawnOrder": [ + "SPAWN_HOME", + "SPAWN_DEBUG", + "SPAWN_PALLET", + "SPAWN_VIRIDIAN", + "SPAWN_PEWTER", + "SPAWN_CERULEAN", + "SPAWN_ROCK_TUNNEL", + "SPAWN_VERMILION", + "SPAWN_LAVENDER", + "SPAWN_SAFFRON", + "SPAWN_CELADON", + "SPAWN_FUCHSIA", + "SPAWN_CINNABAR", + "SPAWN_INDIGO", + "SPAWN_NEW_BARK", + "SPAWN_CHERRYGROVE", + "SPAWN_VIOLET", + "SPAWN_UNION_CAVE", + "SPAWN_AZALEA", + "SPAWN_CIANWOOD", + "SPAWN_GOLDENROD", + "SPAWN_OLIVINE", + "SPAWN_ECRUTEAK", + "SPAWN_MAHOGANY", + "SPAWN_LAKE_OF_RAGE", + "SPAWN_BLACKTHORN", + "SPAWN_MT_SILVER", + "SPAWN_FAST_SHIP" + ], + "specialCallOrder": [ + "SPECIALCALL_NONE", + "SPECIALCALL_POKERUS", + "SPECIALCALL_ROBBED", + "SPECIALCALL_ASSISTANT", + "SPECIALCALL_WEIRDBROADCAST", + "SPECIALCALL_SSTICKET", + "SPECIALCALL_BIKESHOP", + "SPECIALCALL_WORRIED", + "SPECIALCALL_MASTERBALL" + ], + "specialOrder": [ + "WarpToSpawnPoint", + "SetBitsForLinkTradeRequest", + "WaitForLinkedFriend", + "CheckLinkTimeout_Receptionist", + "TryQuickSave", + "CheckBothSelectedSameRoom", + "FailedLinkToPast", + "CloseLink", + "WaitForOtherPlayerToExit", + "SetBitsForBattleRequest", + "SetBitsForTimeCapsuleRequest", + "CheckTimeCapsuleCompatibility", + "EnterTimeCapsule", + "TradeCenter", + "Colosseum", + "TimeCapsule", + "CableClubCheckWhichChris", + "CheckMysteryGift", + "GetMysteryGiftItem", + "UnlockMysteryGift", + "BugContestJudging", + "CheckPartyFullAfterContest", + "ContestDropOffMons", + "ContestReturnMons", + "GiveParkBalls", + "CheckMagikarpLength", + "MagikarpHouseSign", + "HealParty", + "PokemonCenterPC", + "PlayersHousePC", + "DayCareMan", + "DayCareLady", + "DayCareManOutside", + "MoveDeletion", + "BankOfMom", + "MagnetTrain", + "NameRival", + "SetDayOfWeek", + "OverworldTownMap", + "UnownPrinter", + "MapRadio", + "UnownPuzzle", + "SlotMachine", + "CardFlip", + "UnusedMemoryGame", + "ClearBGPalettesBufferScreen", + "FadeOutToWhite", + "BattleTowerFade", + "FadeOutToBlack", + "FadeInFromWhite", + "FadeInFromBlack", + "ReloadSpritesNoPalettes", + "ClearBGPalettes", + "UpdateTimePals", + "ClearTilemap", + "UpdateSprites", + "UpdatePlayerSprite", + "GameCornerPrizeMonCheckDex", + "UnusedSetSeenMon", + "WaitSFX", + "PlayMapMusic", + "RestartMapMusic", + "HealMachineAnim", + "SurfStartStep", + "FindPartyMonAboveLevel", + "FindPartyMonAtLeastThatHappy", + "FindPartyMonThatSpecies", + "FindPartyMonThatSpeciesYourTrainerID", + "UnusedCheckUnusedTwoDayTimer", + "DayCareMon1", + "DayCareMon2", + "SelectRandomBugContestContestants", + "ActivateFishingSwarm", + "ToggleMaptileDecorations", + "ToggleDecorationsVisibility", + "GiveShuckle", + "ReturnShuckie", + "BillsGrandfather", + "CheckPokerus", + "DisplayCoinCaseBalance", + "DisplayMoneyAndCoinBalance", + "PlaceMoneyTopRight", + "CheckForLuckyNumberWinners", + "CheckLuckyNumberShowFlag", + "ResetLuckyNumberShowFlag", + "PrintTodaysLuckyNumber", + "SelectApricornForKurt", + "NameRater", + "DisplayLinkRecord", + "GetFirstPokemonHappiness", + "CheckFirstMonIsEgg", + "RandomUnseenWildMon", + "RandomPhoneWildMon", + "RandomPhoneMon", + "LoadUsedSpritesGFX", + "PlaySlowCry", + "SnorlaxAwake", + "OlderHaircutBrother", + "YoungerHaircutBrother", + "DaisysGrooming", + "PlayCurMonCry", + "ProfOaksPCBoot", + "GameboyCheck", + "TrainerHouse", + "PhotoStudio", + "InitRoamMons", + "FadeOutMusic", + "Diploma", + "PrintDiploma", + "Function11ac3e", + "TradeCornerHoldMon", + "Function11b5e8", + "Function11b7e5", + "Function11b879", + "Function11b920", + "Function11b93b", + "BattleTowerRoomMenu", + "Function1700ba", + "Function170114", + "BattleTowerBattle", + "Function1704e1", + "UnusedBattleTowerDummySpecial1", + "LoadOpponentTrainerAndPokemonWithOTSprite", + "Function11ba38", + "CheckForBattleTowerRules", + "GiveOddEgg", + "Reset", + "Function1011f1", + "Function101220", + "Function101225", + "Function101231", + "MoveTutor", + "OmanyteChamber", + "Function11c1ab", + "BattleTowerAction", + "DisplayUnownWords", + "Menu_ChallengeExplanationCancel", + "Function17d2b6", + "Function17d2ce", + "BattleTowerMobileError", + "AskMobileOrCable", + "HoOhChamber", + "Function102142", + "CelebiShrineEvent", + "CheckCaughtCelebi", + "PokeSeer", + "BuenasPassword", + "BuenaPrize", + "GiveDratini", + "SampleKenjiBreakCountdown", + "BeastsCheck", + "MonCheck", + "SetPlayerPalette", + "UnusedBattleTowerDummySpecial2", + "Mobile_SelectThreeMons", + "Function1037eb", + "Function10383c", + "StubbedTrainerRankings_Healings", + "RefreshSprites", + "Function1037c2", + "CheckMobileAdapterStatusSpecial", + "Function103780", + "Function10387b", + "AskRememberPassword", + "LoadMapPalettes", + "UnusedFindItemInPCOrBag", + "InitialSetDSTFlag", + "InitialClearDSTFlag", + "UnusedDummySpecial" + ], + "speciesOrder": [ + "BULBASAUR", + "IVYSAUR", + "VENUSAUR", + "CHARMANDER", + "CHARMELEON", + "CHARIZARD", + "SQUIRTLE", + "WARTORTLE", + "BLASTOISE", + "CATERPIE", + "METAPOD", + "BUTTERFREE", + "WEEDLE", + "KAKUNA", + "BEEDRILL", + "PIDGEY", + "PIDGEOTTO", + "PIDGEOT", + "RATTATA", + "RATICATE", + "SPEAROW", + "FEAROW", + "EKANS", + "ARBOK", + "PIKACHU", + "RAICHU", + "SANDSHREW", + "SANDSLASH", + "NIDORAN_F", + "NIDORINA", + "NIDOQUEEN", + "NIDORAN_M", + "NIDORINO", + "NIDOKING", + "CLEFAIRY", + "CLEFABLE", + "VULPIX", + "NINETALES", + "JIGGLYPUFF", + "WIGGLYTUFF", + "ZUBAT", + "GOLBAT", + "ODDISH", + "GLOOM", + "VILEPLUME", + "PARAS", + "PARASECT", + "VENONAT", + "VENOMOTH", + "DIGLETT", + "DUGTRIO", + "MEOWTH", + "PERSIAN", + "PSYDUCK", + "GOLDUCK", + "MANKEY", + "PRIMEAPE", + "GROWLITHE", + "ARCANINE", + "POLIWAG", + "POLIWHIRL", + "POLIWRATH", + "ABRA", + "KADABRA", + "ALAKAZAM", + "MACHOP", + "MACHOKE", + "MACHAMP", + "BELLSPROUT", + "WEEPINBELL", + "VICTREEBEL", + "TENTACOOL", + "TENTACRUEL", + "GEODUDE", + "GRAVELER", + "GOLEM", + "PONYTA", + "RAPIDASH", + "SLOWPOKE", + "SLOWBRO", + "MAGNEMITE", + "MAGNETON", + "FARFETCH_D", + "DODUO", + "DODRIO", + "SEEL", + "DEWGONG", + "GRIMER", + "MUK", + "SHELLDER", + "CLOYSTER", + "GASTLY", + "HAUNTER", + "GENGAR", + "ONIX", + "DROWZEE", + "HYPNO", + "KRABBY", + "KINGLER", + "VOLTORB", + "ELECTRODE", + "EXEGGCUTE", + "EXEGGUTOR", + "CUBONE", + "MAROWAK", + "HITMONLEE", + "HITMONCHAN", + "LICKITUNG", + "KOFFING", + "WEEZING", + "RHYHORN", + "RHYDON", + "CHANSEY", + "TANGELA", + "KANGASKHAN", + "HORSEA", + "SEADRA", + "GOLDEEN", + "SEAKING", + "STARYU", + "STARMIE", + "MR__MIME", + "SCYTHER", + "JYNX", + "ELECTABUZZ", + "MAGMAR", + "PINSIR", + "TAUROS", + "MAGIKARP", + "GYARADOS", + "LAPRAS", + "DITTO", + "EEVEE", + "VAPOREON", + "JOLTEON", + "FLAREON", + "PORYGON", + "OMANYTE", + "OMASTAR", + "KABUTO", + "KABUTOPS", + "AERODACTYL", + "SNORLAX", + "ARTICUNO", + "ZAPDOS", + "MOLTRES", + "DRATINI", + "DRAGONAIR", + "DRAGONITE", + "MEWTWO", + "MEW", + "CHIKORITA", + "BAYLEEF", + "MEGANIUM", + "CYNDAQUIL", + "QUILAVA", + "TYPHLOSION", + "TOTODILE", + "CROCONAW", + "FERALIGATR", + "SENTRET", + "FURRET", + "HOOTHOOT", + "NOCTOWL", + "LEDYBA", + "LEDIAN", + "SPINARAK", + "ARIADOS", + "CROBAT", + "CHINCHOU", + "LANTURN", + "PICHU", + "CLEFFA", + "IGGLYBUFF", + "TOGEPI", + "TOGETIC", + "NATU", + "XATU", + "MAREEP", + "FLAAFFY", + "AMPHAROS", + "BELLOSSOM", + "MARILL", + "AZUMARILL", + "SUDOWOODO", + "POLITOED", + "HOPPIP", + "SKIPLOOM", + "JUMPLUFF", + "AIPOM", + "SUNKERN", + "SUNFLORA", + "YANMA", + "WOOPER", + "QUAGSIRE", + "ESPEON", + "UMBREON", + "MURKROW", + "SLOWKING", + "MISDREAVUS", + "UNOWN", + "WOBBUFFET", + "GIRAFARIG", + "PINECO", + "FORRETRESS", + "DUNSPARCE", + "GLIGAR", + "STEELIX", + "SNUBBULL", + "GRANBULL", + "QWILFISH", + "SCIZOR", + "SHUCKLE", + "HERACROSS", + "SNEASEL", + "TEDDIURSA", + "URSARING", + "SLUGMA", + "MAGCARGO", + "SWINUB", + "PILOSWINE", + "CORSOLA", + "REMORAID", + "OCTILLERY", + "DELIBIRD", + "MANTINE", + "SKARMORY", + "HOUNDOUR", + "HOUNDOOM", + "KINGDRA", + "PHANPY", + "DONPHAN", + "PORYGON2", + "STANTLER", + "SMEARGLE", + "TYROGUE", + "HITMONTOP", + "SMOOCHUM", + "ELEKID", + "MAGBY", + "MILTANK", + "BLISSEY", + "RAIKOU", + "ENTEI", + "SUICUNE", + "LARVITAR", + "PUPITAR", + "TYRANITAR", + "LUGIA", + "HO_OH", + "CELEBI" + ], + "spriteOrder": [ + "SPRITE_CHRIS", + "SPRITE_CHRIS_BIKE", + "SPRITE_GAMEBOY_KID", + "SPRITE_RIVAL", + "SPRITE_OAK", + "SPRITE_RED", + "SPRITE_BLUE", + "SPRITE_BILL", + "SPRITE_ELDER", + "SPRITE_JANINE", + "SPRITE_KURT", + "SPRITE_MOM", + "SPRITE_BLAINE", + "SPRITE_REDS_MOM", + "SPRITE_DAISY", + "SPRITE_ELM", + "SPRITE_WILL", + "SPRITE_FALKNER", + "SPRITE_WHITNEY", + "SPRITE_BUGSY", + "SPRITE_MORTY", + "SPRITE_CHUCK", + "SPRITE_JASMINE", + "SPRITE_PRYCE", + "SPRITE_CLAIR", + "SPRITE_BROCK", + "SPRITE_KAREN", + "SPRITE_BRUNO", + "SPRITE_MISTY", + "SPRITE_LANCE", + "SPRITE_SURGE", + "SPRITE_ERIKA", + "SPRITE_KOGA", + "SPRITE_SABRINA", + "SPRITE_COOLTRAINER_M", + "SPRITE_COOLTRAINER_F", + "SPRITE_BUG_CATCHER", + "SPRITE_TWIN", + "SPRITE_YOUNGSTER", + "SPRITE_LASS", + "SPRITE_TEACHER", + "SPRITE_BEAUTY", + "SPRITE_SUPER_NERD", + "SPRITE_ROCKER", + "SPRITE_POKEFAN_M", + "SPRITE_POKEFAN_F", + "SPRITE_GRAMPS", + "SPRITE_GRANNY", + "SPRITE_SWIMMER_GUY", + "SPRITE_SWIMMER_GIRL", + "SPRITE_BIG_SNORLAX", + "SPRITE_SURFING_PIKACHU", + "SPRITE_ROCKET", + "SPRITE_ROCKET_GIRL", + "SPRITE_NURSE", + "SPRITE_LINK_RECEPTIONIST", + "SPRITE_CLERK", + "SPRITE_FISHER", + "SPRITE_FISHING_GURU", + "SPRITE_SCIENTIST", + "SPRITE_KIMONO_GIRL", + "SPRITE_SAGE", + "SPRITE_UNUSED_GUY", + "SPRITE_GENTLEMAN", + "SPRITE_BLACK_BELT", + "SPRITE_RECEPTIONIST", + "SPRITE_OFFICER", + "SPRITE_CAL", + "SPRITE_SLOWPOKE", + "SPRITE_CAPTAIN", + "SPRITE_BIG_LAPRAS", + "SPRITE_GYM_GUIDE", + "SPRITE_SAILOR", + "SPRITE_BIKER", + "SPRITE_PHARMACIST", + "SPRITE_MONSTER", + "SPRITE_FAIRY", + "SPRITE_BIRD", + "SPRITE_DRAGON", + "SPRITE_BIG_ONIX", + "SPRITE_N64", + "SPRITE_SUDOWOODO", + "SPRITE_SURF", + "SPRITE_POKE_BALL", + "SPRITE_POKEDEX", + "SPRITE_PAPER", + "SPRITE_VIRTUAL_BOY", + "SPRITE_OLD_LINK_RECEPTIONIST", + "SPRITE_ROCK", + "SPRITE_BOULDER", + "SPRITE_SNES", + "SPRITE_FAMICOM", + "SPRITE_FRUIT_TREE", + "SPRITE_GOLD_TROPHY", + "SPRITE_SILVER_TROPHY", + "SPRITE_KRIS", + "SPRITE_KRIS_BIKE", + "SPRITE_KURT_OUTSIDE", + "SPRITE_SUICUNE", + "SPRITE_ENTEI", + "SPRITE_RAIKOU", + "SPRITE_STANDING_YOUNGSTER", + "UNUSED", + "UNUSED", + "UNUSED", + "UNUSED", + "UNUSED", + "UNUSED", + "UNUSED", + "UNUSED", + "UNUSED", + "UNUSED", + "UNUSED", + "UNUSED", + "UNUSED", + "UNUSED", + "UNUSED", + "UNUSED", + "UNUSED", + "UNUSED", + "UNUSED", + "UNUSED", + "UNUSED", + "UNUSED", + "UNUSED", + "UNUSED", + "UNUSED", + "SPRITE_UNOWN", + "SPRITE_GEODUDE", + "SPRITE_GROWLITHE", + "SPRITE_WEEDLE", + "SPRITE_SHELLDER", + "SPRITE_ODDISH", + "SPRITE_GENGAR", + "SPRITE_ZUBAT", + "SPRITE_MAGIKARP", + "SPRITE_SQUIRTLE", + "SPRITE_TOGEPI", + "SPRITE_BUTTERFREE", + "SPRITE_DIGLETT", + "SPRITE_POLIWAG", + "SPRITE_PIKACHU", + "SPRITE_CLEFAIRY", + "SPRITE_CHARMANDER", + "SPRITE_JYNX", + "SPRITE_STARMIE", + "SPRITE_BULBASAUR", + "SPRITE_JIGGLYPUFF", + "SPRITE_GRIMER", + "SPRITE_EKANS", + "SPRITE_PARAS", + "SPRITE_TENTACOOL", + "SPRITE_TAUROS", + "SPRITE_MACHOP", + "SPRITE_VOLTORB", + "SPRITE_LAPRAS", + "SPRITE_RHYDON", + "SPRITE_MOLTRES", + "SPRITE_SNORLAX", + "SPRITE_GYARADOS", + "SPRITE_LUGIA", + "SPRITE_HO_OH" + ], + "spritePokemon": 128, + "stdScriptOrder": [ + "PokecenterNurseScript", + "DifficultBookshelfScript", + "PictureBookshelfScript", + "MagazineBookshelfScript", + "TeamRocketOathScript", + "IncenseBurnerScript", + "MerchandiseShelfScript", + "TownMapScript", + "WindowScript", + "TVScript", + "HomepageScript", + "Radio1Script", + "Radio2Script", + "TrashCanScript", + "StrengthBoulderScript", + "SmashRockScript", + "PokecenterSignScript", + "MartSignScript", + "GoldenrodRocketsScript", + "RadioTowerRocketsScript", + "ElevatorButtonScript", + "DayToTextScript", + "BugContestResultsWarpScript", + "BugContestResultsScript", + "InitializeEventsScript", + "AskNumber1MScript", + "AskNumber2MScript", + "RegisteredNumberMScript", + "NumberAcceptedMScript", + "NumberDeclinedMScript", + "PhoneFullMScript", + "RematchMScript", + "GiftMScript", + "PackFullMScript", + "RematchGiftMScript", + "AskNumber1FScript", + "AskNumber2FScript", + "RegisteredNumberFScript", + "NumberAcceptedFScript", + "NumberDeclinedFScript", + "PhoneFullFScript", + "RematchFScript", + "GiftFScript", + "PackFullFScript", + "RematchGiftFScript", + "GymStatue1Script", + "GymStatue2Script", + "ReceiveItemScript", + "ReceiveTogepiEggScript", + "PCScript", + "GameCornerCoinVendorScript", + "HappinessCheckScript" + ], + "tilesetOrder": [ + "TILESET_JOHTO", + "TILESET_JOHTO_MODERN", + "TILESET_KANTO", + "TILESET_BATTLE_TOWER_OUTSIDE", + "TILESET_HOUSE", + "TILESET_PLAYERS_HOUSE", + "TILESET_POKECENTER", + "TILESET_GATE", + "TILESET_PORT", + "TILESET_LAB", + "TILESET_FACILITY", + "TILESET_MART", + "TILESET_MANSION", + "TILESET_GAME_CORNER", + "TILESET_ELITE_FOUR_ROOM", + "TILESET_TRADITIONAL_HOUSE", + "TILESET_TRAIN_STATION", + "TILESET_CHAMPIONS_ROOM", + "TILESET_LIGHTHOUSE", + "TILESET_PLAYERS_ROOM", + "TILESET_POKECOM_CENTER", + "TILESET_BATTLE_TOWER_INSIDE", + "TILESET_TOWER", + "TILESET_CAVE", + "TILESET_PARK", + "TILESET_RUINS_OF_ALPH", + "TILESET_RADIO_TOWER", + "TILESET_UNDERGROUND", + "TILESET_ICE_PATH", + "TILESET_DARK_CAVE", + "TILESET_FOREST", + "TILESET_BETA_WORD_ROOM", + "TILESET_HO_OH_WORD_ROOM", + "TILESET_KABUTO_WORD_ROOM", + "TILESET_OMANYTE_WORD_ROOM", + "TILESET_AERODACTYL_WORD_ROOM" + ], + "tradeDialogOrder": [ + "TRADE_DIALOGSET_COLLECTOR", + "TRADE_DIALOGSET_HAPPY", + "TRADE_DIALOGSET_NEWBIE", + "TRADE_DIALOGSET_GIRL" + ], + "tradeGenderOrder": [ + "TRADE_GENDER_EITHER", + "TRADE_GENDER_MALE", + "TRADE_GENDER_FEMALE" + ], + "trainerClassMembers": { + "BEAUTY": [ + "VICTORIA", + "SAMANTHA", + "JULIE", + "JACLYN", + "BRENDA", + "CASSIE", + "CAROLINE", + "CARLENE", + "JESSICA", + "RACHAEL", + "ANGELICA", + "KENDRA", + "VERONICA", + "JULIA", + "THERESA", + "VALERIE", + "OLIVIA" + ], + "BIKER": [ + "BIKER_BENNY", + "KAZU", + "DWAYNE", + "HARRIS", + "ZEKE", + "CHARLES", + "RILEY", + "JOEL", + "GLENN" + ], + "BIRD_KEEPER": [ + "ROD", + "ABE", + "BRYAN", + "THEO", + "TOBY", + "DENIS", + "VANCE1", + "HANK", + "ROY", + "BORIS", + "BOB", + "JOSE1", + "PETER", + "JOSE2", + "PERRY", + "BRET", + "JOSE3", + "VANCE2", + "VANCE3" + ], + "BLACKBELT_T": [ + "KENJI1", + "YOSHI", + "KENJI2", + "LAO", + "NOB", + "KIYO", + "LUNG", + "KENJI3", + "WAI" + ], + "BLAINE": [ + "BLAINE1" + ], + "BLUE": [ + "BLUE1" + ], + "BOARDER": [ + "RONALD", + "BRAD", + "DOUGLAS" + ], + "BROCK": [ + "BROCK1" + ], + "BRUNO": [ + "BRUNO1" + ], + "BUGSY": [ + "BUGSY1" + ], + "BUG_CATCHER": [ + "DON", + "ROB", + "ED", + "WADE1", + "BUG_CATCHER_BENNY", + "AL", + "JOSH", + "ARNIE1", + "KEN", + "WADE2", + "WADE3", + "DOUG", + "ARNIE2", + "ARNIE3", + "WADE4", + "WADE5", + "ARNIE4", + "ARNIE5", + "WAYNE" + ], + "BURGLAR": [ + "DUNCAN", + "EDDIE", + "COREY" + ], + "CAL": [ + "CAL1", + "CAL2", + "CAL3" + ], + "CAMPER": [ + "ROLAND", + "TODD1", + "IVAN", + "ELLIOT", + "BARRY", + "LLOYD", + "DEAN", + "SID", + "HARVEY", + "DALE", + "TED", + "TODD2", + "TODD3", + "THOMAS", + "LEROY", + "DAVID", + "JOHN", + "JERRY", + "SPENCER", + "TODD4", + "TODD5", + "QUENTIN" + ], + "CHAMPION": [ + "LANCE" + ], + "CHUCK": [ + "CHUCK1" + ], + "CLAIR": [ + "CLAIR1" + ], + "COOLTRAINERF": [ + "GWEN", + "LOIS", + "FRAN", + "LOLA", + "KATE", + "IRENE", + "KELLY", + "JOYCE", + "BETH1", + "REENA1", + "MEGAN", + "BETH2", + "CAROL", + "QUINN", + "EMMA", + "CYBIL", + "JENN", + "BETH3", + "REENA2", + "REENA3", + "CARA" + ], + "COOLTRAINERM": [ + "NICK", + "AARON", + "PAUL", + "CODY", + "MIKE", + "GAVEN1", + "GAVEN2", + "RYAN", + "JAKE", + "GAVEN3", + "BLAKE", + "BRIAN", + "ERICK", + "ANDY", + "TYLER", + "SEAN", + "KEVIN", + "STEVE", + "ALLEN", + "DARIN" + ], + "ERIKA": [ + "ERIKA1" + ], + "EXECUTIVEF": [ + "EXECUTIVEF_1", + "EXECUTIVEF_2" + ], + "EXECUTIVEM": [ + "EXECUTIVEM_1", + "EXECUTIVEM_2", + "EXECUTIVEM_3", + "EXECUTIVEM_4" + ], + "FALKNER": [ + "FALKNER1" + ], + "FIREBREATHER": [ + "OTIS", + "DICK", + "NED", + "BURT", + "BILL", + "WALT", + "RAY", + "LYLE" + ], + "FISHER": [ + "JUSTIN", + "RALPH1", + "ARNOLD", + "KYLE", + "HENRY", + "MARVIN", + "TULLY1", + "ANDRE", + "RAYMOND", + "WILTON1", + "EDGAR", + "JONAH", + "MARTIN", + "STEPHEN", + "BARNEY", + "RALPH2", + "RALPH3", + "TULLY2", + "TULLY3", + "WILTON2", + "SCOTT", + "WILTON3", + "RALPH4", + "RALPH5", + "TULLY4" + ], + "GENTLEMAN": [ + "PRESTON", + "EDWARD", + "GREGORY", + "VIRGIL", + "ALFRED" + ], + "GRUNTF": [ + "GRUNTF_1", + "GRUNTF_2", + "GRUNTF_3", + "GRUNTF_4", + "GRUNTF_5" + ], + "GRUNTM": [ + "GRUNTM_1", + "GRUNTM_2", + "GRUNTM_3", + "GRUNTM_4", + "GRUNTM_5", + "GRUNTM_6", + "GRUNTM_7", + "GRUNTM_8", + "GRUNTM_9", + "GRUNTM_10", + "GRUNTM_11", + "GRUNTM_12", + "GRUNTM_13", + "GRUNTM_14", + "GRUNTM_15", + "GRUNTM_16", + "GRUNTM_17", + "GRUNTM_18", + "GRUNTM_19", + "GRUNTM_20", + "GRUNTM_21", + "GRUNTM_22", + "GRUNTM_23", + "GRUNTM_24", + "GRUNTM_25", + "GRUNTM_26", + "GRUNTM_27", + "GRUNTM_28", + "GRUNTM_29", + "GRUNTM_30", + "GRUNTM_31" + ], + "GUITARIST": [ + "CLYDE", + "VINCENT" + ], + "HIKER": [ + "ANTHONY1", + "RUSSELL", + "PHILLIP", + "LEONARD", + "ANTHONY2", + "BENJAMIN", + "ERIK", + "MICHAEL", + "PARRY1", + "TIMOTHY", + "BAILEY", + "ANTHONY3", + "TIM", + "NOLAND", + "SIDNEY", + "KENNY", + "JIM", + "DANIEL", + "PARRY2", + "PARRY3", + "ANTHONY4", + "ANTHONY5" + ], + "JANINE": [ + "JANINE1" + ], + "JASMINE": [ + "JASMINE1" + ], + "JUGGLER": [ + "IRWIN1", + "FRITZ", + "HORTON", + "IRWIN2", + "IRWIN3", + "IRWIN4" + ], + "KAREN": [ + "KAREN1" + ], + "KIMONO_GIRL": [ + "NAOKO_UNUSED", + "NAOKO", + "SAYO", + "ZUKI", + "KUNI", + "MIKI" + ], + "KOGA": [ + "KOGA1" + ], + "LASS": [ + "CARRIE", + "BRIDGET", + "ALICE", + "KRISE", + "CONNIE1", + "LINDA", + "LAURA", + "SHANNON", + "MICHELLE", + "DANA1", + "ELLEN", + "CONNIE2", + "CONNIE3", + "DANA2", + "DANA3", + "DANA4", + "DANA5" + ], + "LT_SURGE": [ + "LT_SURGE1" + ], + "MEDIUM": [ + "MARTHA", + "GRACE", + "BETHANY", + "MARGRET", + "ETHEL", + "REBECCA", + "DORIS" + ], + "MISTY": [ + "MISTY1" + ], + "MORTY": [ + "MORTY1" + ], + "MYSTICALMAN": [ + "EUSINE" + ], + "OFFICER": [ + "KEITH", + "DIRK" + ], + "PICNICKER": [ + "LIZ1", + "GINA1", + "BROOKE", + "KIM", + "CINDY", + "HOPE", + "SHARON", + "DEBRA", + "GINA2", + "ERIN1", + "LIZ2", + "LIZ3", + "HEIDI", + "EDNA", + "GINA3", + "TIFFANY1", + "TIFFANY2", + "ERIN2", + "TANYA", + "TIFFANY3", + "ERIN3", + "LIZ4", + "LIZ5", + "GINA4", + "GINA5", + "TIFFANY4" + ], + "POKEFANF": [ + "BEVERLY1", + "RUTH", + "BEVERLY2", + "BEVERLY3", + "GEORGIA", + "JAIME" + ], + "POKEFANM": [ + "WILLIAM", + "DEREK1", + "ROBERT", + "JOSHUA", + "CARTER", + "TREVOR", + "BRANDON", + "JEREMY", + "COLIN", + "DEREK2", + "DEREK3", + "ALEX", + "REX", + "ALLAN" + ], + "POKEMANIAC": [ + "LARRY", + "ANDREW", + "CALVIN", + "SHANE", + "BEN", + "BRENT1", + "RON", + "ETHAN", + "BRENT2", + "BRENT3", + "ISSAC", + "DONALD", + "ZACH", + "BRENT4", + "MILLER" + ], + "POKEMON_PROF": [], + "PRYCE": [ + "PRYCE1" + ], + "PSYCHIC_T": [ + "NATHAN", + "FRANKLIN", + "HERMAN", + "FIDEL", + "GREG", + "NORMAN", + "MARK", + "PHIL", + "RICHARD", + "GILBERT", + "JARED", + "RODNEY" + ], + "RED": [ + "RED1" + ], + "RIVAL1": [ + "RIVAL1_1_CHIKORITA", + "RIVAL1_1_CYNDAQUIL", + "RIVAL1_1_TOTODILE", + "RIVAL1_2_CHIKORITA", + "RIVAL1_2_CYNDAQUIL", + "RIVAL1_2_TOTODILE", + "RIVAL1_3_CHIKORITA", + "RIVAL1_3_CYNDAQUIL", + "RIVAL1_3_TOTODILE", + "RIVAL1_4_CHIKORITA", + "RIVAL1_4_CYNDAQUIL", + "RIVAL1_4_TOTODILE", + "RIVAL1_5_CHIKORITA", + "RIVAL1_5_CYNDAQUIL", + "RIVAL1_5_TOTODILE" + ], + "RIVAL2": [ + "RIVAL2_1_CHIKORITA", + "RIVAL2_1_CYNDAQUIL", + "RIVAL2_1_TOTODILE", + "RIVAL2_2_CHIKORITA", + "RIVAL2_2_CYNDAQUIL", + "RIVAL2_2_TOTODILE" + ], + "SABRINA": [ + "SABRINA1" + ], + "SAGE": [ + "CHOW", + "NICO", + "JIN", + "TROY", + "JEFFREY", + "PING", + "EDMOND", + "NEAL", + "LI", + "GAKU", + "MASA", + "KOJI" + ], + "SAILOR": [ + "EUGENE", + "HUEY1", + "TERRELL", + "KENT", + "ERNEST", + "JEFF", + "GARRETT", + "KENNETH", + "STANLY", + "HARRY", + "HUEY2", + "HUEY3", + "HUEY4" + ], + "SCHOOLBOY": [ + "JACK1", + "KIPP", + "ALAN1", + "JOHNNY", + "DANNY", + "TOMMY", + "DUDLEY", + "JOE", + "BILLY", + "CHAD1", + "NATE", + "RICKY", + "JACK2", + "JACK3", + "ALAN2", + "ALAN3", + "CHAD2", + "CHAD3", + "JACK4", + "JACK5", + "ALAN4", + "ALAN5", + "CHAD4", + "CHAD5" + ], + "SCIENTIST": [ + "ROSS", + "MITCH", + "JED", + "MARC", + "RICH" + ], + "SKIER": [ + "ROXANNE", + "CLARISSA" + ], + "SUPER_NERD": [ + "STAN", + "ERIC", + "GREGG", + "JAY", + "DAVE", + "SAM", + "TOM", + "PAT", + "SHAWN", + "TERU", + "RUSS", + "NORTON", + "HUGH", + "MARKUS" + ], + "SWIMMERF": [ + "ELAINE", + "PAULA", + "KAYLEE", + "SUSIE", + "DENISE", + "KARA", + "WENDY", + "LISA", + "JILL", + "MARY", + "KATIE", + "DAWN", + "TARA", + "NICOLE", + "LORI", + "JODY", + "NIKKI", + "DIANA", + "BRIANA" + ], + "SWIMMERM": [ + "HAROLD", + "SIMON", + "RANDALL", + "CHARLIE", + "GEORGE", + "BERKE", + "KIRK", + "MATHEW", + "HAL", + "PATON", + "DARYL", + "WALTER", + "TONY", + "JEROME", + "TUCKER", + "RICK", + "CAMERON", + "SETH", + "JAMES", + "LEWIS", + "PARKER" + ], + "TEACHER": [ + "COLETTE", + "HILLARY", + "SHIRLEY" + ], + "TRAINER_NONE": [], + "TWINS": [ + "AMYANDMAY1", + "ANNANDANNE1", + "ANNANDANNE2", + "AMYANDMAY2", + "JOANDZOE1", + "JOANDZOE2", + "MEGANDPEG1", + "MEGANDPEG2", + "LEAANDPIA1", + "LEAANDPIA2" + ], + "WHITNEY": [ + "WHITNEY1" + ], + "WILL": [ + "WILL1" + ], + "YOUNGSTER": [ + "JOEY1", + "MIKEY", + "ALBERT", + "GORDON", + "SAMUEL", + "IAN", + "JOEY2", + "JOEY3", + "WARREN", + "JIMMY", + "OWEN", + "JASON", + "JOEY4", + "JOEY5" + ] + }, + "trainerClassOrder": [ + "TRAINER_NONE", + "FALKNER", + "WHITNEY", + "BUGSY", + "MORTY", + "PRYCE", + "JASMINE", + "CHUCK", + "CLAIR", + "RIVAL1", + "POKEMON_PROF", + "WILL", + "CAL", + "BRUNO", + "KAREN", + "KOGA", + "CHAMPION", + "BROCK", + "MISTY", + "LT_SURGE", + "SCIENTIST", + "ERIKA", + "YOUNGSTER", + "SCHOOLBOY", + "BIRD_KEEPER", + "LASS", + "JANINE", + "COOLTRAINERM", + "COOLTRAINERF", + "BEAUTY", + "POKEMANIAC", + "GRUNTM", + "GENTLEMAN", + "SKIER", + "TEACHER", + "SABRINA", + "BUG_CATCHER", + "FISHER", + "SWIMMERM", + "SWIMMERF", + "SAILOR", + "SUPER_NERD", + "RIVAL2", + "GUITARIST", + "HIKER", + "BIKER", + "BLAINE", + "BURGLAR", + "FIREBREATHER", + "JUGGLER", + "BLACKBELT_T", + "EXECUTIVEM", + "PSYCHIC_T", + "PICNICKER", + "CAMPER", + "EXECUTIVEF", + "SAGE", + "MEDIUM", + "BOARDER", + "POKEFANM", + "KIMONO_GIRL", + "TWINS", + "POKEFANF", + "RED", + "BLUE", + "OFFICER", + "GRUNTF", + "MYSTICALMAN" + ], + "trainerTypeOrder": [ + "TRAINERTYPE_NORMAL", + "TRAINERTYPE_MOVES", + "TRAINERTYPE_ITEM", + "TRAINERTYPE_ITEM_MOVES" + ], + "treeMonSetOrder": [ + "TREEMON_SET_NONE", + "TREEMON_SET_CANYON", + "TREEMON_SET_TOWN", + "TREEMON_SET_ROUTE", + "TREEMON_SET_KANTO", + "TREEMON_SET_LAKE", + "TREEMON_SET_FOREST", + "TREEMON_SET_ROCK" + ], + "types": { + "BIRD": 6, + "BUG": 7, + "CURSE_TYPE": 19, + "DARK": 27, + "DRAGON": 26, + "ELECTRIC": 23, + "FIGHTING": 1, + "FIRE": 20, + "FLYING": 2, + "GHOST": 8, + "GRASS": 22, + "GROUND": 4, + "ICE": 25, + "NORMAL": 0, + "POISON": 3, + "PSYCHIC_TYPE": 24, + "ROCK": 5, + "STEEL": 9, + "WATER": 21 + } + }, + "fontCharmap": [ + { + "code": 96, + "seq": "" + }, + { + "code": 97, + "seq": "" + }, + { + "code": 98, + "seq": "" + }, + { + "code": 99, + "seq": "" + }, + { + "code": 100, + "seq": "" + }, + { + "code": 101, + "seq": "" + }, + { + "code": 102, + "seq": "" + }, + { + "code": 103, + "seq": "" + }, + { + "code": 104, + "seq": "" + }, + { + "code": 107, + "seq": "" + }, + { + "code": 108, + "seq": "" + }, + { + "code": 106, + "seq": "" + }, + { + "code": 105, + "seq": "" + }, + { + "code": 109, + "seq": "" + }, + { + "code": 242, + "seq": "" + }, + { + "code": 112, + "seq": "" + }, + { + "code": 115, + "seq": "" + }, + { + "code": 113, + "seq": "" + }, + { + "code": 110, + "seq": "" + }, + { + "code": 226, + "seq": "" + }, + { + "code": 225, + "seq": "" + }, + { + "code": 112, + "seq": "" + }, + { + "code": 208, + "seq": "'d" + }, + { + "code": 209, + "seq": "'l" + }, + { + "code": 210, + "seq": "'m" + }, + { + "code": 211, + "seq": "'r" + }, + { + "code": 212, + "seq": "'s" + }, + { + "code": 213, + "seq": "'t" + }, + { + "code": 214, + "seq": "'v" + }, + { + "code": 127, + "seq": " " + }, + { + "code": 231, + "seq": "!" + }, + { + "code": 115, + "seq": "\"" + }, + { + "code": 233, + "seq": "&" + }, + { + "code": 224, + "seq": "'" + }, + { + "code": 154, + "seq": "(" + }, + { + "code": 155, + "seq": ")" + }, + { + "code": 244, + "seq": "," + }, + { + "code": 227, + "seq": "-" + }, + { + "code": 232, + "seq": "." + }, + { + "code": 243, + "seq": "/" + }, + { + "code": 246, + "seq": "0" + }, + { + "code": 247, + "seq": "1" + }, + { + "code": 248, + "seq": "2" + }, + { + "code": 249, + "seq": "3" + }, + { + "code": 250, + "seq": "4" + }, + { + "code": 251, + "seq": "5" + }, + { + "code": 252, + "seq": "6" + }, + { + "code": 253, + "seq": "7" + }, + { + "code": 254, + "seq": "8" + }, + { + "code": 255, + "seq": "9" + }, + { + "code": 156, + "seq": ":" + }, + { + "code": 157, + "seq": ";" + }, + { + "code": 230, + "seq": "?" + }, + { + "code": 128, + "seq": "A" + }, + { + "code": 129, + "seq": "B" + }, + { + "code": 130, + "seq": "C" + }, + { + "code": 131, + "seq": "D" + }, + { + "code": 132, + "seq": "E" + }, + { + "code": 133, + "seq": "F" + }, + { + "code": 134, + "seq": "G" + }, + { + "code": 135, + "seq": "H" + }, + { + "code": 136, + "seq": "I" + }, + { + "code": 137, + "seq": "J" + }, + { + "code": 138, + "seq": "K" + }, + { + "code": 139, + "seq": "L" + }, + { + "code": 140, + "seq": "M" + }, + { + "code": 141, + "seq": "N" + }, + { + "code": 142, + "seq": "O" + }, + { + "code": 143, + "seq": "P" + }, + { + "code": 144, + "seq": "Q" + }, + { + "code": 145, + "seq": "R" + }, + { + "code": 146, + "seq": "S" + }, + { + "code": 147, + "seq": "T" + }, + { + "code": 148, + "seq": "U" + }, + { + "code": 149, + "seq": "V" + }, + { + "code": 150, + "seq": "W" + }, + { + "code": 151, + "seq": "X" + }, + { + "code": 152, + "seq": "Y" + }, + { + "code": 153, + "seq": "Z" + }, + { + "code": 158, + "seq": "[" + }, + { + "code": 159, + "seq": "]" + }, + { + "code": 160, + "seq": "a" + }, + { + "code": 161, + "seq": "b" + }, + { + "code": 162, + "seq": "c" + }, + { + "code": 163, + "seq": "d" + }, + { + "code": 164, + "seq": "e" + }, + { + "code": 165, + "seq": "f" + }, + { + "code": 166, + "seq": "g" + }, + { + "code": 167, + "seq": "h" + }, + { + "code": 168, + "seq": "i" + }, + { + "code": 169, + "seq": "j" + }, + { + "code": 170, + "seq": "k" + }, + { + "code": 171, + "seq": "l" + }, + { + "code": 172, + "seq": "m" + }, + { + "code": 173, + "seq": "n" + }, + { + "code": 174, + "seq": "o" + }, + { + "code": 175, + "seq": "p" + }, + { + "code": 176, + "seq": "q" + }, + { + "code": 177, + "seq": "r" + }, + { + "code": 178, + "seq": "s" + }, + { + "code": 179, + "seq": "t" + }, + { + "code": 180, + "seq": "u" + }, + { + "code": 181, + "seq": "v" + }, + { + "code": 182, + "seq": "w" + }, + { + "code": 183, + "seq": "x" + }, + { + "code": 184, + "seq": "y" + }, + { + "code": 185, + "seq": "z" + }, + { + "code": 240, + "seq": "¥" + }, + { + "code": 116, + "seq": "·" + }, + { + "code": 192, + "seq": "Ä" + }, + { + "code": 193, + "seq": "Ö" + }, + { + "code": 241, + "seq": "×" + }, + { + "code": 194, + "seq": "Ü" + }, + { + "code": 195, + "seq": "ä" + }, + { + "code": 234, + "seq": "é" + }, + { + "code": 196, + "seq": "ö" + }, + { + "code": 197, + "seq": "ü" + }, + { + "code": 114, + "seq": "“" + }, + { + "code": 115, + "seq": "”" + }, + { + "code": 117, + "seq": "…" + }, + { + "code": 110, + "seq": "′" + }, + { + "code": 111, + "seq": "″" + }, + { + "code": 116, + "seq": "№" + }, + { + "code": 223, + "seq": "←" + }, + { + "code": 235, + "seq": "→" + }, + { + "code": 117, + "seq": "⋯" + }, + { + "code": 122, + "seq": "─" + }, + { + "code": 124, + "seq": "│" + }, + { + "code": 121, + "seq": "┌" + }, + { + "code": 123, + "seq": "┐" + }, + { + "code": 125, + "seq": "└" + }, + { + "code": 126, + "seq": "┘" + }, + { + "code": 96, + "seq": "■" + }, + { + "code": 97, + "seq": "▲" + }, + { + "code": 237, + "seq": "▶" + }, + { + "code": 236, + "seq": "▷" + }, + { + "code": 238, + "seq": "▼" + }, + { + "code": 113, + "seq": "◀" + }, + { + "code": 98, + "seq": "☎" + }, + { + "code": 245, + "seq": "♀" + }, + { + "code": 239, + "seq": "♂" + }, + { + "code": 127, + "seq": " " + }, + { + "code": 232, + "seq": "。" + }, + { + "code": 112, + "seq": "「" + }, + { + "code": 113, + "seq": "」" + }, + { + "code": 114, + "seq": "『" + }, + { + "code": 115, + "seq": "』" + }, + { + "code": 118, + "seq": "ぁ" + }, + { + "code": 177, + "seq": "あ" + }, + { + "code": 110, + "seq": "ぃ" + }, + { + "code": 178, + "seq": "い" + }, + { + "code": 111, + "seq": "ぅ" + }, + { + "code": 179, + "seq": "う" + }, + { + "code": 119, + "seq": "ぇ" + }, + { + "code": 180, + "seq": "え" + }, + { + "code": 120, + "seq": "ぉ" + }, + { + "code": 181, + "seq": "お" + }, + { + "code": 182, + "seq": "か" + }, + { + "code": 183, + "seq": "き" + }, + { + "code": 184, + "seq": "く" + }, + { + "code": 185, + "seq": "け" + }, + { + "code": 186, + "seq": "こ" + }, + { + "code": 187, + "seq": "さ" + }, + { + "code": 188, + "seq": "し" + }, + { + "code": 189, + "seq": "す" + }, + { + "code": 190, + "seq": "せ" + }, + { + "code": 191, + "seq": "そ" + }, + { + "code": 192, + "seq": "た" + }, + { + "code": 193, + "seq": "ち" + }, + { + "code": 223, + "seq": "っ" + }, + { + "code": 194, + "seq": "つ" + }, + { + "code": 195, + "seq": "て" + }, + { + "code": 196, + "seq": "と" + }, + { + "code": 197, + "seq": "な" + }, + { + "code": 198, + "seq": "に" + }, + { + "code": 199, + "seq": "ぬ" + }, + { + "code": 200, + "seq": "ね" + }, + { + "code": 201, + "seq": "の" + }, + { + "code": 202, + "seq": "は" + }, + { + "code": 203, + "seq": "ひ" + }, + { + "code": 204, + "seq": "ふ" + }, + { + "code": 205, + "seq": "へ" + }, + { + "code": 206, + "seq": "ほ" + }, + { + "code": 207, + "seq": "ま" + }, + { + "code": 208, + "seq": "み" + }, + { + "code": 209, + "seq": "む" + }, + { + "code": 210, + "seq": "め" + }, + { + "code": 211, + "seq": "も" + }, + { + "code": 224, + "seq": "ゃ" + }, + { + "code": 212, + "seq": "や" + }, + { + "code": 225, + "seq": "ゅ" + }, + { + "code": 213, + "seq": "ゆ" + }, + { + "code": 226, + "seq": "ょ" + }, + { + "code": 214, + "seq": "よ" + }, + { + "code": 215, + "seq": "ら" + }, + { + "code": 216, + "seq": "り" + }, + { + "code": 217, + "seq": "る" + }, + { + "code": 218, + "seq": "れ" + }, + { + "code": 219, + "seq": "ろ" + }, + { + "code": 220, + "seq": "わ" + }, + { + "code": 221, + "seq": "を" + }, + { + "code": 222, + "seq": "ん" + }, + { + "code": 233, + "seq": "ァ" + }, + { + "code": 128, + "seq": "ア" + }, + { + "code": 176, + "seq": "ィ" + }, + { + "code": 129, + "seq": "イ" + }, + { + "code": 234, + "seq": "ゥ" + }, + { + "code": 130, + "seq": "ウ" + }, + { + "code": 235, + "seq": "ェ" + }, + { + "code": 131, + "seq": "エ" + }, + { + "code": 244, + "seq": "ォ" + }, + { + "code": 132, + "seq": "オ" + }, + { + "code": 133, + "seq": "カ" + }, + { + "code": 134, + "seq": "キ" + }, + { + "code": 135, + "seq": "ク" + }, + { + "code": 136, + "seq": "ケ" + }, + { + "code": 137, + "seq": "コ" + }, + { + "code": 138, + "seq": "サ" + }, + { + "code": 139, + "seq": "シ" + }, + { + "code": 140, + "seq": "ス" + }, + { + "code": 141, + "seq": "セ" + }, + { + "code": 142, + "seq": "ソ" + }, + { + "code": 143, + "seq": "タ" + }, + { + "code": 144, + "seq": "チ" + }, + { + "code": 172, + "seq": "ッ" + }, + { + "code": 145, + "seq": "ツ" + }, + { + "code": 146, + "seq": "テ" + }, + { + "code": 147, + "seq": "ト" + }, + { + "code": 148, + "seq": "ナ" + }, + { + "code": 149, + "seq": "ニ" + }, + { + "code": 150, + "seq": "ヌ" + }, + { + "code": 151, + "seq": "ネ" + }, + { + "code": 152, + "seq": "ノ" + }, + { + "code": 153, + "seq": "ハ" + }, + { + "code": 154, + "seq": "ヒ" + }, + { + "code": 155, + "seq": "フ" + }, + { + "code": 156, + "seq": "ホ" + }, + { + "code": 157, + "seq": "マ" + }, + { + "code": 158, + "seq": "ミ" + }, + { + "code": 159, + "seq": "ム" + }, + { + "code": 160, + "seq": "メ" + }, + { + "code": 161, + "seq": "モ" + }, + { + "code": 173, + "seq": "ャ" + }, + { + "code": 162, + "seq": "ヤ" + }, + { + "code": 174, + "seq": "ュ" + }, + { + "code": 163, + "seq": "ユ" + }, + { + "code": 175, + "seq": "ョ" + }, + { + "code": 164, + "seq": "ヨ" + }, + { + "code": 165, + "seq": "ラ" + }, + { + "code": 166, + "seq": "ル" + }, + { + "code": 167, + "seq": "レ" + }, + { + "code": 168, + "seq": "ロ" + }, + { + "code": 169, + "seq": "ワ" + }, + { + "code": 170, + "seq": "ヲ" + }, + { + "code": 171, + "seq": "ン" + }, + { + "code": 116, + "seq": "・" + }, + { + "code": 227, + "seq": "ー" + }, + { + "code": 240, + "seq": "円" + }, + { + "code": 231, + "seq": "!" + }, + { + "code": 242, + "seq": "." + }, + { + "code": 243, + "seq": "/" + }, + { + "code": 246, + "seq": "0" + }, + { + "code": 247, + "seq": "1" + }, + { + "code": 248, + "seq": "2" + }, + { + "code": 249, + "seq": "3" + }, + { + "code": 250, + "seq": "4" + }, + { + "code": 251, + "seq": "5" + }, + { + "code": 252, + "seq": "6" + }, + { + "code": 253, + "seq": "7" + }, + { + "code": 254, + "seq": "8" + }, + { + "code": 255, + "seq": "9" + }, + { + "code": 230, + "seq": "?" + }, + { + "code": 229, + "seq": "゙" + }, + { + "code": 228, + "seq": "゚" + } + ], + "format": 3, + "generation": 2, + "maps": { + "AZALEA_GYM": { + "group": 8, + "height": 8, + "map": 5, + "name": "AZALEA_GYM", + "width": 5 + }, + "AZALEA_MART": { + "group": 8, + "height": 4, + "map": 3, + "name": "AZALEA_MART", + "width": 6 + }, + "AZALEA_POKECENTER_1F": { + "group": 8, + "height": 4, + "map": 1, + "name": "AZALEA_POKECENTER_1F", + "width": 5 + }, + "AZALEA_TOWN": { + "group": 8, + "height": 9, + "map": 7, + "name": "AZALEA_TOWN", + "width": 20 + }, + "BATTLE_TOWER_1F": { + "group": 22, + "height": 5, + "map": 11, + "name": "BATTLE_TOWER_1F", + "width": 8 + }, + "BATTLE_TOWER_BATTLE_ROOM": { + "group": 22, + "height": 4, + "map": 12, + "name": "BATTLE_TOWER_BATTLE_ROOM", + "width": 4 + }, + "BATTLE_TOWER_ELEVATOR": { + "group": 22, + "height": 2, + "map": 13, + "name": "BATTLE_TOWER_ELEVATOR", + "width": 2 + }, + "BATTLE_TOWER_HALLWAY": { + "group": 22, + "height": 2, + "map": 14, + "name": "BATTLE_TOWER_HALLWAY", + "width": 11 + }, + "BATTLE_TOWER_OUTSIDE": { + "group": 22, + "height": 14, + "map": 16, + "name": "BATTLE_TOWER_OUTSIDE", + "width": 10 + }, + "BILLS_FAMILYS_HOUSE": { + "group": 11, + "height": 4, + "map": 6, + "name": "BILLS_FAMILYS_HOUSE", + "width": 4 + }, + "BILLS_HOUSE": { + "group": 7, + "height": 4, + "map": 11, + "name": "BILLS_HOUSE", + "width": 4 + }, + "BILLS_OLDER_SISTERS_HOUSE": { + "group": 17, + "height": 4, + "map": 9, + "name": "BILLS_OLDER_SISTERS_HOUSE", + "width": 4 + }, + "BLACKTHORN_CITY": { + "group": 5, + "height": 18, + "map": 10, + "name": "BLACKTHORN_CITY", + "width": 20 + }, + "BLACKTHORN_DRAGON_SPEECH_HOUSE": { + "group": 5, + "height": 4, + "map": 3, + "name": "BLACKTHORN_DRAGON_SPEECH_HOUSE", + "width": 4 + }, + "BLACKTHORN_EMYS_HOUSE": { + "group": 5, + "height": 4, + "map": 4, + "name": "BLACKTHORN_EMYS_HOUSE", + "width": 4 + }, + "BLACKTHORN_GYM_1F": { + "group": 5, + "height": 9, + "map": 1, + "name": "BLACKTHORN_GYM_1F", + "width": 5 + }, + "BLACKTHORN_GYM_2F": { + "group": 5, + "height": 9, + "map": 2, + "name": "BLACKTHORN_GYM_2F", + "width": 5 + }, + "BLACKTHORN_MART": { + "group": 5, + "height": 4, + "map": 5, + "name": "BLACKTHORN_MART", + "width": 6 + }, + "BLACKTHORN_POKECENTER_1F": { + "group": 5, + "height": 4, + "map": 6, + "name": "BLACKTHORN_POKECENTER_1F", + "width": 5 + }, + "BLUES_HOUSE": { + "group": 13, + "height": 4, + "map": 5, + "name": "BLUES_HOUSE", + "width": 4 + }, + "BRUNOS_ROOM": { + "group": 16, + "height": 9, + "map": 5, + "name": "BRUNOS_ROOM", + "width": 5 + }, + "BURNED_TOWER_1F": { + "group": 3, + "height": 9, + "map": 13, + "name": "BURNED_TOWER_1F", + "width": 10 + }, + "BURNED_TOWER_B1F": { + "group": 3, + "height": 9, + "map": 14, + "name": "BURNED_TOWER_B1F", + "width": 10 + }, + "CELADON_CAFE": { + "group": 21, + "height": 4, + "map": 22, + "name": "CELADON_CAFE", + "width": 6 + }, + "CELADON_CITY": { + "group": 21, + "height": 18, + "map": 4, + "name": "CELADON_CITY", + "width": 20 + }, + "CELADON_DEPT_STORE_1F": { + "group": 21, + "height": 4, + "map": 5, + "name": "CELADON_DEPT_STORE_1F", + "width": 8 + }, + "CELADON_DEPT_STORE_2F": { + "group": 21, + "height": 4, + "map": 6, + "name": "CELADON_DEPT_STORE_2F", + "width": 8 + }, + "CELADON_DEPT_STORE_3F": { + "group": 21, + "height": 4, + "map": 7, + "name": "CELADON_DEPT_STORE_3F", + "width": 8 + }, + "CELADON_DEPT_STORE_4F": { + "group": 21, + "height": 4, + "map": 8, + "name": "CELADON_DEPT_STORE_4F", + "width": 8 + }, + "CELADON_DEPT_STORE_5F": { + "group": 21, + "height": 4, + "map": 9, + "name": "CELADON_DEPT_STORE_5F", + "width": 8 + }, + "CELADON_DEPT_STORE_6F": { + "group": 21, + "height": 4, + "map": 10, + "name": "CELADON_DEPT_STORE_6F", + "width": 8 + }, + "CELADON_DEPT_STORE_ELEVATOR": { + "group": 21, + "height": 2, + "map": 11, + "name": "CELADON_DEPT_STORE_ELEVATOR", + "width": 2 + }, + "CELADON_GAME_CORNER": { + "group": 21, + "height": 7, + "map": 19, + "name": "CELADON_GAME_CORNER", + "width": 10 + }, + "CELADON_GAME_CORNER_PRIZE_ROOM": { + "group": 21, + "height": 3, + "map": 20, + "name": "CELADON_GAME_CORNER_PRIZE_ROOM", + "width": 3 + }, + "CELADON_GYM": { + "group": 21, + "height": 9, + "map": 21, + "name": "CELADON_GYM", + "width": 5 + }, + "CELADON_MANSION_1F": { + "group": 21, + "height": 5, + "map": 12, + "name": "CELADON_MANSION_1F", + "width": 4 + }, + "CELADON_MANSION_2F": { + "group": 21, + "height": 5, + "map": 13, + "name": "CELADON_MANSION_2F", + "width": 4 + }, + "CELADON_MANSION_3F": { + "group": 21, + "height": 5, + "map": 14, + "name": "CELADON_MANSION_3F", + "width": 4 + }, + "CELADON_MANSION_ROOF": { + "group": 21, + "height": 5, + "map": 15, + "name": "CELADON_MANSION_ROOF", + "width": 4 + }, + "CELADON_MANSION_ROOF_HOUSE": { + "group": 21, + "height": 4, + "map": 16, + "name": "CELADON_MANSION_ROOF_HOUSE", + "width": 4 + }, + "CELADON_POKECENTER_1F": { + "group": 21, + "height": 4, + "map": 17, + "name": "CELADON_POKECENTER_1F", + "width": 5 + }, + "CELADON_POKECENTER_2F_BETA": { + "group": 21, + "height": 4, + "map": 18, + "name": "CELADON_POKECENTER_2F_BETA", + "width": 8 + }, + "CERULEAN_CITY": { + "group": 7, + "height": 18, + "map": 17, + "name": "CERULEAN_CITY", + "width": 20 + }, + "CERULEAN_GYM": { + "group": 7, + "height": 8, + "map": 6, + "name": "CERULEAN_GYM", + "width": 5 + }, + "CERULEAN_GYM_BADGE_SPEECH_HOUSE": { + "group": 7, + "height": 4, + "map": 1, + "name": "CERULEAN_GYM_BADGE_SPEECH_HOUSE", + "width": 4 + }, + "CERULEAN_MART": { + "group": 7, + "height": 4, + "map": 7, + "name": "CERULEAN_MART", + "width": 6 + }, + "CERULEAN_POKECENTER_1F": { + "group": 7, + "height": 4, + "map": 4, + "name": "CERULEAN_POKECENTER_1F", + "width": 5 + }, + "CERULEAN_POKECENTER_2F_BETA": { + "group": 7, + "height": 4, + "map": 5, + "name": "CERULEAN_POKECENTER_2F_BETA", + "width": 8 + }, + "CERULEAN_POLICE_STATION": { + "group": 7, + "height": 4, + "map": 2, + "name": "CERULEAN_POLICE_STATION", + "width": 4 + }, + "CERULEAN_TRADE_SPEECH_HOUSE": { + "group": 7, + "height": 4, + "map": 3, + "name": "CERULEAN_TRADE_SPEECH_HOUSE", + "width": 4 + }, + "CHARCOAL_KILN": { + "group": 8, + "height": 4, + "map": 2, + "name": "CHARCOAL_KILN", + "width": 4 + }, + "CHERRYGROVE_CITY": { + "group": 26, + "height": 9, + "map": 3, + "name": "CHERRYGROVE_CITY", + "width": 20 + }, + "CHERRYGROVE_EVOLUTION_SPEECH_HOUSE": { + "group": 26, + "height": 4, + "map": 8, + "name": "CHERRYGROVE_EVOLUTION_SPEECH_HOUSE", + "width": 4 + }, + "CHERRYGROVE_GYM_SPEECH_HOUSE": { + "group": 26, + "height": 4, + "map": 6, + "name": "CHERRYGROVE_GYM_SPEECH_HOUSE", + "width": 4 + }, + "CHERRYGROVE_MART": { + "group": 26, + "height": 4, + "map": 4, + "name": "CHERRYGROVE_MART", + "width": 6 + }, + "CHERRYGROVE_POKECENTER_1F": { + "group": 26, + "height": 4, + "map": 5, + "name": "CHERRYGROVE_POKECENTER_1F", + "width": 5 + }, + "CIANWOOD_CITY": { + "group": 22, + "height": 27, + "map": 3, + "name": "CIANWOOD_CITY", + "width": 15 + }, + "CIANWOOD_GYM": { + "group": 22, + "height": 9, + "map": 5, + "name": "CIANWOOD_GYM", + "width": 5 + }, + "CIANWOOD_LUGIA_SPEECH_HOUSE": { + "group": 22, + "height": 4, + "map": 9, + "name": "CIANWOOD_LUGIA_SPEECH_HOUSE", + "width": 4 + }, + "CIANWOOD_PHARMACY": { + "group": 22, + "height": 4, + "map": 7, + "name": "CIANWOOD_PHARMACY", + "width": 4 + }, + "CIANWOOD_PHOTO_STUDIO": { + "group": 22, + "height": 4, + "map": 8, + "name": "CIANWOOD_PHOTO_STUDIO", + "width": 4 + }, + "CIANWOOD_POKECENTER_1F": { + "group": 22, + "height": 4, + "map": 6, + "name": "CIANWOOD_POKECENTER_1F", + "width": 5 + }, + "CINNABAR_ISLAND": { + "group": 6, + "height": 9, + "map": 8, + "name": "CINNABAR_ISLAND", + "width": 10 + }, + "CINNABAR_POKECENTER_1F": { + "group": 6, + "height": 4, + "map": 1, + "name": "CINNABAR_POKECENTER_1F", + "width": 5 + }, + "CINNABAR_POKECENTER_2F_BETA": { + "group": 6, + "height": 4, + "map": 2, + "name": "CINNABAR_POKECENTER_2F_BETA", + "width": 8 + }, + "COLOSSEUM": { + "group": 20, + "height": 4, + "map": 3, + "name": "COLOSSEUM", + "width": 5 + }, + "COPYCATS_HOUSE_1F": { + "group": 25, + "height": 4, + "map": 11, + "name": "COPYCATS_HOUSE_1F", + "width": 4 + }, + "COPYCATS_HOUSE_2F": { + "group": 25, + "height": 3, + "map": 12, + "name": "COPYCATS_HOUSE_2F", + "width": 5 + }, + "DANCE_THEATER": { + "group": 4, + "height": 7, + "map": 5, + "name": "DANCE_THEATER", + "width": 6 + }, + "DARK_CAVE_BLACKTHORN_ENTRANCE": { + "group": 3, + "height": 18, + "map": 79, + "name": "DARK_CAVE_BLACKTHORN_ENTRANCE", + "width": 15 + }, + "DARK_CAVE_VIOLET_ENTRANCE": { + "group": 3, + "height": 18, + "map": 78, + "name": "DARK_CAVE_VIOLET_ENTRANCE", + "width": 20 + }, + "DAY_CARE": { + "group": 11, + "height": 4, + "map": 24, + "name": "DAY_CARE", + "width": 5 + }, + "DAY_OF_WEEK_SIBLINGS_HOUSE": { + "group": 24, + "height": 4, + "map": 11, + "name": "DAY_OF_WEEK_SIBLINGS_HOUSE", + "width": 4 + }, + "DIGLETTS_CAVE": { + "group": 3, + "height": 18, + "map": 84, + "name": "DIGLETTS_CAVE", + "width": 10 + }, + "DRAGONS_DEN_1F": { + "group": 3, + "height": 9, + "map": 80, + "name": "DRAGONS_DEN_1F", + "width": 5 + }, + "DRAGONS_DEN_B1F": { + "group": 3, + "height": 18, + "map": 81, + "name": "DRAGONS_DEN_B1F", + "width": 20 + }, + "DRAGON_SHRINE": { + "group": 3, + "height": 5, + "map": 82, + "name": "DRAGON_SHRINE", + "width": 5 + }, + "EARLS_POKEMON_ACADEMY": { + "group": 10, + "height": 8, + "map": 8, + "name": "EARLS_POKEMON_ACADEMY", + "width": 4 + }, + "ECRUTEAK_CITY": { + "group": 4, + "height": 18, + "map": 9, + "name": "ECRUTEAK_CITY", + "width": 20 + }, + "ECRUTEAK_GYM": { + "group": 4, + "height": 9, + "map": 7, + "name": "ECRUTEAK_GYM", + "width": 5 + }, + "ECRUTEAK_ITEMFINDER_HOUSE": { + "group": 4, + "height": 4, + "map": 8, + "name": "ECRUTEAK_ITEMFINDER_HOUSE", + "width": 4 + }, + "ECRUTEAK_LUGIA_SPEECH_HOUSE": { + "group": 4, + "height": 4, + "map": 4, + "name": "ECRUTEAK_LUGIA_SPEECH_HOUSE", + "width": 4 + }, + "ECRUTEAK_MART": { + "group": 4, + "height": 4, + "map": 6, + "name": "ECRUTEAK_MART", + "width": 6 + }, + "ECRUTEAK_POKECENTER_1F": { + "group": 4, + "height": 4, + "map": 3, + "name": "ECRUTEAK_POKECENTER_1F", + "width": 5 + }, + "ECRUTEAK_TIN_TOWER_ENTRANCE": { + "group": 4, + "height": 9, + "map": 1, + "name": "ECRUTEAK_TIN_TOWER_ENTRANCE", + "width": 10 + }, + "ELMS_HOUSE": { + "group": 24, + "height": 4, + "map": 9, + "name": "ELMS_HOUSE", + "width": 4 + }, + "ELMS_LAB": { + "group": 24, + "height": 6, + "map": 5, + "name": "ELMS_LAB", + "width": 5 + }, + "FAST_SHIP_1F": { + "group": 15, + "height": 9, + "map": 3, + "name": "FAST_SHIP_1F", + "width": 16 + }, + "FAST_SHIP_B1F": { + "group": 15, + "height": 8, + "map": 7, + "name": "FAST_SHIP_B1F", + "width": 16 + }, + "FAST_SHIP_CABINS_NNW_NNE_NE": { + "group": 15, + "height": 16, + "map": 4, + "name": "FAST_SHIP_CABINS_NNW_NNE_NE", + "width": 4 + }, + "FAST_SHIP_CABINS_SE_SSE_CAPTAINS_CABIN": { + "group": 15, + "height": 17, + "map": 6, + "name": "FAST_SHIP_CABINS_SE_SSE_CAPTAINS_CABIN", + "width": 5 + }, + "FAST_SHIP_CABINS_SW_SSW_NW": { + "group": 15, + "height": 16, + "map": 5, + "name": "FAST_SHIP_CABINS_SW_SSW_NW", + "width": 4 + }, + "FIGHTING_DOJO": { + "group": 25, + "height": 6, + "map": 3, + "name": "FIGHTING_DOJO", + "width": 5 + }, + "FUCHSIA_CITY": { + "group": 17, + "height": 18, + "map": 5, + "name": "FUCHSIA_CITY", + "width": 20 + }, + "FUCHSIA_GYM": { + "group": 17, + "height": 9, + "map": 8, + "name": "FUCHSIA_GYM", + "width": 5 + }, + "FUCHSIA_MART": { + "group": 17, + "height": 4, + "map": 6, + "name": "FUCHSIA_MART", + "width": 6 + }, + "FUCHSIA_POKECENTER_1F": { + "group": 17, + "height": 4, + "map": 10, + "name": "FUCHSIA_POKECENTER_1F", + "width": 5 + }, + "FUCHSIA_POKECENTER_2F_BETA": { + "group": 17, + "height": 4, + "map": 11, + "name": "FUCHSIA_POKECENTER_2F_BETA", + "width": 8 + }, + "GOLDENROD_BIKE_SHOP": { + "group": 11, + "height": 4, + "map": 4, + "name": "GOLDENROD_BIKE_SHOP", + "width": 4 + }, + "GOLDENROD_CITY": { + "group": 11, + "height": 18, + "map": 2, + "name": "GOLDENROD_CITY", + "width": 20 + }, + "GOLDENROD_DEPT_STORE_1F": { + "group": 11, + "height": 4, + "map": 11, + "name": "GOLDENROD_DEPT_STORE_1F", + "width": 8 + }, + "GOLDENROD_DEPT_STORE_2F": { + "group": 11, + "height": 4, + "map": 12, + "name": "GOLDENROD_DEPT_STORE_2F", + "width": 8 + }, + "GOLDENROD_DEPT_STORE_3F": { + "group": 11, + "height": 4, + "map": 13, + "name": "GOLDENROD_DEPT_STORE_3F", + "width": 8 + }, + "GOLDENROD_DEPT_STORE_4F": { + "group": 11, + "height": 4, + "map": 14, + "name": "GOLDENROD_DEPT_STORE_4F", + "width": 8 + }, + "GOLDENROD_DEPT_STORE_5F": { + "group": 11, + "height": 4, + "map": 15, + "name": "GOLDENROD_DEPT_STORE_5F", + "width": 8 + }, + "GOLDENROD_DEPT_STORE_6F": { + "group": 11, + "height": 4, + "map": 16, + "name": "GOLDENROD_DEPT_STORE_6F", + "width": 8 + }, + "GOLDENROD_DEPT_STORE_B1F": { + "group": 3, + "height": 9, + "map": 55, + "name": "GOLDENROD_DEPT_STORE_B1F", + "width": 10 + }, + "GOLDENROD_DEPT_STORE_ELEVATOR": { + "group": 11, + "height": 2, + "map": 17, + "name": "GOLDENROD_DEPT_STORE_ELEVATOR", + "width": 2 + }, + "GOLDENROD_DEPT_STORE_ROOF": { + "group": 11, + "height": 4, + "map": 18, + "name": "GOLDENROD_DEPT_STORE_ROOF", + "width": 8 + }, + "GOLDENROD_FLOWER_SHOP": { + "group": 11, + "height": 4, + "map": 8, + "name": "GOLDENROD_FLOWER_SHOP", + "width": 4 + }, + "GOLDENROD_GAME_CORNER": { + "group": 11, + "height": 7, + "map": 19, + "name": "GOLDENROD_GAME_CORNER", + "width": 10 + }, + "GOLDENROD_GYM": { + "group": 11, + "height": 9, + "map": 3, + "name": "GOLDENROD_GYM", + "width": 10 + }, + "GOLDENROD_HAPPINESS_RATER": { + "group": 11, + "height": 4, + "map": 5, + "name": "GOLDENROD_HAPPINESS_RATER", + "width": 4 + }, + "GOLDENROD_MAGNET_TRAIN_STATION": { + "group": 11, + "height": 9, + "map": 7, + "name": "GOLDENROD_MAGNET_TRAIN_STATION", + "width": 10 + }, + "GOLDENROD_NAME_RATER": { + "group": 11, + "height": 4, + "map": 10, + "name": "GOLDENROD_NAME_RATER", + "width": 4 + }, + "GOLDENROD_POKECENTER_1F": { + "group": 11, + "height": 4, + "map": 20, + "name": "GOLDENROD_POKECENTER_1F", + "width": 5 + }, + "GOLDENROD_PP_SPEECH_HOUSE": { + "group": 11, + "height": 4, + "map": 9, + "name": "GOLDENROD_PP_SPEECH_HOUSE", + "width": 4 + }, + "GOLDENROD_UNDERGROUND": { + "group": 3, + "height": 18, + "map": 53, + "name": "GOLDENROD_UNDERGROUND", + "width": 15 + }, + "GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES": { + "group": 3, + "height": 18, + "map": 54, + "name": "GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES", + "width": 15 + }, + "GOLDENROD_UNDERGROUND_WAREHOUSE": { + "group": 3, + "height": 9, + "map": 56, + "name": "GOLDENROD_UNDERGROUND_WAREHOUSE", + "width": 10 + }, + "GUIDE_GENTS_HOUSE": { + "group": 26, + "height": 4, + "map": 7, + "name": "GUIDE_GENTS_HOUSE", + "width": 4 + }, + "HALL_OF_FAME": { + "group": 16, + "height": 7, + "map": 8, + "name": "HALL_OF_FAME", + "width": 5 + }, + "ICE_PATH_1F": { + "group": 3, + "height": 18, + "map": 61, + "name": "ICE_PATH_1F", + "width": 20 + }, + "ICE_PATH_B1F": { + "group": 3, + "height": 18, + "map": 62, + "name": "ICE_PATH_B1F", + "width": 10 + }, + "ICE_PATH_B2F_BLACKTHORN_SIDE": { + "group": 3, + "height": 9, + "map": 64, + "name": "ICE_PATH_B2F_BLACKTHORN_SIDE", + "width": 5 + }, + "ICE_PATH_B2F_MAHOGANY_SIDE": { + "group": 3, + "height": 9, + "map": 63, + "name": "ICE_PATH_B2F_MAHOGANY_SIDE", + "width": 10 + }, + "ICE_PATH_B3F": { + "group": 3, + "height": 9, + "map": 65, + "name": "ICE_PATH_B3F", + "width": 10 + }, + "ILEX_FOREST": { + "group": 3, + "height": 27, + "map": 52, + "name": "ILEX_FOREST", + "width": 15 + }, + "ILEX_FOREST_AZALEA_GATE": { + "group": 11, + "height": 4, + "map": 22, + "name": "ILEX_FOREST_AZALEA_GATE", + "width": 5 + }, + "INDIGO_PLATEAU_POKECENTER_1F": { + "group": 16, + "height": 7, + "map": 2, + "name": "INDIGO_PLATEAU_POKECENTER_1F", + "width": 9 + }, + "KARENS_ROOM": { + "group": 16, + "height": 9, + "map": 6, + "name": "KARENS_ROOM", + "width": 5 + }, + "KOGAS_ROOM": { + "group": 16, + "height": 9, + "map": 4, + "name": "KOGAS_ROOM", + "width": 5 + }, + "KURTS_HOUSE": { + "group": 8, + "height": 4, + "map": 4, + "name": "KURTS_HOUSE", + "width": 8 + }, + "LAKE_OF_RAGE": { + "group": 9, + "height": 18, + "map": 6, + "name": "LAKE_OF_RAGE", + "width": 20 + }, + "LAKE_OF_RAGE_HIDDEN_POWER_HOUSE": { + "group": 9, + "height": 4, + "map": 1, + "name": "LAKE_OF_RAGE_HIDDEN_POWER_HOUSE", + "width": 4 + }, + "LAKE_OF_RAGE_MAGIKARP_HOUSE": { + "group": 9, + "height": 4, + "map": 2, + "name": "LAKE_OF_RAGE_MAGIKARP_HOUSE", + "width": 4 + }, + "LANCES_ROOM": { + "group": 16, + "height": 12, + "map": 7, + "name": "LANCES_ROOM", + "width": 5 + }, + "LAVENDER_MART": { + "group": 18, + "height": 4, + "map": 10, + "name": "LAVENDER_MART", + "width": 6 + }, + "LAVENDER_NAME_RATER": { + "group": 18, + "height": 4, + "map": 9, + "name": "LAVENDER_NAME_RATER", + "width": 4 + }, + "LAVENDER_POKECENTER_1F": { + "group": 18, + "height": 4, + "map": 5, + "name": "LAVENDER_POKECENTER_1F", + "width": 5 + }, + "LAVENDER_POKECENTER_2F_BETA": { + "group": 18, + "height": 4, + "map": 6, + "name": "LAVENDER_POKECENTER_2F_BETA", + "width": 8 + }, + "LAVENDER_SPEECH_HOUSE": { + "group": 18, + "height": 4, + "map": 8, + "name": "LAVENDER_SPEECH_HOUSE", + "width": 4 + }, + "LAVENDER_TOWN": { + "group": 18, + "height": 9, + "map": 4, + "name": "LAVENDER_TOWN", + "width": 10 + }, + "LAV_RADIO_TOWER_1F": { + "group": 18, + "height": 4, + "map": 12, + "name": "LAV_RADIO_TOWER_1F", + "width": 10 + }, + "MAHOGANY_GYM": { + "group": 2, + "height": 9, + "map": 2, + "name": "MAHOGANY_GYM", + "width": 5 + }, + "MAHOGANY_MART_1F": { + "group": 3, + "height": 4, + "map": 48, + "name": "MAHOGANY_MART_1F", + "width": 4 + }, + "MAHOGANY_POKECENTER_1F": { + "group": 2, + "height": 4, + "map": 3, + "name": "MAHOGANY_POKECENTER_1F", + "width": 5 + }, + "MAHOGANY_RED_GYARADOS_SPEECH_HOUSE": { + "group": 2, + "height": 4, + "map": 1, + "name": "MAHOGANY_RED_GYARADOS_SPEECH_HOUSE", + "width": 4 + }, + "MAHOGANY_TOWN": { + "group": 2, + "height": 9, + "map": 7, + "name": "MAHOGANY_TOWN", + "width": 10 + }, + "MANIAS_HOUSE": { + "group": 22, + "height": 4, + "map": 4, + "name": "MANIAS_HOUSE", + "width": 4 + }, + "MOBILE_BATTLE_ROOM": { + "group": 20, + "height": 4, + "map": 6, + "name": "MOBILE_BATTLE_ROOM", + "width": 5 + }, + "MOBILE_TRADE_ROOM": { + "group": 20, + "height": 4, + "map": 5, + "name": "MOBILE_TRADE_ROOM", + "width": 5 + }, + "MOUNT_MOON": { + "group": 3, + "height": 9, + "map": 85, + "name": "MOUNT_MOON", + "width": 15 + }, + "MOUNT_MOON_GIFT_SHOP": { + "group": 15, + "height": 4, + "map": 11, + "name": "MOUNT_MOON_GIFT_SHOP", + "width": 4 + }, + "MOUNT_MOON_SQUARE": { + "group": 15, + "height": 9, + "map": 10, + "name": "MOUNT_MOON_SQUARE", + "width": 15 + }, + "MOUNT_MORTAR_1F_INSIDE": { + "group": 3, + "height": 27, + "map": 58, + "name": "MOUNT_MORTAR_1F_INSIDE", + "width": 20 + }, + "MOUNT_MORTAR_1F_OUTSIDE": { + "group": 3, + "height": 18, + "map": 57, + "name": "MOUNT_MORTAR_1F_OUTSIDE", + "width": 20 + }, + "MOUNT_MORTAR_2F_INSIDE": { + "group": 3, + "height": 18, + "map": 59, + "name": "MOUNT_MORTAR_2F_INSIDE", + "width": 20 + }, + "MOUNT_MORTAR_B1F": { + "group": 3, + "height": 18, + "map": 60, + "name": "MOUNT_MORTAR_B1F", + "width": 20 + }, + "MOVE_DELETERS_HOUSE": { + "group": 5, + "height": 4, + "map": 7, + "name": "MOVE_DELETERS_HOUSE", + "width": 4 + }, + "MR_FUJIS_HOUSE": { + "group": 18, + "height": 4, + "map": 7, + "name": "MR_FUJIS_HOUSE", + "width": 5 + }, + "MR_POKEMONS_HOUSE": { + "group": 26, + "height": 4, + "map": 10, + "name": "MR_POKEMONS_HOUSE", + "width": 4 + }, + "MR_PSYCHICS_HOUSE": { + "group": 25, + "height": 4, + "map": 8, + "name": "MR_PSYCHICS_HOUSE", + "width": 4 + }, + "NATIONAL_PARK": { + "group": 3, + "height": 27, + "map": 15, + "name": "NATIONAL_PARK", + "width": 20 + }, + "NATIONAL_PARK_BUG_CONTEST": { + "group": 3, + "height": 27, + "map": 16, + "name": "NATIONAL_PARK_BUG_CONTEST", + "width": 20 + }, + "NEW_BARK_TOWN": { + "group": 24, + "height": 9, + "map": 4, + "name": "NEW_BARK_TOWN", + "width": 10 + }, + "OAKS_LAB": { + "group": 13, + "height": 6, + "map": 6, + "name": "OAKS_LAB", + "width": 5 + }, + "OLIVINE_CAFE": { + "group": 1, + "height": 4, + "map": 7, + "name": "OLIVINE_CAFE", + "width": 4 + }, + "OLIVINE_CITY": { + "group": 1, + "height": 18, + "map": 14, + "name": "OLIVINE_CITY", + "width": 20 + }, + "OLIVINE_GOOD_ROD_HOUSE": { + "group": 1, + "height": 4, + "map": 6, + "name": "OLIVINE_GOOD_ROD_HOUSE", + "width": 4 + }, + "OLIVINE_GYM": { + "group": 1, + "height": 8, + "map": 2, + "name": "OLIVINE_GYM", + "width": 5 + }, + "OLIVINE_HOUSE_BETA": { + "group": 1, + "height": 4, + "map": 4, + "name": "OLIVINE_HOUSE_BETA", + "width": 4 + }, + "OLIVINE_LIGHTHOUSE_1F": { + "group": 3, + "height": 9, + "map": 42, + "name": "OLIVINE_LIGHTHOUSE_1F", + "width": 10 + }, + "OLIVINE_LIGHTHOUSE_2F": { + "group": 3, + "height": 9, + "map": 43, + "name": "OLIVINE_LIGHTHOUSE_2F", + "width": 10 + }, + "OLIVINE_LIGHTHOUSE_3F": { + "group": 3, + "height": 9, + "map": 44, + "name": "OLIVINE_LIGHTHOUSE_3F", + "width": 10 + }, + "OLIVINE_LIGHTHOUSE_4F": { + "group": 3, + "height": 9, + "map": 45, + "name": "OLIVINE_LIGHTHOUSE_4F", + "width": 10 + }, + "OLIVINE_LIGHTHOUSE_5F": { + "group": 3, + "height": 9, + "map": 46, + "name": "OLIVINE_LIGHTHOUSE_5F", + "width": 10 + }, + "OLIVINE_LIGHTHOUSE_6F": { + "group": 3, + "height": 9, + "map": 47, + "name": "OLIVINE_LIGHTHOUSE_6F", + "width": 10 + }, + "OLIVINE_MART": { + "group": 1, + "height": 4, + "map": 8, + "name": "OLIVINE_MART", + "width": 6 + }, + "OLIVINE_POKECENTER_1F": { + "group": 1, + "height": 4, + "map": 1, + "name": "OLIVINE_POKECENTER_1F", + "width": 5 + }, + "OLIVINE_PORT": { + "group": 15, + "height": 18, + "map": 1, + "name": "OLIVINE_PORT", + "width": 10 + }, + "OLIVINE_PORT_PASSAGE": { + "group": 15, + "height": 9, + "map": 8, + "name": "OLIVINE_PORT_PASSAGE", + "width": 10 + }, + "OLIVINE_PUNISHMENT_SPEECH_HOUSE": { + "group": 1, + "height": 4, + "map": 5, + "name": "OLIVINE_PUNISHMENT_SPEECH_HOUSE", + "width": 4 + }, + "OLIVINE_TIMS_HOUSE": { + "group": 1, + "height": 4, + "map": 3, + "name": "OLIVINE_TIMS_HOUSE", + "width": 4 + }, + "PALLET_TOWN": { + "group": 13, + "height": 9, + "map": 2, + "name": "PALLET_TOWN", + "width": 10 + }, + "PEWTER_CITY": { + "group": 14, + "height": 18, + "map": 2, + "name": "PEWTER_CITY", + "width": 20 + }, + "PEWTER_GYM": { + "group": 14, + "height": 7, + "map": 4, + "name": "PEWTER_GYM", + "width": 5 + }, + "PEWTER_MART": { + "group": 14, + "height": 4, + "map": 5, + "name": "PEWTER_MART", + "width": 6 + }, + "PEWTER_NIDORAN_SPEECH_HOUSE": { + "group": 14, + "height": 4, + "map": 3, + "name": "PEWTER_NIDORAN_SPEECH_HOUSE", + "width": 4 + }, + "PEWTER_POKECENTER_1F": { + "group": 14, + "height": 4, + "map": 6, + "name": "PEWTER_POKECENTER_1F", + "width": 5 + }, + "PEWTER_POKECENTER_2F_BETA": { + "group": 14, + "height": 4, + "map": 7, + "name": "PEWTER_POKECENTER_2F_BETA", + "width": 8 + }, + "PEWTER_SNOOZE_SPEECH_HOUSE": { + "group": 14, + "height": 4, + "map": 8, + "name": "PEWTER_SNOOZE_SPEECH_HOUSE", + "width": 4 + }, + "PLAYERS_HOUSE_1F": { + "group": 24, + "height": 4, + "map": 6, + "name": "PLAYERS_HOUSE_1F", + "width": 5 + }, + "PLAYERS_HOUSE_2F": { + "group": 24, + "height": 3, + "map": 7, + "name": "PLAYERS_HOUSE_2F", + "width": 4 + }, + "PLAYERS_NEIGHBORS_HOUSE": { + "group": 24, + "height": 4, + "map": 8, + "name": "PLAYERS_NEIGHBORS_HOUSE", + "width": 4 + }, + "POKECENTER_2F": { + "group": 20, + "height": 4, + "map": 1, + "name": "POKECENTER_2F", + "width": 8 + }, + "POKECOM_CENTER_ADMIN_OFFICE_MOBILE": { + "group": 11, + "height": 16, + "map": 21, + "name": "POKECOM_CENTER_ADMIN_OFFICE_MOBILE", + "width": 16 + }, + "POKEMON_FAN_CLUB": { + "group": 12, + "height": 4, + "map": 7, + "name": "POKEMON_FAN_CLUB", + "width": 5 + }, + "POKE_SEERS_HOUSE": { + "group": 22, + "height": 4, + "map": 10, + "name": "POKE_SEERS_HOUSE", + "width": 4 + }, + "POWER_PLANT": { + "group": 7, + "height": 9, + "map": 10, + "name": "POWER_PLANT", + "width": 10 + }, + "RADIO_TOWER_1F": { + "group": 3, + "height": 4, + "map": 17, + "name": "RADIO_TOWER_1F", + "width": 9 + }, + "RADIO_TOWER_2F": { + "group": 3, + "height": 4, + "map": 18, + "name": "RADIO_TOWER_2F", + "width": 9 + }, + "RADIO_TOWER_3F": { + "group": 3, + "height": 4, + "map": 19, + "name": "RADIO_TOWER_3F", + "width": 9 + }, + "RADIO_TOWER_4F": { + "group": 3, + "height": 4, + "map": 20, + "name": "RADIO_TOWER_4F", + "width": 9 + }, + "RADIO_TOWER_5F": { + "group": 3, + "height": 4, + "map": 21, + "name": "RADIO_TOWER_5F", + "width": 9 + }, + "REDS_HOUSE_1F": { + "group": 13, + "height": 4, + "map": 3, + "name": "REDS_HOUSE_1F", + "width": 4 + }, + "REDS_HOUSE_2F": { + "group": 13, + "height": 4, + "map": 4, + "name": "REDS_HOUSE_2F", + "width": 4 + }, + "ROCK_TUNNEL_1F": { + "group": 3, + "height": 18, + "map": 87, + "name": "ROCK_TUNNEL_1F", + "width": 15 + }, + "ROCK_TUNNEL_B1F": { + "group": 3, + "height": 18, + "map": 88, + "name": "ROCK_TUNNEL_B1F", + "width": 15 + }, + "ROUTE_1": { + "group": 13, + "height": 18, + "map": 1, + "name": "ROUTE_1", + "width": 10 + }, + "ROUTE_10_NORTH": { + "group": 7, + "height": 9, + "map": 14, + "name": "ROUTE_10_NORTH", + "width": 10 + }, + "ROUTE_10_POKECENTER_1F": { + "group": 7, + "height": 4, + "map": 8, + "name": "ROUTE_10_POKECENTER_1F", + "width": 5 + }, + "ROUTE_10_POKECENTER_2F_BETA": { + "group": 7, + "height": 4, + "map": 9, + "name": "ROUTE_10_POKECENTER_2F_BETA", + "width": 8 + }, + "ROUTE_10_SOUTH": { + "group": 18, + "height": 9, + "map": 3, + "name": "ROUTE_10_SOUTH", + "width": 10 + }, + "ROUTE_11": { + "group": 12, + "height": 9, + "map": 2, + "name": "ROUTE_11", + "width": 20 + }, + "ROUTE_12": { + "group": 18, + "height": 27, + "map": 2, + "name": "ROUTE_12", + "width": 10 + }, + "ROUTE_12_SUPER_ROD_HOUSE": { + "group": 18, + "height": 4, + "map": 14, + "name": "ROUTE_12_SUPER_ROD_HOUSE", + "width": 4 + }, + "ROUTE_13": { + "group": 17, + "height": 9, + "map": 1, + "name": "ROUTE_13", + "width": 30 + }, + "ROUTE_14": { + "group": 17, + "height": 18, + "map": 2, + "name": "ROUTE_14", + "width": 10 + }, + "ROUTE_15": { + "group": 17, + "height": 9, + "map": 3, + "name": "ROUTE_15", + "width": 20 + }, + "ROUTE_15_FUCHSIA_GATE": { + "group": 17, + "height": 4, + "map": 13, + "name": "ROUTE_15_FUCHSIA_GATE", + "width": 5 + }, + "ROUTE_16": { + "group": 21, + "height": 9, + "map": 2, + "name": "ROUTE_16", + "width": 10 + }, + "ROUTE_16_FUCHSIA_SPEECH_HOUSE": { + "group": 21, + "height": 4, + "map": 23, + "name": "ROUTE_16_FUCHSIA_SPEECH_HOUSE", + "width": 4 + }, + "ROUTE_16_GATE": { + "group": 21, + "height": 4, + "map": 24, + "name": "ROUTE_16_GATE", + "width": 5 + }, + "ROUTE_17": { + "group": 21, + "height": 45, + "map": 3, + "name": "ROUTE_17", + "width": 10 + }, + "ROUTE_17_ROUTE_18_GATE": { + "group": 21, + "height": 4, + "map": 26, + "name": "ROUTE_17_ROUTE_18_GATE", + "width": 5 + }, + "ROUTE_18": { + "group": 17, + "height": 9, + "map": 4, + "name": "ROUTE_18", + "width": 10 + }, + "ROUTE_19": { + "group": 6, + "height": 18, + "map": 5, + "name": "ROUTE_19", + "width": 10 + }, + "ROUTE_19_FUCHSIA_GATE": { + "group": 6, + "height": 4, + "map": 3, + "name": "ROUTE_19_FUCHSIA_GATE", + "width": 5 + }, + "ROUTE_2": { + "group": 23, + "height": 27, + "map": 1, + "name": "ROUTE_2", + "width": 10 + }, + "ROUTE_20": { + "group": 6, + "height": 9, + "map": 6, + "name": "ROUTE_20", + "width": 30 + }, + "ROUTE_21": { + "group": 6, + "height": 18, + "map": 7, + "name": "ROUTE_21", + "width": 10 + }, + "ROUTE_22": { + "group": 23, + "height": 9, + "map": 2, + "name": "ROUTE_22", + "width": 20 + }, + "ROUTE_23": { + "group": 16, + "height": 9, + "map": 1, + "name": "ROUTE_23", + "width": 10 + }, + "ROUTE_24": { + "group": 7, + "height": 9, + "map": 15, + "name": "ROUTE_24", + "width": 10 + }, + "ROUTE_25": { + "group": 7, + "height": 9, + "map": 16, + "name": "ROUTE_25", + "width": 30 + }, + "ROUTE_26": { + "group": 24, + "height": 54, + "map": 1, + "name": "ROUTE_26", + "width": 10 + }, + "ROUTE_26_HEAL_HOUSE": { + "group": 24, + "height": 4, + "map": 10, + "name": "ROUTE_26_HEAL_HOUSE", + "width": 4 + }, + "ROUTE_27": { + "group": 24, + "height": 9, + "map": 2, + "name": "ROUTE_27", + "width": 40 + }, + "ROUTE_27_SANDSTORM_HOUSE": { + "group": 24, + "height": 4, + "map": 12, + "name": "ROUTE_27_SANDSTORM_HOUSE", + "width": 4 + }, + "ROUTE_28": { + "group": 19, + "height": 9, + "map": 1, + "name": "ROUTE_28", + "width": 20 + }, + "ROUTE_28_STEEL_WING_HOUSE": { + "group": 19, + "height": 4, + "map": 4, + "name": "ROUTE_28_STEEL_WING_HOUSE", + "width": 4 + }, + "ROUTE_29": { + "group": 24, + "height": 9, + "map": 3, + "name": "ROUTE_29", + "width": 30 + }, + "ROUTE_29_ROUTE_46_GATE": { + "group": 24, + "height": 4, + "map": 13, + "name": "ROUTE_29_ROUTE_46_GATE", + "width": 5 + }, + "ROUTE_2_GATE": { + "group": 23, + "height": 4, + "map": 12, + "name": "ROUTE_2_GATE", + "width": 5 + }, + "ROUTE_2_NUGGET_HOUSE": { + "group": 23, + "height": 4, + "map": 11, + "name": "ROUTE_2_NUGGET_HOUSE", + "width": 4 + }, + "ROUTE_3": { + "group": 14, + "height": 9, + "map": 1, + "name": "ROUTE_3", + "width": 30 + }, + "ROUTE_30": { + "group": 26, + "height": 27, + "map": 1, + "name": "ROUTE_30", + "width": 10 + }, + "ROUTE_30_BERRY_HOUSE": { + "group": 26, + "height": 4, + "map": 9, + "name": "ROUTE_30_BERRY_HOUSE", + "width": 4 + }, + "ROUTE_31": { + "group": 26, + "height": 9, + "map": 2, + "name": "ROUTE_31", + "width": 20 + }, + "ROUTE_31_VIOLET_GATE": { + "group": 26, + "height": 4, + "map": 11, + "name": "ROUTE_31_VIOLET_GATE", + "width": 5 + }, + "ROUTE_32": { + "group": 10, + "height": 45, + "map": 1, + "name": "ROUTE_32", + "width": 10 + }, + "ROUTE_32_POKECENTER_1F": { + "group": 10, + "height": 4, + "map": 13, + "name": "ROUTE_32_POKECENTER_1F", + "width": 5 + }, + "ROUTE_32_RUINS_OF_ALPH_GATE": { + "group": 10, + "height": 4, + "map": 12, + "name": "ROUTE_32_RUINS_OF_ALPH_GATE", + "width": 5 + }, + "ROUTE_33": { + "group": 8, + "height": 9, + "map": 6, + "name": "ROUTE_33", + "width": 10 + }, + "ROUTE_34": { + "group": 11, + "height": 27, + "map": 1, + "name": "ROUTE_34", + "width": 10 + }, + "ROUTE_34_ILEX_FOREST_GATE": { + "group": 11, + "height": 4, + "map": 23, + "name": "ROUTE_34_ILEX_FOREST_GATE", + "width": 5 + }, + "ROUTE_35": { + "group": 10, + "height": 18, + "map": 2, + "name": "ROUTE_35", + "width": 10 + }, + "ROUTE_35_GOLDENROD_GATE": { + "group": 10, + "height": 4, + "map": 14, + "name": "ROUTE_35_GOLDENROD_GATE", + "width": 5 + }, + "ROUTE_35_NATIONAL_PARK_GATE": { + "group": 10, + "height": 4, + "map": 15, + "name": "ROUTE_35_NATIONAL_PARK_GATE", + "width": 4 + }, + "ROUTE_36": { + "group": 10, + "height": 9, + "map": 3, + "name": "ROUTE_36", + "width": 30 + }, + "ROUTE_36_NATIONAL_PARK_GATE": { + "group": 10, + "height": 4, + "map": 17, + "name": "ROUTE_36_NATIONAL_PARK_GATE", + "width": 5 + }, + "ROUTE_36_RUINS_OF_ALPH_GATE": { + "group": 10, + "height": 4, + "map": 16, + "name": "ROUTE_36_RUINS_OF_ALPH_GATE", + "width": 5 + }, + "ROUTE_37": { + "group": 10, + "height": 9, + "map": 4, + "name": "ROUTE_37", + "width": 10 + }, + "ROUTE_38": { + "group": 1, + "height": 9, + "map": 12, + "name": "ROUTE_38", + "width": 20 + }, + "ROUTE_38_ECRUTEAK_GATE": { + "group": 1, + "height": 4, + "map": 9, + "name": "ROUTE_38_ECRUTEAK_GATE", + "width": 5 + }, + "ROUTE_39": { + "group": 1, + "height": 18, + "map": 13, + "name": "ROUTE_39", + "width": 10 + }, + "ROUTE_39_BARN": { + "group": 1, + "height": 4, + "map": 10, + "name": "ROUTE_39_BARN", + "width": 4 + }, + "ROUTE_39_FARMHOUSE": { + "group": 1, + "height": 4, + "map": 11, + "name": "ROUTE_39_FARMHOUSE", + "width": 4 + }, + "ROUTE_4": { + "group": 7, + "height": 9, + "map": 12, + "name": "ROUTE_4", + "width": 20 + }, + "ROUTE_40": { + "group": 22, + "height": 18, + "map": 1, + "name": "ROUTE_40", + "width": 10 + }, + "ROUTE_40_BATTLE_TOWER_GATE": { + "group": 22, + "height": 4, + "map": 15, + "name": "ROUTE_40_BATTLE_TOWER_GATE", + "width": 5 + }, + "ROUTE_41": { + "group": 22, + "height": 27, + "map": 2, + "name": "ROUTE_41", + "width": 25 + }, + "ROUTE_42": { + "group": 2, + "height": 9, + "map": 5, + "name": "ROUTE_42", + "width": 30 + }, + "ROUTE_42_ECRUTEAK_GATE": { + "group": 2, + "height": 4, + "map": 4, + "name": "ROUTE_42_ECRUTEAK_GATE", + "width": 5 + }, + "ROUTE_43": { + "group": 9, + "height": 27, + "map": 5, + "name": "ROUTE_43", + "width": 10 + }, + "ROUTE_43_GATE": { + "group": 9, + "height": 4, + "map": 4, + "name": "ROUTE_43_GATE", + "width": 5 + }, + "ROUTE_43_MAHOGANY_GATE": { + "group": 9, + "height": 4, + "map": 3, + "name": "ROUTE_43_MAHOGANY_GATE", + "width": 5 + }, + "ROUTE_44": { + "group": 2, + "height": 9, + "map": 6, + "name": "ROUTE_44", + "width": 30 + }, + "ROUTE_45": { + "group": 5, + "height": 45, + "map": 8, + "name": "ROUTE_45", + "width": 10 + }, + "ROUTE_46": { + "group": 5, + "height": 18, + "map": 9, + "name": "ROUTE_46", + "width": 10 + }, + "ROUTE_5": { + "group": 25, + "height": 9, + "map": 1, + "name": "ROUTE_5", + "width": 10 + }, + "ROUTE_5_CLEANSE_TAG_HOUSE": { + "group": 25, + "height": 4, + "map": 15, + "name": "ROUTE_5_CLEANSE_TAG_HOUSE", + "width": 4 + }, + "ROUTE_5_SAFFRON_GATE": { + "group": 25, + "height": 4, + "map": 14, + "name": "ROUTE_5_SAFFRON_GATE", + "width": 5 + }, + "ROUTE_5_UNDERGROUND_PATH_ENTRANCE": { + "group": 25, + "height": 4, + "map": 13, + "name": "ROUTE_5_UNDERGROUND_PATH_ENTRANCE", + "width": 4 + }, + "ROUTE_6": { + "group": 12, + "height": 9, + "map": 1, + "name": "ROUTE_6", + "width": 10 + }, + "ROUTE_6_SAFFRON_GATE": { + "group": 12, + "height": 4, + "map": 12, + "name": "ROUTE_6_SAFFRON_GATE", + "width": 5 + }, + "ROUTE_6_UNDERGROUND_PATH_ENTRANCE": { + "group": 12, + "height": 4, + "map": 13, + "name": "ROUTE_6_UNDERGROUND_PATH_ENTRANCE", + "width": 4 + }, + "ROUTE_7": { + "group": 21, + "height": 9, + "map": 1, + "name": "ROUTE_7", + "width": 10 + }, + "ROUTE_7_SAFFRON_GATE": { + "group": 21, + "height": 4, + "map": 25, + "name": "ROUTE_7_SAFFRON_GATE", + "width": 5 + }, + "ROUTE_8": { + "group": 18, + "height": 9, + "map": 1, + "name": "ROUTE_8", + "width": 20 + }, + "ROUTE_8_SAFFRON_GATE": { + "group": 18, + "height": 4, + "map": 13, + "name": "ROUTE_8_SAFFRON_GATE", + "width": 5 + }, + "ROUTE_9": { + "group": 7, + "height": 9, + "map": 13, + "name": "ROUTE_9", + "width": 30 + }, + "RUINS_OF_ALPH_AERODACTYL_CHAMBER": { + "group": 3, + "height": 5, + "map": 26, + "name": "RUINS_OF_ALPH_AERODACTYL_CHAMBER", + "width": 4 + }, + "RUINS_OF_ALPH_AERODACTYL_ITEM_ROOM": { + "group": 3, + "height": 5, + "map": 32, + "name": "RUINS_OF_ALPH_AERODACTYL_ITEM_ROOM", + "width": 4 + }, + "RUINS_OF_ALPH_AERODACTYL_WORD_ROOM": { + "group": 3, + "height": 7, + "map": 36, + "name": "RUINS_OF_ALPH_AERODACTYL_WORD_ROOM", + "width": 10 + }, + "RUINS_OF_ALPH_HO_OH_CHAMBER": { + "group": 3, + "height": 5, + "map": 23, + "name": "RUINS_OF_ALPH_HO_OH_CHAMBER", + "width": 4 + }, + "RUINS_OF_ALPH_HO_OH_ITEM_ROOM": { + "group": 3, + "height": 5, + "map": 29, + "name": "RUINS_OF_ALPH_HO_OH_ITEM_ROOM", + "width": 4 + }, + "RUINS_OF_ALPH_HO_OH_WORD_ROOM": { + "group": 3, + "height": 12, + "map": 33, + "name": "RUINS_OF_ALPH_HO_OH_WORD_ROOM", + "width": 10 + }, + "RUINS_OF_ALPH_INNER_CHAMBER": { + "group": 3, + "height": 14, + "map": 27, + "name": "RUINS_OF_ALPH_INNER_CHAMBER", + "width": 10 + }, + "RUINS_OF_ALPH_KABUTO_CHAMBER": { + "group": 3, + "height": 5, + "map": 24, + "name": "RUINS_OF_ALPH_KABUTO_CHAMBER", + "width": 4 + }, + "RUINS_OF_ALPH_KABUTO_ITEM_ROOM": { + "group": 3, + "height": 5, + "map": 30, + "name": "RUINS_OF_ALPH_KABUTO_ITEM_ROOM", + "width": 4 + }, + "RUINS_OF_ALPH_KABUTO_WORD_ROOM": { + "group": 3, + "height": 7, + "map": 34, + "name": "RUINS_OF_ALPH_KABUTO_WORD_ROOM", + "width": 10 + }, + "RUINS_OF_ALPH_OMANYTE_CHAMBER": { + "group": 3, + "height": 5, + "map": 25, + "name": "RUINS_OF_ALPH_OMANYTE_CHAMBER", + "width": 4 + }, + "RUINS_OF_ALPH_OMANYTE_ITEM_ROOM": { + "group": 3, + "height": 5, + "map": 31, + "name": "RUINS_OF_ALPH_OMANYTE_ITEM_ROOM", + "width": 4 + }, + "RUINS_OF_ALPH_OMANYTE_WORD_ROOM": { + "group": 3, + "height": 8, + "map": 35, + "name": "RUINS_OF_ALPH_OMANYTE_WORD_ROOM", + "width": 10 + }, + "RUINS_OF_ALPH_OUTSIDE": { + "group": 3, + "height": 18, + "map": 22, + "name": "RUINS_OF_ALPH_OUTSIDE", + "width": 10 + }, + "RUINS_OF_ALPH_RESEARCH_CENTER": { + "group": 3, + "height": 4, + "map": 28, + "name": "RUINS_OF_ALPH_RESEARCH_CENTER", + "width": 4 + }, + "SAFARI_ZONE_BETA": { + "group": 3, + "height": 18, + "map": 90, + "name": "SAFARI_ZONE_BETA", + "width": 10 + }, + "SAFARI_ZONE_FUCHSIA_GATE_BETA": { + "group": 3, + "height": 4, + "map": 89, + "name": "SAFARI_ZONE_FUCHSIA_GATE_BETA", + "width": 5 + }, + "SAFARI_ZONE_MAIN_OFFICE": { + "group": 17, + "height": 4, + "map": 7, + "name": "SAFARI_ZONE_MAIN_OFFICE", + "width": 4 + }, + "SAFARI_ZONE_WARDENS_HOME": { + "group": 17, + "height": 4, + "map": 12, + "name": "SAFARI_ZONE_WARDENS_HOME", + "width": 5 + }, + "SAFFRON_CITY": { + "group": 25, + "height": 18, + "map": 2, + "name": "SAFFRON_CITY", + "width": 20 + }, + "SAFFRON_GYM": { + "group": 25, + "height": 9, + "map": 4, + "name": "SAFFRON_GYM", + "width": 10 + }, + "SAFFRON_MAGNET_TRAIN_STATION": { + "group": 25, + "height": 9, + "map": 9, + "name": "SAFFRON_MAGNET_TRAIN_STATION", + "width": 10 + }, + "SAFFRON_MART": { + "group": 25, + "height": 4, + "map": 5, + "name": "SAFFRON_MART", + "width": 6 + }, + "SAFFRON_POKECENTER_1F": { + "group": 25, + "height": 4, + "map": 6, + "name": "SAFFRON_POKECENTER_1F", + "width": 5 + }, + "SAFFRON_POKECENTER_2F_BETA": { + "group": 25, + "height": 4, + "map": 7, + "name": "SAFFRON_POKECENTER_2F_BETA", + "width": 8 + }, + "SEAFOAM_GYM": { + "group": 6, + "height": 4, + "map": 4, + "name": "SEAFOAM_GYM", + "width": 5 + }, + "SILPH_CO_1F": { + "group": 25, + "height": 4, + "map": 10, + "name": "SILPH_CO_1F", + "width": 8 + }, + "SILVER_CAVE_ITEM_ROOMS": { + "group": 3, + "height": 9, + "map": 77, + "name": "SILVER_CAVE_ITEM_ROOMS", + "width": 10 + }, + "SILVER_CAVE_OUTSIDE": { + "group": 19, + "height": 18, + "map": 2, + "name": "SILVER_CAVE_OUTSIDE", + "width": 20 + }, + "SILVER_CAVE_POKECENTER_1F": { + "group": 19, + "height": 4, + "map": 3, + "name": "SILVER_CAVE_POKECENTER_1F", + "width": 5 + }, + "SILVER_CAVE_ROOM_1": { + "group": 3, + "height": 18, + "map": 74, + "name": "SILVER_CAVE_ROOM_1", + "width": 10 + }, + "SILVER_CAVE_ROOM_2": { + "group": 3, + "height": 18, + "map": 75, + "name": "SILVER_CAVE_ROOM_2", + "width": 15 + }, + "SILVER_CAVE_ROOM_3": { + "group": 3, + "height": 18, + "map": 76, + "name": "SILVER_CAVE_ROOM_3", + "width": 10 + }, + "SLOWPOKE_WELL_B1F": { + "group": 3, + "height": 9, + "map": 40, + "name": "SLOWPOKE_WELL_B1F", + "width": 10 + }, + "SLOWPOKE_WELL_B2F": { + "group": 3, + "height": 9, + "map": 41, + "name": "SLOWPOKE_WELL_B2F", + "width": 10 + }, + "SOUL_HOUSE": { + "group": 18, + "height": 4, + "map": 11, + "name": "SOUL_HOUSE", + "width": 5 + }, + "SPROUT_TOWER_1F": { + "group": 3, + "height": 8, + "map": 1, + "name": "SPROUT_TOWER_1F", + "width": 10 + }, + "SPROUT_TOWER_2F": { + "group": 3, + "height": 8, + "map": 2, + "name": "SPROUT_TOWER_2F", + "width": 10 + }, + "SPROUT_TOWER_3F": { + "group": 3, + "height": 8, + "map": 3, + "name": "SPROUT_TOWER_3F", + "width": 10 + }, + "TEAM_ROCKET_BASE_B1F": { + "group": 3, + "height": 9, + "map": 49, + "name": "TEAM_ROCKET_BASE_B1F", + "width": 15 + }, + "TEAM_ROCKET_BASE_B2F": { + "group": 3, + "height": 9, + "map": 50, + "name": "TEAM_ROCKET_BASE_B2F", + "width": 15 + }, + "TEAM_ROCKET_BASE_B3F": { + "group": 3, + "height": 9, + "map": 51, + "name": "TEAM_ROCKET_BASE_B3F", + "width": 15 + }, + "TIME_CAPSULE": { + "group": 20, + "height": 4, + "map": 4, + "name": "TIME_CAPSULE", + "width": 5 + }, + "TIN_TOWER_1F": { + "group": 3, + "height": 9, + "map": 4, + "name": "TIN_TOWER_1F", + "width": 10 + }, + "TIN_TOWER_2F": { + "group": 3, + "height": 9, + "map": 5, + "name": "TIN_TOWER_2F", + "width": 10 + }, + "TIN_TOWER_3F": { + "group": 3, + "height": 9, + "map": 6, + "name": "TIN_TOWER_3F", + "width": 10 + }, + "TIN_TOWER_4F": { + "group": 3, + "height": 9, + "map": 7, + "name": "TIN_TOWER_4F", + "width": 10 + }, + "TIN_TOWER_5F": { + "group": 3, + "height": 9, + "map": 8, + "name": "TIN_TOWER_5F", + "width": 10 + }, + "TIN_TOWER_6F": { + "group": 3, + "height": 9, + "map": 9, + "name": "TIN_TOWER_6F", + "width": 10 + }, + "TIN_TOWER_7F": { + "group": 3, + "height": 9, + "map": 10, + "name": "TIN_TOWER_7F", + "width": 10 + }, + "TIN_TOWER_8F": { + "group": 3, + "height": 9, + "map": 11, + "name": "TIN_TOWER_8F", + "width": 10 + }, + "TIN_TOWER_9F": { + "group": 3, + "height": 9, + "map": 12, + "name": "TIN_TOWER_9F", + "width": 10 + }, + "TIN_TOWER_ROOF": { + "group": 15, + "height": 9, + "map": 12, + "name": "TIN_TOWER_ROOF", + "width": 10 + }, + "TOHJO_FALLS": { + "group": 3, + "height": 9, + "map": 83, + "name": "TOHJO_FALLS", + "width": 15 + }, + "TRADE_CENTER": { + "group": 20, + "height": 4, + "map": 2, + "name": "TRADE_CENTER", + "width": 5 + }, + "TRAINER_HOUSE_1F": { + "group": 23, + "height": 7, + "map": 6, + "name": "TRAINER_HOUSE_1F", + "width": 5 + }, + "TRAINER_HOUSE_B1F": { + "group": 23, + "height": 8, + "map": 7, + "name": "TRAINER_HOUSE_B1F", + "width": 5 + }, + "UNDERGROUND_PATH": { + "group": 3, + "height": 14, + "map": 86, + "name": "UNDERGROUND_PATH", + "width": 3 + }, + "UNION_CAVE_1F": { + "group": 3, + "height": 18, + "map": 37, + "name": "UNION_CAVE_1F", + "width": 10 + }, + "UNION_CAVE_B1F": { + "group": 3, + "height": 18, + "map": 38, + "name": "UNION_CAVE_B1F", + "width": 10 + }, + "UNION_CAVE_B2F": { + "group": 3, + "height": 18, + "map": 39, + "name": "UNION_CAVE_B2F", + "width": 10 + }, + "VERMILION_CITY": { + "group": 12, + "height": 18, + "map": 3, + "name": "VERMILION_CITY", + "width": 20 + }, + "VERMILION_DIGLETTS_CAVE_SPEECH_HOUSE": { + "group": 12, + "height": 4, + "map": 10, + "name": "VERMILION_DIGLETTS_CAVE_SPEECH_HOUSE", + "width": 4 + }, + "VERMILION_FISHING_SPEECH_HOUSE": { + "group": 12, + "height": 4, + "map": 4, + "name": "VERMILION_FISHING_SPEECH_HOUSE", + "width": 4 + }, + "VERMILION_GYM": { + "group": 12, + "height": 9, + "map": 11, + "name": "VERMILION_GYM", + "width": 5 + }, + "VERMILION_MAGNET_TRAIN_SPEECH_HOUSE": { + "group": 12, + "height": 4, + "map": 8, + "name": "VERMILION_MAGNET_TRAIN_SPEECH_HOUSE", + "width": 4 + }, + "VERMILION_MART": { + "group": 12, + "height": 4, + "map": 9, + "name": "VERMILION_MART", + "width": 6 + }, + "VERMILION_POKECENTER_1F": { + "group": 12, + "height": 4, + "map": 5, + "name": "VERMILION_POKECENTER_1F", + "width": 5 + }, + "VERMILION_POKECENTER_2F_BETA": { + "group": 12, + "height": 4, + "map": 6, + "name": "VERMILION_POKECENTER_2F_BETA", + "width": 8 + }, + "VERMILION_PORT": { + "group": 15, + "height": 18, + "map": 2, + "name": "VERMILION_PORT", + "width": 10 + }, + "VERMILION_PORT_PASSAGE": { + "group": 15, + "height": 9, + "map": 9, + "name": "VERMILION_PORT_PASSAGE", + "width": 10 + }, + "VICTORY_ROAD": { + "group": 3, + "height": 36, + "map": 91, + "name": "VICTORY_ROAD", + "width": 10 + }, + "VICTORY_ROAD_GATE": { + "group": 23, + "height": 9, + "map": 13, + "name": "VICTORY_ROAD_GATE", + "width": 10 + }, + "VIOLET_CITY": { + "group": 10, + "height": 18, + "map": 5, + "name": "VIOLET_CITY", + "width": 20 + }, + "VIOLET_GYM": { + "group": 10, + "height": 8, + "map": 7, + "name": "VIOLET_GYM", + "width": 5 + }, + "VIOLET_KYLES_HOUSE": { + "group": 10, + "height": 4, + "map": 11, + "name": "VIOLET_KYLES_HOUSE", + "width": 4 + }, + "VIOLET_MART": { + "group": 10, + "height": 4, + "map": 6, + "name": "VIOLET_MART", + "width": 6 + }, + "VIOLET_NICKNAME_SPEECH_HOUSE": { + "group": 10, + "height": 4, + "map": 9, + "name": "VIOLET_NICKNAME_SPEECH_HOUSE", + "width": 4 + }, + "VIOLET_POKECENTER_1F": { + "group": 10, + "height": 4, + "map": 10, + "name": "VIOLET_POKECENTER_1F", + "width": 5 + }, + "VIRIDIAN_CITY": { + "group": 23, + "height": 18, + "map": 3, + "name": "VIRIDIAN_CITY", + "width": 20 + }, + "VIRIDIAN_GYM": { + "group": 23, + "height": 9, + "map": 4, + "name": "VIRIDIAN_GYM", + "width": 5 + }, + "VIRIDIAN_MART": { + "group": 23, + "height": 4, + "map": 8, + "name": "VIRIDIAN_MART", + "width": 6 + }, + "VIRIDIAN_NICKNAME_SPEECH_HOUSE": { + "group": 23, + "height": 4, + "map": 5, + "name": "VIRIDIAN_NICKNAME_SPEECH_HOUSE", + "width": 4 + }, + "VIRIDIAN_POKECENTER_1F": { + "group": 23, + "height": 4, + "map": 9, + "name": "VIRIDIAN_POKECENTER_1F", + "width": 5 + }, + "VIRIDIAN_POKECENTER_2F_BETA": { + "group": 23, + "height": 4, + "map": 10, + "name": "VIRIDIAN_POKECENTER_2F_BETA", + "width": 8 + }, + "WHIRL_ISLAND_B1F": { + "group": 3, + "height": 18, + "map": 71, + "name": "WHIRL_ISLAND_B1F", + "width": 20 + }, + "WHIRL_ISLAND_B2F": { + "group": 3, + "height": 18, + "map": 72, + "name": "WHIRL_ISLAND_B2F", + "width": 10 + }, + "WHIRL_ISLAND_CAVE": { + "group": 3, + "height": 9, + "map": 69, + "name": "WHIRL_ISLAND_CAVE", + "width": 5 + }, + "WHIRL_ISLAND_LUGIA_CHAMBER": { + "group": 3, + "height": 9, + "map": 73, + "name": "WHIRL_ISLAND_LUGIA_CHAMBER", + "width": 10 + }, + "WHIRL_ISLAND_NE": { + "group": 3, + "height": 9, + "map": 67, + "name": "WHIRL_ISLAND_NE", + "width": 10 + }, + "WHIRL_ISLAND_NW": { + "group": 3, + "height": 9, + "map": 66, + "name": "WHIRL_ISLAND_NW", + "width": 5 + }, + "WHIRL_ISLAND_SE": { + "group": 3, + "height": 9, + "map": 70, + "name": "WHIRL_ISLAND_SE", + "width": 5 + }, + "WHIRL_ISLAND_SW": { + "group": 3, + "height": 9, + "map": 68, + "name": "WHIRL_ISLAND_SW", + "width": 10 + }, + "WILLS_ROOM": { + "group": 16, + "height": 9, + "map": 3, + "name": "WILLS_ROOM", + "width": 5 + }, + "WISE_TRIOS_ROOM": { + "group": 4, + "height": 4, + "map": 2, + "name": "WISE_TRIOS_ROOM", + "width": 4 + } + }, + "pokemonAssets": { + "ABRA": { + "animLabel": "AbraAnimation", + "back": "abra_back", + "backLabel": "AbraBackpic", + "bitmaskLabel": "AbraBitmasks", + "dexLabel": "AbraPokedexEntry", + "framesLabel": "AbraFrames", + "front": "abra", + "frontLabel": "AbraFrontpic", + "id": "ABRA", + "idleLabel": "AbraAnimationIdle", + "name": "ABRA" + }, + "AERODACTYL": { + "animLabel": "AerodactylAnimation", + "back": "aerodactyl_back", + "backLabel": "AerodactylBackpic", + "bitmaskLabel": "AerodactylBitmasks", + "dexLabel": "AerodactylPokedexEntry", + "framesLabel": "AerodactylFrames", + "front": "aerodactyl", + "frontLabel": "AerodactylFrontpic", + "id": "AERODACTYL", + "idleLabel": "AerodactylAnimationIdle", + "name": "AERODACTYL" + }, + "AIPOM": { + "animLabel": "AipomAnimation", + "back": "aipom_back", + "backLabel": "AipomBackpic", + "bitmaskLabel": "AipomBitmasks", + "dexLabel": "AipomPokedexEntry", + "framesLabel": "AipomFrames", + "front": "aipom", + "frontLabel": "AipomFrontpic", + "id": "AIPOM", + "idleLabel": "AipomAnimationIdle", + "name": "AIPOM" + }, + "ALAKAZAM": { + "animLabel": "AlakazamAnimation", + "back": "alakazam_back", + "backLabel": "AlakazamBackpic", + "bitmaskLabel": "AlakazamBitmasks", + "dexLabel": "AlakazamPokedexEntry", + "framesLabel": "AlakazamFrames", + "front": "alakazam", + "frontLabel": "AlakazamFrontpic", + "id": "ALAKAZAM", + "idleLabel": "AlakazamAnimationIdle", + "name": "ALAKAZAM" + }, + "AMPHAROS": { + "animLabel": "AmpharosAnimation", + "back": "ampharos_back", + "backLabel": "AmpharosBackpic", + "bitmaskLabel": "AmpharosBitmasks", + "dexLabel": "AmpharosPokedexEntry", + "framesLabel": "AmpharosFrames", + "front": "ampharos", + "frontLabel": "AmpharosFrontpic", + "id": "AMPHAROS", + "idleLabel": "AmpharosAnimationIdle", + "name": "AMPHAROS" + }, + "ARBOK": { + "animLabel": "ArbokAnimation", + "back": "arbok_back", + "backLabel": "ArbokBackpic", + "bitmaskLabel": "ArbokBitmasks", + "dexLabel": "ArbokPokedexEntry", + "framesLabel": "ArbokFrames", + "front": "arbok", + "frontLabel": "ArbokFrontpic", + "id": "ARBOK", + "idleLabel": "ArbokAnimationIdle", + "name": "ARBOK" + }, + "ARCANINE": { + "animLabel": "ArcanineAnimation", + "back": "arcanine_back", + "backLabel": "ArcanineBackpic", + "bitmaskLabel": "ArcanineBitmasks", + "dexLabel": "ArcaninePokedexEntry", + "framesLabel": "ArcanineFrames", + "front": "arcanine", + "frontLabel": "ArcanineFrontpic", + "id": "ARCANINE", + "idleLabel": "ArcanineAnimationIdle", + "name": "ARCANINE" + }, + "ARIADOS": { + "animLabel": "AriadosAnimation", + "back": "ariados_back", + "backLabel": "AriadosBackpic", + "bitmaskLabel": "AriadosBitmasks", + "dexLabel": "AriadosPokedexEntry", + "framesLabel": "AriadosFrames", + "front": "ariados", + "frontLabel": "AriadosFrontpic", + "id": "ARIADOS", + "idleLabel": "AriadosAnimationIdle", + "name": "ARIADOS" + }, + "ARTICUNO": { + "animLabel": "ArticunoAnimation", + "back": "articuno_back", + "backLabel": "ArticunoBackpic", + "bitmaskLabel": "ArticunoBitmasks", + "dexLabel": "ArticunoPokedexEntry", + "framesLabel": "ArticunoFrames", + "front": "articuno", + "frontLabel": "ArticunoFrontpic", + "id": "ARTICUNO", + "idleLabel": "ArticunoAnimationIdle", + "name": "ARTICUNO" + }, + "AZUMARILL": { + "animLabel": "AzumarillAnimation", + "back": "azumarill_back", + "backLabel": "AzumarillBackpic", + "bitmaskLabel": "AzumarillBitmasks", + "dexLabel": "AzumarillPokedexEntry", + "framesLabel": "AzumarillFrames", + "front": "azumarill", + "frontLabel": "AzumarillFrontpic", + "id": "AZUMARILL", + "idleLabel": "AzumarillAnimationIdle", + "name": "AZUMARILL" + }, + "BAYLEEF": { + "animLabel": "BayleefAnimation", + "back": "bayleef_back", + "backLabel": "BayleefBackpic", + "bitmaskLabel": "BayleefBitmasks", + "dexLabel": "BayleefPokedexEntry", + "framesLabel": "BayleefFrames", + "front": "bayleef", + "frontLabel": "BayleefFrontpic", + "id": "BAYLEEF", + "idleLabel": "BayleefAnimationIdle", + "name": "BAYLEEF" + }, + "BEEDRILL": { + "animLabel": "BeedrillAnimation", + "back": "beedrill_back", + "backLabel": "BeedrillBackpic", + "bitmaskLabel": "BeedrillBitmasks", + "dexLabel": "BeedrillPokedexEntry", + "framesLabel": "BeedrillFrames", + "front": "beedrill", + "frontLabel": "BeedrillFrontpic", + "id": "BEEDRILL", + "idleLabel": "BeedrillAnimationIdle", + "name": "BEEDRILL" + }, + "BELLOSSOM": { + "animLabel": "BellossomAnimation", + "back": "bellossom_back", + "backLabel": "BellossomBackpic", + "bitmaskLabel": "BellossomBitmasks", + "dexLabel": "BellossomPokedexEntry", + "framesLabel": "BellossomFrames", + "front": "bellossom", + "frontLabel": "BellossomFrontpic", + "id": "BELLOSSOM", + "idleLabel": "BellossomAnimationIdle", + "name": "BELLOSSOM" + }, + "BELLSPROUT": { + "animLabel": "BellsproutAnimation", + "back": "bellsprout_back", + "backLabel": "BellsproutBackpic", + "bitmaskLabel": "BellsproutBitmasks", + "dexLabel": "BellsproutPokedexEntry", + "framesLabel": "BellsproutFrames", + "front": "bellsprout", + "frontLabel": "BellsproutFrontpic", + "id": "BELLSPROUT", + "idleLabel": "BellsproutAnimationIdle", + "name": "BELLSPROUT" + }, + "BLASTOISE": { + "animLabel": "BlastoiseAnimation", + "back": "blastoise_back", + "backLabel": "BlastoiseBackpic", + "bitmaskLabel": "BlastoiseBitmasks", + "dexLabel": "BlastoisePokedexEntry", + "framesLabel": "BlastoiseFrames", + "front": "blastoise", + "frontLabel": "BlastoiseFrontpic", + "id": "BLASTOISE", + "idleLabel": "BlastoiseAnimationIdle", + "name": "BLASTOISE" + }, + "BLISSEY": { + "animLabel": "BlisseyAnimation", + "back": "blissey_back", + "backLabel": "BlisseyBackpic", + "bitmaskLabel": "BlisseyBitmasks", + "dexLabel": "BlisseyPokedexEntry", + "framesLabel": "BlisseyFrames", + "front": "blissey", + "frontLabel": "BlisseyFrontpic", + "id": "BLISSEY", + "idleLabel": "BlisseyAnimationIdle", + "name": "BLISSEY" + }, + "BULBASAUR": { + "animLabel": "BulbasaurAnimation", + "back": "bulbasaur_back", + "backLabel": "BulbasaurBackpic", + "bitmaskLabel": "BulbasaurBitmasks", + "dexLabel": "BulbasaurPokedexEntry", + "framesLabel": "BulbasaurFrames", + "front": "bulbasaur", + "frontLabel": "BulbasaurFrontpic", + "id": "BULBASAUR", + "idleLabel": "BulbasaurAnimationIdle", + "name": "BULBASAUR" + }, + "BUTTERFREE": { + "animLabel": "ButterfreeAnimation", + "back": "butterfree_back", + "backLabel": "ButterfreeBackpic", + "bitmaskLabel": "ButterfreeBitmasks", + "dexLabel": "ButterfreePokedexEntry", + "framesLabel": "ButterfreeFrames", + "front": "butterfree", + "frontLabel": "ButterfreeFrontpic", + "id": "BUTTERFREE", + "idleLabel": "ButterfreeAnimationIdle", + "name": "BUTTERFREE" + }, + "CATERPIE": { + "animLabel": "CaterpieAnimation", + "back": "caterpie_back", + "backLabel": "CaterpieBackpic", + "bitmaskLabel": "CaterpieBitmasks", + "dexLabel": "CaterpiePokedexEntry", + "framesLabel": "CaterpieFrames", + "front": "caterpie", + "frontLabel": "CaterpieFrontpic", + "id": "CATERPIE", + "idleLabel": "CaterpieAnimationIdle", + "name": "CATERPIE" + }, + "CELEBI": { + "animLabel": "CelebiAnimation", + "back": "celebi_back", + "backLabel": "CelebiBackpic", + "bitmaskLabel": "CelebiBitmasks", + "dexLabel": "CelebiPokedexEntry", + "framesLabel": "CelebiFrames", + "front": "celebi", + "frontLabel": "CelebiFrontpic", + "id": "CELEBI", + "idleLabel": "CelebiAnimationIdle", + "name": "CELEBI" + }, + "CHANSEY": { + "animLabel": "ChanseyAnimation", + "back": "chansey_back", + "backLabel": "ChanseyBackpic", + "bitmaskLabel": "ChanseyBitmasks", + "dexLabel": "ChanseyPokedexEntry", + "framesLabel": "ChanseyFrames", + "front": "chansey", + "frontLabel": "ChanseyFrontpic", + "id": "CHANSEY", + "idleLabel": "ChanseyAnimationIdle", + "name": "CHANSEY" + }, + "CHARIZARD": { + "animLabel": "CharizardAnimation", + "back": "charizard_back", + "backLabel": "CharizardBackpic", + "bitmaskLabel": "CharizardBitmasks", + "dexLabel": "CharizardPokedexEntry", + "framesLabel": "CharizardFrames", + "front": "charizard", + "frontLabel": "CharizardFrontpic", + "id": "CHARIZARD", + "idleLabel": "CharizardAnimationIdle", + "name": "CHARIZARD" + }, + "CHARMANDER": { + "animLabel": "CharmanderAnimation", + "back": "charmander_back", + "backLabel": "CharmanderBackpic", + "bitmaskLabel": "CharmanderBitmasks", + "dexLabel": "CharmanderPokedexEntry", + "framesLabel": "CharmanderFrames", + "front": "charmander", + "frontLabel": "CharmanderFrontpic", + "id": "CHARMANDER", + "idleLabel": "CharmanderAnimationIdle", + "name": "CHARMANDER" + }, + "CHARMELEON": { + "animLabel": "CharmeleonAnimation", + "back": "charmeleon_back", + "backLabel": "CharmeleonBackpic", + "bitmaskLabel": "CharmeleonBitmasks", + "dexLabel": "CharmeleonPokedexEntry", + "framesLabel": "CharmeleonFrames", + "front": "charmeleon", + "frontLabel": "CharmeleonFrontpic", + "id": "CHARMELEON", + "idleLabel": "CharmeleonAnimationIdle", + "name": "CHARMELEON" + }, + "CHIKORITA": { + "animLabel": "ChikoritaAnimation", + "back": "chikorita_back", + "backLabel": "ChikoritaBackpic", + "bitmaskLabel": "ChikoritaBitmasks", + "dexLabel": "ChikoritaPokedexEntry", + "framesLabel": "ChikoritaFrames", + "front": "chikorita", + "frontLabel": "ChikoritaFrontpic", + "id": "CHIKORITA", + "idleLabel": "ChikoritaAnimationIdle", + "name": "CHIKORITA" + }, + "CHINCHOU": { + "animLabel": "ChinchouAnimation", + "back": "chinchou_back", + "backLabel": "ChinchouBackpic", + "bitmaskLabel": "ChinchouBitmasks", + "dexLabel": "ChinchouPokedexEntry", + "framesLabel": "ChinchouFrames", + "front": "chinchou", + "frontLabel": "ChinchouFrontpic", + "id": "CHINCHOU", + "idleLabel": "ChinchouAnimationIdle", + "name": "CHINCHOU" + }, + "CLEFABLE": { + "animLabel": "ClefableAnimation", + "back": "clefable_back", + "backLabel": "ClefableBackpic", + "bitmaskLabel": "ClefableBitmasks", + "dexLabel": "ClefablePokedexEntry", + "framesLabel": "ClefableFrames", + "front": "clefable", + "frontLabel": "ClefableFrontpic", + "id": "CLEFABLE", + "idleLabel": "ClefableAnimationIdle", + "name": "CLEFABLE" + }, + "CLEFAIRY": { + "animLabel": "ClefairyAnimation", + "back": "clefairy_back", + "backLabel": "ClefairyBackpic", + "bitmaskLabel": "ClefairyBitmasks", + "dexLabel": "ClefairyPokedexEntry", + "framesLabel": "ClefairyFrames", + "front": "clefairy", + "frontLabel": "ClefairyFrontpic", + "id": "CLEFAIRY", + "idleLabel": "ClefairyAnimationIdle", + "name": "CLEFAIRY" + }, + "CLEFFA": { + "animLabel": "CleffaAnimation", + "back": "cleffa_back", + "backLabel": "CleffaBackpic", + "bitmaskLabel": "CleffaBitmasks", + "dexLabel": "CleffaPokedexEntry", + "framesLabel": "CleffaFrames", + "front": "cleffa", + "frontLabel": "CleffaFrontpic", + "id": "CLEFFA", + "idleLabel": "CleffaAnimationIdle", + "name": "CLEFFA" + }, + "CLOYSTER": { + "animLabel": "CloysterAnimation", + "back": "cloyster_back", + "backLabel": "CloysterBackpic", + "bitmaskLabel": "CloysterBitmasks", + "dexLabel": "CloysterPokedexEntry", + "framesLabel": "CloysterFrames", + "front": "cloyster", + "frontLabel": "CloysterFrontpic", + "id": "CLOYSTER", + "idleLabel": "CloysterAnimationIdle", + "name": "CLOYSTER" + }, + "CORSOLA": { + "animLabel": "CorsolaAnimation", + "back": "corsola_back", + "backLabel": "CorsolaBackpic", + "bitmaskLabel": "CorsolaBitmasks", + "dexLabel": "CorsolaPokedexEntry", + "framesLabel": "CorsolaFrames", + "front": "corsola", + "frontLabel": "CorsolaFrontpic", + "id": "CORSOLA", + "idleLabel": "CorsolaAnimationIdle", + "name": "CORSOLA" + }, + "CROBAT": { + "animLabel": "CrobatAnimation", + "back": "crobat_back", + "backLabel": "CrobatBackpic", + "bitmaskLabel": "CrobatBitmasks", + "dexLabel": "CrobatPokedexEntry", + "framesLabel": "CrobatFrames", + "front": "crobat", + "frontLabel": "CrobatFrontpic", + "id": "CROBAT", + "idleLabel": "CrobatAnimationIdle", + "name": "CROBAT" + }, + "CROCONAW": { + "animLabel": "CroconawAnimation", + "back": "croconaw_back", + "backLabel": "CroconawBackpic", + "bitmaskLabel": "CroconawBitmasks", + "dexLabel": "CroconawPokedexEntry", + "framesLabel": "CroconawFrames", + "front": "croconaw", + "frontLabel": "CroconawFrontpic", + "id": "CROCONAW", + "idleLabel": "CroconawAnimationIdle", + "name": "CROCONAW" + }, + "CUBONE": { + "animLabel": "CuboneAnimation", + "back": "cubone_back", + "backLabel": "CuboneBackpic", + "bitmaskLabel": "CuboneBitmasks", + "dexLabel": "CubonePokedexEntry", + "framesLabel": "CuboneFrames", + "front": "cubone", + "frontLabel": "CuboneFrontpic", + "id": "CUBONE", + "idleLabel": "CuboneAnimationIdle", + "name": "CUBONE" + }, + "CYNDAQUIL": { + "animLabel": "CyndaquilAnimation", + "back": "cyndaquil_back", + "backLabel": "CyndaquilBackpic", + "bitmaskLabel": "CyndaquilBitmasks", + "dexLabel": "CyndaquilPokedexEntry", + "framesLabel": "CyndaquilFrames", + "front": "cyndaquil", + "frontLabel": "CyndaquilFrontpic", + "id": "CYNDAQUIL", + "idleLabel": "CyndaquilAnimationIdle", + "name": "CYNDAQUIL" + }, + "DELIBIRD": { + "animLabel": "DelibirdAnimation", + "back": "delibird_back", + "backLabel": "DelibirdBackpic", + "bitmaskLabel": "DelibirdBitmasks", + "dexLabel": "DelibirdPokedexEntry", + "framesLabel": "DelibirdFrames", + "front": "delibird", + "frontLabel": "DelibirdFrontpic", + "id": "DELIBIRD", + "idleLabel": "DelibirdAnimationIdle", + "name": "DELIBIRD" + }, + "DEWGONG": { + "animLabel": "DewgongAnimation", + "back": "dewgong_back", + "backLabel": "DewgongBackpic", + "bitmaskLabel": "DewgongBitmasks", + "dexLabel": "DewgongPokedexEntry", + "framesLabel": "DewgongFrames", + "front": "dewgong", + "frontLabel": "DewgongFrontpic", + "id": "DEWGONG", + "idleLabel": "DewgongAnimationIdle", + "name": "DEWGONG" + }, + "DIGLETT": { + "animLabel": "DiglettAnimation", + "back": "diglett_back", + "backLabel": "DiglettBackpic", + "bitmaskLabel": "DiglettBitmasks", + "dexLabel": "DiglettPokedexEntry", + "framesLabel": "DiglettFrames", + "front": "diglett", + "frontLabel": "DiglettFrontpic", + "id": "DIGLETT", + "idleLabel": "DiglettAnimationIdle", + "name": "DIGLETT" + }, + "DITTO": { + "animLabel": "DittoAnimation", + "back": "ditto_back", + "backLabel": "DittoBackpic", + "bitmaskLabel": "DittoBitmasks", + "dexLabel": "DittoPokedexEntry", + "framesLabel": "DittoFrames", + "front": "ditto", + "frontLabel": "DittoFrontpic", + "id": "DITTO", + "idleLabel": "DittoAnimationIdle", + "name": "DITTO" + }, + "DODRIO": { + "animLabel": "DodrioAnimation", + "back": "dodrio_back", + "backLabel": "DodrioBackpic", + "bitmaskLabel": "DodrioBitmasks", + "dexLabel": "DodrioPokedexEntry", + "framesLabel": "DodrioFrames", + "front": "dodrio", + "frontLabel": "DodrioFrontpic", + "id": "DODRIO", + "idleLabel": "DodrioAnimationIdle", + "name": "DODRIO" + }, + "DODUO": { + "animLabel": "DoduoAnimation", + "back": "doduo_back", + "backLabel": "DoduoBackpic", + "bitmaskLabel": "DoduoBitmasks", + "dexLabel": "DoduoPokedexEntry", + "framesLabel": "DoduoFrames", + "front": "doduo", + "frontLabel": "DoduoFrontpic", + "id": "DODUO", + "idleLabel": "DoduoAnimationIdle", + "name": "DODUO" + }, + "DONPHAN": { + "animLabel": "DonphanAnimation", + "back": "donphan_back", + "backLabel": "DonphanBackpic", + "bitmaskLabel": "DonphanBitmasks", + "dexLabel": "DonphanPokedexEntry", + "framesLabel": "DonphanFrames", + "front": "donphan", + "frontLabel": "DonphanFrontpic", + "id": "DONPHAN", + "idleLabel": "DonphanAnimationIdle", + "name": "DONPHAN" + }, + "DRAGONAIR": { + "animLabel": "DragonairAnimation", + "back": "dragonair_back", + "backLabel": "DragonairBackpic", + "bitmaskLabel": "DragonairBitmasks", + "dexLabel": "DragonairPokedexEntry", + "framesLabel": "DragonairFrames", + "front": "dragonair", + "frontLabel": "DragonairFrontpic", + "id": "DRAGONAIR", + "idleLabel": "DragonairAnimationIdle", + "name": "DRAGONAIR" + }, + "DRAGONITE": { + "animLabel": "DragoniteAnimation", + "back": "dragonite_back", + "backLabel": "DragoniteBackpic", + "bitmaskLabel": "DragoniteBitmasks", + "dexLabel": "DragonitePokedexEntry", + "framesLabel": "DragoniteFrames", + "front": "dragonite", + "frontLabel": "DragoniteFrontpic", + "id": "DRAGONITE", + "idleLabel": "DragoniteAnimationIdle", + "name": "DRAGONITE" + }, + "DRATINI": { + "animLabel": "DratiniAnimation", + "back": "dratini_back", + "backLabel": "DratiniBackpic", + "bitmaskLabel": "DratiniBitmasks", + "dexLabel": "DratiniPokedexEntry", + "framesLabel": "DratiniFrames", + "front": "dratini", + "frontLabel": "DratiniFrontpic", + "id": "DRATINI", + "idleLabel": "DratiniAnimationIdle", + "name": "DRATINI" + }, + "DROWZEE": { + "animLabel": "DrowzeeAnimation", + "back": "drowzee_back", + "backLabel": "DrowzeeBackpic", + "bitmaskLabel": "DrowzeeBitmasks", + "dexLabel": "DrowzeePokedexEntry", + "framesLabel": "DrowzeeFrames", + "front": "drowzee", + "frontLabel": "DrowzeeFrontpic", + "id": "DROWZEE", + "idleLabel": "DrowzeeAnimationIdle", + "name": "DROWZEE" + }, + "DUGTRIO": { + "animLabel": "DugtrioAnimation", + "back": "dugtrio_back", + "backLabel": "DugtrioBackpic", + "bitmaskLabel": "DugtrioBitmasks", + "dexLabel": "DugtrioPokedexEntry", + "framesLabel": "DugtrioFrames", + "front": "dugtrio", + "frontLabel": "DugtrioFrontpic", + "id": "DUGTRIO", + "idleLabel": "DugtrioAnimationIdle", + "name": "DUGTRIO" + }, + "DUNSPARCE": { + "animLabel": "DunsparceAnimation", + "back": "dunsparce_back", + "backLabel": "DunsparceBackpic", + "bitmaskLabel": "DunsparceBitmasks", + "dexLabel": "DunsparcePokedexEntry", + "framesLabel": "DunsparceFrames", + "front": "dunsparce", + "frontLabel": "DunsparceFrontpic", + "id": "DUNSPARCE", + "idleLabel": "DunsparceAnimationIdle", + "name": "DUNSPARCE" + }, + "EEVEE": { + "animLabel": "EeveeAnimation", + "back": "eevee_back", + "backLabel": "EeveeBackpic", + "bitmaskLabel": "EeveeBitmasks", + "dexLabel": "EeveePokedexEntry", + "framesLabel": "EeveeFrames", + "front": "eevee", + "frontLabel": "EeveeFrontpic", + "id": "EEVEE", + "idleLabel": "EeveeAnimationIdle", + "name": "EEVEE" + }, + "EKANS": { + "animLabel": "EkansAnimation", + "back": "ekans_back", + "backLabel": "EkansBackpic", + "bitmaskLabel": "EkansBitmasks", + "dexLabel": "EkansPokedexEntry", + "framesLabel": "EkansFrames", + "front": "ekans", + "frontLabel": "EkansFrontpic", + "id": "EKANS", + "idleLabel": "EkansAnimationIdle", + "name": "EKANS" + }, + "ELECTABUZZ": { + "animLabel": "ElectabuzzAnimation", + "back": "electabuzz_back", + "backLabel": "ElectabuzzBackpic", + "bitmaskLabel": "ElectabuzzBitmasks", + "dexLabel": "ElectabuzzPokedexEntry", + "framesLabel": "ElectabuzzFrames", + "front": "electabuzz", + "frontLabel": "ElectabuzzFrontpic", + "id": "ELECTABUZZ", + "idleLabel": "ElectabuzzAnimationIdle", + "name": "ELECTABUZZ" + }, + "ELECTRODE": { + "animLabel": "ElectrodeAnimation", + "back": "electrode_back", + "backLabel": "ElectrodeBackpic", + "bitmaskLabel": "ElectrodeBitmasks", + "dexLabel": "ElectrodePokedexEntry", + "framesLabel": "ElectrodeFrames", + "front": "electrode", + "frontLabel": "ElectrodeFrontpic", + "id": "ELECTRODE", + "idleLabel": "ElectrodeAnimationIdle", + "name": "ELECTRODE" + }, + "ELEKID": { + "animLabel": "ElekidAnimation", + "back": "elekid_back", + "backLabel": "ElekidBackpic", + "bitmaskLabel": "ElekidBitmasks", + "dexLabel": "ElekidPokedexEntry", + "framesLabel": "ElekidFrames", + "front": "elekid", + "frontLabel": "ElekidFrontpic", + "id": "ELEKID", + "idleLabel": "ElekidAnimationIdle", + "name": "ELEKID" + }, + "ENTEI": { + "animLabel": "EnteiAnimation", + "back": "entei_back", + "backLabel": "EnteiBackpic", + "bitmaskLabel": "EnteiBitmasks", + "dexLabel": "EnteiPokedexEntry", + "framesLabel": "EnteiFrames", + "front": "entei", + "frontLabel": "EnteiFrontpic", + "id": "ENTEI", + "idleLabel": "EnteiAnimationIdle", + "name": "ENTEI" + }, + "ESPEON": { + "animLabel": "EspeonAnimation", + "back": "espeon_back", + "backLabel": "EspeonBackpic", + "bitmaskLabel": "EspeonBitmasks", + "dexLabel": "EspeonPokedexEntry", + "framesLabel": "EspeonFrames", + "front": "espeon", + "frontLabel": "EspeonFrontpic", + "id": "ESPEON", + "idleLabel": "EspeonAnimationIdle", + "name": "ESPEON" + }, + "EXEGGCUTE": { + "animLabel": "ExeggcuteAnimation", + "back": "exeggcute_back", + "backLabel": "ExeggcuteBackpic", + "bitmaskLabel": "ExeggcuteBitmasks", + "dexLabel": "ExeggcutePokedexEntry", + "framesLabel": "ExeggcuteFrames", + "front": "exeggcute", + "frontLabel": "ExeggcuteFrontpic", + "id": "EXEGGCUTE", + "idleLabel": "ExeggcuteAnimationIdle", + "name": "EXEGGCUTE" + }, + "EXEGGUTOR": { + "animLabel": "ExeggutorAnimation", + "back": "exeggutor_back", + "backLabel": "ExeggutorBackpic", + "bitmaskLabel": "ExeggutorBitmasks", + "dexLabel": "ExeggutorPokedexEntry", + "framesLabel": "ExeggutorFrames", + "front": "exeggutor", + "frontLabel": "ExeggutorFrontpic", + "id": "EXEGGUTOR", + "idleLabel": "ExeggutorAnimationIdle", + "name": "EXEGGUTOR" + }, + "FARFETCH_D": { + "animLabel": "FarfetchDAnimation", + "back": "farfetchd_back", + "backLabel": "FarfetchDBackpic", + "bitmaskLabel": "FarfetchDBitmasks", + "dexLabel": "FarfetchDPokedexEntry", + "framesLabel": "FarfetchDFrames", + "front": "farfetchd", + "frontLabel": "FarfetchDFrontpic", + "id": "FARFETCH_D", + "idleLabel": "FarfetchDAnimationIdle", + "name": "FARFETCH'D" + }, + "FEAROW": { + "animLabel": "FearowAnimation", + "back": "fearow_back", + "backLabel": "FearowBackpic", + "bitmaskLabel": "FearowBitmasks", + "dexLabel": "FearowPokedexEntry", + "framesLabel": "FearowFrames", + "front": "fearow", + "frontLabel": "FearowFrontpic", + "id": "FEAROW", + "idleLabel": "FearowAnimationIdle", + "name": "FEAROW" + }, + "FERALIGATR": { + "animLabel": "FeraligatrAnimation", + "back": "feraligatr_back", + "backLabel": "FeraligatrBackpic", + "bitmaskLabel": "FeraligatrBitmasks", + "dexLabel": "FeraligatrPokedexEntry", + "framesLabel": "FeraligatrFrames", + "front": "feraligatr", + "frontLabel": "FeraligatrFrontpic", + "id": "FERALIGATR", + "idleLabel": "FeraligatrAnimationIdle", + "name": "FERALIGATR" + }, + "FLAAFFY": { + "animLabel": "FlaaffyAnimation", + "back": "flaaffy_back", + "backLabel": "FlaaffyBackpic", + "bitmaskLabel": "FlaaffyBitmasks", + "dexLabel": "FlaaffyPokedexEntry", + "framesLabel": "FlaaffyFrames", + "front": "flaaffy", + "frontLabel": "FlaaffyFrontpic", + "id": "FLAAFFY", + "idleLabel": "FlaaffyAnimationIdle", + "name": "FLAAFFY" + }, + "FLAREON": { + "animLabel": "FlareonAnimation", + "back": "flareon_back", + "backLabel": "FlareonBackpic", + "bitmaskLabel": "FlareonBitmasks", + "dexLabel": "FlareonPokedexEntry", + "framesLabel": "FlareonFrames", + "front": "flareon", + "frontLabel": "FlareonFrontpic", + "id": "FLAREON", + "idleLabel": "FlareonAnimationIdle", + "name": "FLAREON" + }, + "FORRETRESS": { + "animLabel": "ForretressAnimation", + "back": "forretress_back", + "backLabel": "ForretressBackpic", + "bitmaskLabel": "ForretressBitmasks", + "dexLabel": "ForretressPokedexEntry", + "framesLabel": "ForretressFrames", + "front": "forretress", + "frontLabel": "ForretressFrontpic", + "id": "FORRETRESS", + "idleLabel": "ForretressAnimationIdle", + "name": "FORRETRESS" + }, + "FURRET": { + "animLabel": "FurretAnimation", + "back": "furret_back", + "backLabel": "FurretBackpic", + "bitmaskLabel": "FurretBitmasks", + "dexLabel": "FurretPokedexEntry", + "framesLabel": "FurretFrames", + "front": "furret", + "frontLabel": "FurretFrontpic", + "id": "FURRET", + "idleLabel": "FurretAnimationIdle", + "name": "FURRET" + }, + "GASTLY": { + "animLabel": "GastlyAnimation", + "back": "gastly_back", + "backLabel": "GastlyBackpic", + "bitmaskLabel": "GastlyBitmasks", + "dexLabel": "GastlyPokedexEntry", + "framesLabel": "GastlyFrames", + "front": "gastly", + "frontLabel": "GastlyFrontpic", + "id": "GASTLY", + "idleLabel": "GastlyAnimationIdle", + "name": "GASTLY" + }, + "GENGAR": { + "animLabel": "GengarAnimation", + "back": "gengar_back", + "backLabel": "GengarBackpic", + "bitmaskLabel": "GengarBitmasks", + "dexLabel": "GengarPokedexEntry", + "framesLabel": "GengarFrames", + "front": "gengar", + "frontLabel": "GengarFrontpic", + "id": "GENGAR", + "idleLabel": "GengarAnimationIdle", + "name": "GENGAR" + }, + "GEODUDE": { + "animLabel": "GeodudeAnimation", + "back": "geodude_back", + "backLabel": "GeodudeBackpic", + "bitmaskLabel": "GeodudeBitmasks", + "dexLabel": "GeodudePokedexEntry", + "framesLabel": "GeodudeFrames", + "front": "geodude", + "frontLabel": "GeodudeFrontpic", + "id": "GEODUDE", + "idleLabel": "GeodudeAnimationIdle", + "name": "GEODUDE" + }, + "GIRAFARIG": { + "animLabel": "GirafarigAnimation", + "back": "girafarig_back", + "backLabel": "GirafarigBackpic", + "bitmaskLabel": "GirafarigBitmasks", + "dexLabel": "GirafarigPokedexEntry", + "framesLabel": "GirafarigFrames", + "front": "girafarig", + "frontLabel": "GirafarigFrontpic", + "id": "GIRAFARIG", + "idleLabel": "GirafarigAnimationIdle", + "name": "GIRAFARIG" + }, + "GLIGAR": { + "animLabel": "GligarAnimation", + "back": "gligar_back", + "backLabel": "GligarBackpic", + "bitmaskLabel": "GligarBitmasks", + "dexLabel": "GligarPokedexEntry", + "framesLabel": "GligarFrames", + "front": "gligar", + "frontLabel": "GligarFrontpic", + "id": "GLIGAR", + "idleLabel": "GligarAnimationIdle", + "name": "GLIGAR" + }, + "GLOOM": { + "animLabel": "GloomAnimation", + "back": "gloom_back", + "backLabel": "GloomBackpic", + "bitmaskLabel": "GloomBitmasks", + "dexLabel": "GloomPokedexEntry", + "framesLabel": "GloomFrames", + "front": "gloom", + "frontLabel": "GloomFrontpic", + "id": "GLOOM", + "idleLabel": "GloomAnimationIdle", + "name": "GLOOM" + }, + "GOLBAT": { + "animLabel": "GolbatAnimation", + "back": "golbat_back", + "backLabel": "GolbatBackpic", + "bitmaskLabel": "GolbatBitmasks", + "dexLabel": "GolbatPokedexEntry", + "framesLabel": "GolbatFrames", + "front": "golbat", + "frontLabel": "GolbatFrontpic", + "id": "GOLBAT", + "idleLabel": "GolbatAnimationIdle", + "name": "GOLBAT" + }, + "GOLDEEN": { + "animLabel": "GoldeenAnimation", + "back": "goldeen_back", + "backLabel": "GoldeenBackpic", + "bitmaskLabel": "GoldeenBitmasks", + "dexLabel": "GoldeenPokedexEntry", + "framesLabel": "GoldeenFrames", + "front": "goldeen", + "frontLabel": "GoldeenFrontpic", + "id": "GOLDEEN", + "idleLabel": "GoldeenAnimationIdle", + "name": "GOLDEEN" + }, + "GOLDUCK": { + "animLabel": "GolduckAnimation", + "back": "golduck_back", + "backLabel": "GolduckBackpic", + "bitmaskLabel": "GolduckBitmasks", + "dexLabel": "GolduckPokedexEntry", + "framesLabel": "GolduckFrames", + "front": "golduck", + "frontLabel": "GolduckFrontpic", + "id": "GOLDUCK", + "idleLabel": "GolduckAnimationIdle", + "name": "GOLDUCK" + }, + "GOLEM": { + "animLabel": "GolemAnimation", + "back": "golem_back", + "backLabel": "GolemBackpic", + "bitmaskLabel": "GolemBitmasks", + "dexLabel": "GolemPokedexEntry", + "framesLabel": "GolemFrames", + "front": "golem", + "frontLabel": "GolemFrontpic", + "id": "GOLEM", + "idleLabel": "GolemAnimationIdle", + "name": "GOLEM" + }, + "GRANBULL": { + "animLabel": "GranbullAnimation", + "back": "granbull_back", + "backLabel": "GranbullBackpic", + "bitmaskLabel": "GranbullBitmasks", + "dexLabel": "GranbullPokedexEntry", + "framesLabel": "GranbullFrames", + "front": "granbull", + "frontLabel": "GranbullFrontpic", + "id": "GRANBULL", + "idleLabel": "GranbullAnimationIdle", + "name": "GRANBULL" + }, + "GRAVELER": { + "animLabel": "GravelerAnimation", + "back": "graveler_back", + "backLabel": "GravelerBackpic", + "bitmaskLabel": "GravelerBitmasks", + "dexLabel": "GravelerPokedexEntry", + "framesLabel": "GravelerFrames", + "front": "graveler", + "frontLabel": "GravelerFrontpic", + "id": "GRAVELER", + "idleLabel": "GravelerAnimationIdle", + "name": "GRAVELER" + }, + "GRIMER": { + "animLabel": "GrimerAnimation", + "back": "grimer_back", + "backLabel": "GrimerBackpic", + "bitmaskLabel": "GrimerBitmasks", + "dexLabel": "GrimerPokedexEntry", + "framesLabel": "GrimerFrames", + "front": "grimer", + "frontLabel": "GrimerFrontpic", + "id": "GRIMER", + "idleLabel": "GrimerAnimationIdle", + "name": "GRIMER" + }, + "GROWLITHE": { + "animLabel": "GrowlitheAnimation", + "back": "growlithe_back", + "backLabel": "GrowlitheBackpic", + "bitmaskLabel": "GrowlitheBitmasks", + "dexLabel": "GrowlithePokedexEntry", + "framesLabel": "GrowlitheFrames", + "front": "growlithe", + "frontLabel": "GrowlitheFrontpic", + "id": "GROWLITHE", + "idleLabel": "GrowlitheAnimationIdle", + "name": "GROWLITHE" + }, + "GYARADOS": { + "animLabel": "GyaradosAnimation", + "back": "gyarados_back", + "backLabel": "GyaradosBackpic", + "bitmaskLabel": "GyaradosBitmasks", + "dexLabel": "GyaradosPokedexEntry", + "framesLabel": "GyaradosFrames", + "front": "gyarados", + "frontLabel": "GyaradosFrontpic", + "id": "GYARADOS", + "idleLabel": "GyaradosAnimationIdle", + "name": "GYARADOS" + }, + "HAUNTER": { + "animLabel": "HaunterAnimation", + "back": "haunter_back", + "backLabel": "HaunterBackpic", + "bitmaskLabel": "HaunterBitmasks", + "dexLabel": "HaunterPokedexEntry", + "framesLabel": "HaunterFrames", + "front": "haunter", + "frontLabel": "HaunterFrontpic", + "id": "HAUNTER", + "idleLabel": "HaunterAnimationIdle", + "name": "HAUNTER" + }, + "HERACROSS": { + "animLabel": "HeracrossAnimation", + "back": "heracross_back", + "backLabel": "HeracrossBackpic", + "bitmaskLabel": "HeracrossBitmasks", + "dexLabel": "HeracrossPokedexEntry", + "framesLabel": "HeracrossFrames", + "front": "heracross", + "frontLabel": "HeracrossFrontpic", + "id": "HERACROSS", + "idleLabel": "HeracrossAnimationIdle", + "name": "HERACROSS" + }, + "HITMONCHAN": { + "animLabel": "HitmonchanAnimation", + "back": "hitmonchan_back", + "backLabel": "HitmonchanBackpic", + "bitmaskLabel": "HitmonchanBitmasks", + "dexLabel": "HitmonchanPokedexEntry", + "framesLabel": "HitmonchanFrames", + "front": "hitmonchan", + "frontLabel": "HitmonchanFrontpic", + "id": "HITMONCHAN", + "idleLabel": "HitmonchanAnimationIdle", + "name": "HITMONCHAN" + }, + "HITMONLEE": { + "animLabel": "HitmonleeAnimation", + "back": "hitmonlee_back", + "backLabel": "HitmonleeBackpic", + "bitmaskLabel": "HitmonleeBitmasks", + "dexLabel": "HitmonleePokedexEntry", + "framesLabel": "HitmonleeFrames", + "front": "hitmonlee", + "frontLabel": "HitmonleeFrontpic", + "id": "HITMONLEE", + "idleLabel": "HitmonleeAnimationIdle", + "name": "HITMONLEE" + }, + "HITMONTOP": { + "animLabel": "HitmontopAnimation", + "back": "hitmontop_back", + "backLabel": "HitmontopBackpic", + "bitmaskLabel": "HitmontopBitmasks", + "dexLabel": "HitmontopPokedexEntry", + "framesLabel": "HitmontopFrames", + "front": "hitmontop", + "frontLabel": "HitmontopFrontpic", + "id": "HITMONTOP", + "idleLabel": "HitmontopAnimationIdle", + "name": "HITMONTOP" + }, + "HOOTHOOT": { + "animLabel": "HoothootAnimation", + "back": "hoothoot_back", + "backLabel": "HoothootBackpic", + "bitmaskLabel": "HoothootBitmasks", + "dexLabel": "HoothootPokedexEntry", + "framesLabel": "HoothootFrames", + "front": "hoothoot", + "frontLabel": "HoothootFrontpic", + "id": "HOOTHOOT", + "idleLabel": "HoothootAnimationIdle", + "name": "HOOTHOOT" + }, + "HOPPIP": { + "animLabel": "HoppipAnimation", + "back": "hoppip_back", + "backLabel": "HoppipBackpic", + "bitmaskLabel": "HoppipBitmasks", + "dexLabel": "HoppipPokedexEntry", + "framesLabel": "HoppipFrames", + "front": "hoppip", + "frontLabel": "HoppipFrontpic", + "id": "HOPPIP", + "idleLabel": "HoppipAnimationIdle", + "name": "HOPPIP" + }, + "HORSEA": { + "animLabel": "HorseaAnimation", + "back": "horsea_back", + "backLabel": "HorseaBackpic", + "bitmaskLabel": "HorseaBitmasks", + "dexLabel": "HorseaPokedexEntry", + "framesLabel": "HorseaFrames", + "front": "horsea", + "frontLabel": "HorseaFrontpic", + "id": "HORSEA", + "idleLabel": "HorseaAnimationIdle", + "name": "HORSEA" + }, + "HOUNDOOM": { + "animLabel": "HoundoomAnimation", + "back": "houndoom_back", + "backLabel": "HoundoomBackpic", + "bitmaskLabel": "HoundoomBitmasks", + "dexLabel": "HoundoomPokedexEntry", + "framesLabel": "HoundoomFrames", + "front": "houndoom", + "frontLabel": "HoundoomFrontpic", + "id": "HOUNDOOM", + "idleLabel": "HoundoomAnimationIdle", + "name": "HOUNDOOM" + }, + "HOUNDOUR": { + "animLabel": "HoundourAnimation", + "back": "houndour_back", + "backLabel": "HoundourBackpic", + "bitmaskLabel": "HoundourBitmasks", + "dexLabel": "HoundourPokedexEntry", + "framesLabel": "HoundourFrames", + "front": "houndour", + "frontLabel": "HoundourFrontpic", + "id": "HOUNDOUR", + "idleLabel": "HoundourAnimationIdle", + "name": "HOUNDOUR" + }, + "HO_OH": { + "animLabel": "HoOhAnimation", + "back": "hooh_back", + "backLabel": "HoOhBackpic", + "bitmaskLabel": "HoOhBitmasks", + "dexLabel": "HoOhPokedexEntry", + "framesLabel": "HoOhFrames", + "front": "hooh", + "frontLabel": "HoOhFrontpic", + "id": "HO_OH", + "idleLabel": "HoOhAnimationIdle", + "name": "HO-OH" + }, + "HYPNO": { + "animLabel": "HypnoAnimation", + "back": "hypno_back", + "backLabel": "HypnoBackpic", + "bitmaskLabel": "HypnoBitmasks", + "dexLabel": "HypnoPokedexEntry", + "framesLabel": "HypnoFrames", + "front": "hypno", + "frontLabel": "HypnoFrontpic", + "id": "HYPNO", + "idleLabel": "HypnoAnimationIdle", + "name": "HYPNO" + }, + "IGGLYBUFF": { + "animLabel": "IgglybuffAnimation", + "back": "igglybuff_back", + "backLabel": "IgglybuffBackpic", + "bitmaskLabel": "IgglybuffBitmasks", + "dexLabel": "IgglybuffPokedexEntry", + "framesLabel": "IgglybuffFrames", + "front": "igglybuff", + "frontLabel": "IgglybuffFrontpic", + "id": "IGGLYBUFF", + "idleLabel": "IgglybuffAnimationIdle", + "name": "IGGLYBUFF" + }, + "IVYSAUR": { + "animLabel": "IvysaurAnimation", + "back": "ivysaur_back", + "backLabel": "IvysaurBackpic", + "bitmaskLabel": "IvysaurBitmasks", + "dexLabel": "IvysaurPokedexEntry", + "framesLabel": "IvysaurFrames", + "front": "ivysaur", + "frontLabel": "IvysaurFrontpic", + "id": "IVYSAUR", + "idleLabel": "IvysaurAnimationIdle", + "name": "IVYSAUR" + }, + "JIGGLYPUFF": { + "animLabel": "JigglypuffAnimation", + "back": "jigglypuff_back", + "backLabel": "JigglypuffBackpic", + "bitmaskLabel": "JigglypuffBitmasks", + "dexLabel": "JigglypuffPokedexEntry", + "framesLabel": "JigglypuffFrames", + "front": "jigglypuff", + "frontLabel": "JigglypuffFrontpic", + "id": "JIGGLYPUFF", + "idleLabel": "JigglypuffAnimationIdle", + "name": "JIGGLYPUFF" + }, + "JOLTEON": { + "animLabel": "JolteonAnimation", + "back": "jolteon_back", + "backLabel": "JolteonBackpic", + "bitmaskLabel": "JolteonBitmasks", + "dexLabel": "JolteonPokedexEntry", + "framesLabel": "JolteonFrames", + "front": "jolteon", + "frontLabel": "JolteonFrontpic", + "id": "JOLTEON", + "idleLabel": "JolteonAnimationIdle", + "name": "JOLTEON" + }, + "JUMPLUFF": { + "animLabel": "JumpluffAnimation", + "back": "jumpluff_back", + "backLabel": "JumpluffBackpic", + "bitmaskLabel": "JumpluffBitmasks", + "dexLabel": "JumpluffPokedexEntry", + "framesLabel": "JumpluffFrames", + "front": "jumpluff", + "frontLabel": "JumpluffFrontpic", + "id": "JUMPLUFF", + "idleLabel": "JumpluffAnimationIdle", + "name": "JUMPLUFF" + }, + "JYNX": { + "animLabel": "JynxAnimation", + "back": "jynx_back", + "backLabel": "JynxBackpic", + "bitmaskLabel": "JynxBitmasks", + "dexLabel": "JynxPokedexEntry", + "framesLabel": "JynxFrames", + "front": "jynx", + "frontLabel": "JynxFrontpic", + "id": "JYNX", + "idleLabel": "JynxAnimationIdle", + "name": "JYNX" + }, + "KABUTO": { + "animLabel": "KabutoAnimation", + "back": "kabuto_back", + "backLabel": "KabutoBackpic", + "bitmaskLabel": "KabutoBitmasks", + "dexLabel": "KabutoPokedexEntry", + "framesLabel": "KabutoFrames", + "front": "kabuto", + "frontLabel": "KabutoFrontpic", + "id": "KABUTO", + "idleLabel": "KabutoAnimationIdle", + "name": "KABUTO" + }, + "KABUTOPS": { + "animLabel": "KabutopsAnimation", + "back": "kabutops_back", + "backLabel": "KabutopsBackpic", + "bitmaskLabel": "KabutopsBitmasks", + "dexLabel": "KabutopsPokedexEntry", + "framesLabel": "KabutopsFrames", + "front": "kabutops", + "frontLabel": "KabutopsFrontpic", + "id": "KABUTOPS", + "idleLabel": "KabutopsAnimationIdle", + "name": "KABUTOPS" + }, + "KADABRA": { + "animLabel": "KadabraAnimation", + "back": "kadabra_back", + "backLabel": "KadabraBackpic", + "bitmaskLabel": "KadabraBitmasks", + "dexLabel": "KadabraPokedexEntry", + "framesLabel": "KadabraFrames", + "front": "kadabra", + "frontLabel": "KadabraFrontpic", + "id": "KADABRA", + "idleLabel": "KadabraAnimationIdle", + "name": "KADABRA" + }, + "KAKUNA": { + "animLabel": "KakunaAnimation", + "back": "kakuna_back", + "backLabel": "KakunaBackpic", + "bitmaskLabel": "KakunaBitmasks", + "dexLabel": "KakunaPokedexEntry", + "framesLabel": "KakunaFrames", + "front": "kakuna", + "frontLabel": "KakunaFrontpic", + "id": "KAKUNA", + "idleLabel": "KakunaAnimationIdle", + "name": "KAKUNA" + }, + "KANGASKHAN": { + "animLabel": "KangaskhanAnimation", + "back": "kangaskhan_back", + "backLabel": "KangaskhanBackpic", + "bitmaskLabel": "KangaskhanBitmasks", + "dexLabel": "KangaskhanPokedexEntry", + "framesLabel": "KangaskhanFrames", + "front": "kangaskhan", + "frontLabel": "KangaskhanFrontpic", + "id": "KANGASKHAN", + "idleLabel": "KangaskhanAnimationIdle", + "name": "KANGASKHAN" + }, + "KINGDRA": { + "animLabel": "KingdraAnimation", + "back": "kingdra_back", + "backLabel": "KingdraBackpic", + "bitmaskLabel": "KingdraBitmasks", + "dexLabel": "KingdraPokedexEntry", + "framesLabel": "KingdraFrames", + "front": "kingdra", + "frontLabel": "KingdraFrontpic", + "id": "KINGDRA", + "idleLabel": "KingdraAnimationIdle", + "name": "KINGDRA" + }, + "KINGLER": { + "animLabel": "KinglerAnimation", + "back": "kingler_back", + "backLabel": "KinglerBackpic", + "bitmaskLabel": "KinglerBitmasks", + "dexLabel": "KinglerPokedexEntry", + "framesLabel": "KinglerFrames", + "front": "kingler", + "frontLabel": "KinglerFrontpic", + "id": "KINGLER", + "idleLabel": "KinglerAnimationIdle", + "name": "KINGLER" + }, + "KOFFING": { + "animLabel": "KoffingAnimation", + "back": "koffing_back", + "backLabel": "KoffingBackpic", + "bitmaskLabel": "KoffingBitmasks", + "dexLabel": "KoffingPokedexEntry", + "framesLabel": "KoffingFrames", + "front": "koffing", + "frontLabel": "KoffingFrontpic", + "id": "KOFFING", + "idleLabel": "KoffingAnimationIdle", + "name": "KOFFING" + }, + "KRABBY": { + "animLabel": "KrabbyAnimation", + "back": "krabby_back", + "backLabel": "KrabbyBackpic", + "bitmaskLabel": "KrabbyBitmasks", + "dexLabel": "KrabbyPokedexEntry", + "framesLabel": "KrabbyFrames", + "front": "krabby", + "frontLabel": "KrabbyFrontpic", + "id": "KRABBY", + "idleLabel": "KrabbyAnimationIdle", + "name": "KRABBY" + }, + "LANTURN": { + "animLabel": "LanturnAnimation", + "back": "lanturn_back", + "backLabel": "LanturnBackpic", + "bitmaskLabel": "LanturnBitmasks", + "dexLabel": "LanturnPokedexEntry", + "framesLabel": "LanturnFrames", + "front": "lanturn", + "frontLabel": "LanturnFrontpic", + "id": "LANTURN", + "idleLabel": "LanturnAnimationIdle", + "name": "LANTURN" + }, + "LAPRAS": { + "animLabel": "LaprasAnimation", + "back": "lapras_back", + "backLabel": "LaprasBackpic", + "bitmaskLabel": "LaprasBitmasks", + "dexLabel": "LaprasPokedexEntry", + "framesLabel": "LaprasFrames", + "front": "lapras", + "frontLabel": "LaprasFrontpic", + "id": "LAPRAS", + "idleLabel": "LaprasAnimationIdle", + "name": "LAPRAS" + }, + "LARVITAR": { + "animLabel": "LarvitarAnimation", + "back": "larvitar_back", + "backLabel": "LarvitarBackpic", + "bitmaskLabel": "LarvitarBitmasks", + "dexLabel": "LarvitarPokedexEntry", + "framesLabel": "LarvitarFrames", + "front": "larvitar", + "frontLabel": "LarvitarFrontpic", + "id": "LARVITAR", + "idleLabel": "LarvitarAnimationIdle", + "name": "LARVITAR" + }, + "LEDIAN": { + "animLabel": "LedianAnimation", + "back": "ledian_back", + "backLabel": "LedianBackpic", + "bitmaskLabel": "LedianBitmasks", + "dexLabel": "LedianPokedexEntry", + "framesLabel": "LedianFrames", + "front": "ledian", + "frontLabel": "LedianFrontpic", + "id": "LEDIAN", + "idleLabel": "LedianAnimationIdle", + "name": "LEDIAN" + }, + "LEDYBA": { + "animLabel": "LedybaAnimation", + "back": "ledyba_back", + "backLabel": "LedybaBackpic", + "bitmaskLabel": "LedybaBitmasks", + "dexLabel": "LedybaPokedexEntry", + "framesLabel": "LedybaFrames", + "front": "ledyba", + "frontLabel": "LedybaFrontpic", + "id": "LEDYBA", + "idleLabel": "LedybaAnimationIdle", + "name": "LEDYBA" + }, + "LICKITUNG": { + "animLabel": "LickitungAnimation", + "back": "lickitung_back", + "backLabel": "LickitungBackpic", + "bitmaskLabel": "LickitungBitmasks", + "dexLabel": "LickitungPokedexEntry", + "framesLabel": "LickitungFrames", + "front": "lickitung", + "frontLabel": "LickitungFrontpic", + "id": "LICKITUNG", + "idleLabel": "LickitungAnimationIdle", + "name": "LICKITUNG" + }, + "LUGIA": { + "animLabel": "LugiaAnimation", + "back": "lugia_back", + "backLabel": "LugiaBackpic", + "bitmaskLabel": "LugiaBitmasks", + "dexLabel": "LugiaPokedexEntry", + "framesLabel": "LugiaFrames", + "front": "lugia", + "frontLabel": "LugiaFrontpic", + "id": "LUGIA", + "idleLabel": "LugiaAnimationIdle", + "name": "LUGIA" + }, + "MACHAMP": { + "animLabel": "MachampAnimation", + "back": "machamp_back", + "backLabel": "MachampBackpic", + "bitmaskLabel": "MachampBitmasks", + "dexLabel": "MachampPokedexEntry", + "framesLabel": "MachampFrames", + "front": "machamp", + "frontLabel": "MachampFrontpic", + "id": "MACHAMP", + "idleLabel": "MachampAnimationIdle", + "name": "MACHAMP" + }, + "MACHOKE": { + "animLabel": "MachokeAnimation", + "back": "machoke_back", + "backLabel": "MachokeBackpic", + "bitmaskLabel": "MachokeBitmasks", + "dexLabel": "MachokePokedexEntry", + "framesLabel": "MachokeFrames", + "front": "machoke", + "frontLabel": "MachokeFrontpic", + "id": "MACHOKE", + "idleLabel": "MachokeAnimationIdle", + "name": "MACHOKE" + }, + "MACHOP": { + "animLabel": "MachopAnimation", + "back": "machop_back", + "backLabel": "MachopBackpic", + "bitmaskLabel": "MachopBitmasks", + "dexLabel": "MachopPokedexEntry", + "framesLabel": "MachopFrames", + "front": "machop", + "frontLabel": "MachopFrontpic", + "id": "MACHOP", + "idleLabel": "MachopAnimationIdle", + "name": "MACHOP" + }, + "MAGBY": { + "animLabel": "MagbyAnimation", + "back": "magby_back", + "backLabel": "MagbyBackpic", + "bitmaskLabel": "MagbyBitmasks", + "dexLabel": "MagbyPokedexEntry", + "framesLabel": "MagbyFrames", + "front": "magby", + "frontLabel": "MagbyFrontpic", + "id": "MAGBY", + "idleLabel": "MagbyAnimationIdle", + "name": "MAGBY" + }, + "MAGCARGO": { + "animLabel": "MagcargoAnimation", + "back": "magcargo_back", + "backLabel": "MagcargoBackpic", + "bitmaskLabel": "MagcargoBitmasks", + "dexLabel": "MagcargoPokedexEntry", + "framesLabel": "MagcargoFrames", + "front": "magcargo", + "frontLabel": "MagcargoFrontpic", + "id": "MAGCARGO", + "idleLabel": "MagcargoAnimationIdle", + "name": "MAGCARGO" + }, + "MAGIKARP": { + "animLabel": "MagikarpAnimation", + "back": "magikarp_back", + "backLabel": "MagikarpBackpic", + "bitmaskLabel": "MagikarpBitmasks", + "dexLabel": "MagikarpPokedexEntry", + "framesLabel": "MagikarpFrames", + "front": "magikarp", + "frontLabel": "MagikarpFrontpic", + "id": "MAGIKARP", + "idleLabel": "MagikarpAnimationIdle", + "name": "MAGIKARP" + }, + "MAGMAR": { + "animLabel": "MagmarAnimation", + "back": "magmar_back", + "backLabel": "MagmarBackpic", + "bitmaskLabel": "MagmarBitmasks", + "dexLabel": "MagmarPokedexEntry", + "framesLabel": "MagmarFrames", + "front": "magmar", + "frontLabel": "MagmarFrontpic", + "id": "MAGMAR", + "idleLabel": "MagmarAnimationIdle", + "name": "MAGMAR" + }, + "MAGNEMITE": { + "animLabel": "MagnemiteAnimation", + "back": "magnemite_back", + "backLabel": "MagnemiteBackpic", + "bitmaskLabel": "MagnemiteBitmasks", + "dexLabel": "MagnemitePokedexEntry", + "framesLabel": "MagnemiteFrames", + "front": "magnemite", + "frontLabel": "MagnemiteFrontpic", + "id": "MAGNEMITE", + "idleLabel": "MagnemiteAnimationIdle", + "name": "MAGNEMITE" + }, + "MAGNETON": { + "animLabel": "MagnetonAnimation", + "back": "magneton_back", + "backLabel": "MagnetonBackpic", + "bitmaskLabel": "MagnetonBitmasks", + "dexLabel": "MagnetonPokedexEntry", + "framesLabel": "MagnetonFrames", + "front": "magneton", + "frontLabel": "MagnetonFrontpic", + "id": "MAGNETON", + "idleLabel": "MagnetonAnimationIdle", + "name": "MAGNETON" + }, + "MANKEY": { + "animLabel": "MankeyAnimation", + "back": "mankey_back", + "backLabel": "MankeyBackpic", + "bitmaskLabel": "MankeyBitmasks", + "dexLabel": "MankeyPokedexEntry", + "framesLabel": "MankeyFrames", + "front": "mankey", + "frontLabel": "MankeyFrontpic", + "id": "MANKEY", + "idleLabel": "MankeyAnimationIdle", + "name": "MANKEY" + }, + "MANTINE": { + "animLabel": "MantineAnimation", + "back": "mantine_back", + "backLabel": "MantineBackpic", + "bitmaskLabel": "MantineBitmasks", + "dexLabel": "MantinePokedexEntry", + "framesLabel": "MantineFrames", + "front": "mantine", + "frontLabel": "MantineFrontpic", + "id": "MANTINE", + "idleLabel": "MantineAnimationIdle", + "name": "MANTINE" + }, + "MAREEP": { + "animLabel": "MareepAnimation", + "back": "mareep_back", + "backLabel": "MareepBackpic", + "bitmaskLabel": "MareepBitmasks", + "dexLabel": "MareepPokedexEntry", + "framesLabel": "MareepFrames", + "front": "mareep", + "frontLabel": "MareepFrontpic", + "id": "MAREEP", + "idleLabel": "MareepAnimationIdle", + "name": "MAREEP" + }, + "MARILL": { + "animLabel": "MarillAnimation", + "back": "marill_back", + "backLabel": "MarillBackpic", + "bitmaskLabel": "MarillBitmasks", + "dexLabel": "MarillPokedexEntry", + "framesLabel": "MarillFrames", + "front": "marill", + "frontLabel": "MarillFrontpic", + "id": "MARILL", + "idleLabel": "MarillAnimationIdle", + "name": "MARILL" + }, + "MAROWAK": { + "animLabel": "MarowakAnimation", + "back": "marowak_back", + "backLabel": "MarowakBackpic", + "bitmaskLabel": "MarowakBitmasks", + "dexLabel": "MarowakPokedexEntry", + "framesLabel": "MarowakFrames", + "front": "marowak", + "frontLabel": "MarowakFrontpic", + "id": "MAROWAK", + "idleLabel": "MarowakAnimationIdle", + "name": "MAROWAK" + }, + "MEGANIUM": { + "animLabel": "MeganiumAnimation", + "back": "meganium_back", + "backLabel": "MeganiumBackpic", + "bitmaskLabel": "MeganiumBitmasks", + "dexLabel": "MeganiumPokedexEntry", + "framesLabel": "MeganiumFrames", + "front": "meganium", + "frontLabel": "MeganiumFrontpic", + "id": "MEGANIUM", + "idleLabel": "MeganiumAnimationIdle", + "name": "MEGANIUM" + }, + "MEOWTH": { + "animLabel": "MeowthAnimation", + "back": "meowth_back", + "backLabel": "MeowthBackpic", + "bitmaskLabel": "MeowthBitmasks", + "dexLabel": "MeowthPokedexEntry", + "framesLabel": "MeowthFrames", + "front": "meowth", + "frontLabel": "MeowthFrontpic", + "id": "MEOWTH", + "idleLabel": "MeowthAnimationIdle", + "name": "MEOWTH" + }, + "METAPOD": { + "animLabel": "MetapodAnimation", + "back": "metapod_back", + "backLabel": "MetapodBackpic", + "bitmaskLabel": "MetapodBitmasks", + "dexLabel": "MetapodPokedexEntry", + "framesLabel": "MetapodFrames", + "front": "metapod", + "frontLabel": "MetapodFrontpic", + "id": "METAPOD", + "idleLabel": "MetapodAnimationIdle", + "name": "METAPOD" + }, + "MEW": { + "animLabel": "MewAnimation", + "back": "mew_back", + "backLabel": "MewBackpic", + "bitmaskLabel": "MewBitmasks", + "dexLabel": "MewPokedexEntry", + "framesLabel": "MewFrames", + "front": "mew", + "frontLabel": "MewFrontpic", + "id": "MEW", + "idleLabel": "MewAnimationIdle", + "name": "MEW" + }, + "MEWTWO": { + "animLabel": "MewtwoAnimation", + "back": "mewtwo_back", + "backLabel": "MewtwoBackpic", + "bitmaskLabel": "MewtwoBitmasks", + "dexLabel": "MewtwoPokedexEntry", + "framesLabel": "MewtwoFrames", + "front": "mewtwo", + "frontLabel": "MewtwoFrontpic", + "id": "MEWTWO", + "idleLabel": "MewtwoAnimationIdle", + "name": "MEWTWO" + }, + "MILTANK": { + "animLabel": "MiltankAnimation", + "back": "miltank_back", + "backLabel": "MiltankBackpic", + "bitmaskLabel": "MiltankBitmasks", + "dexLabel": "MiltankPokedexEntry", + "framesLabel": "MiltankFrames", + "front": "miltank", + "frontLabel": "MiltankFrontpic", + "id": "MILTANK", + "idleLabel": "MiltankAnimationIdle", + "name": "MILTANK" + }, + "MISDREAVUS": { + "animLabel": "MisdreavusAnimation", + "back": "misdreavus_back", + "backLabel": "MisdreavusBackpic", + "bitmaskLabel": "MisdreavusBitmasks", + "dexLabel": "MisdreavusPokedexEntry", + "framesLabel": "MisdreavusFrames", + "front": "misdreavus", + "frontLabel": "MisdreavusFrontpic", + "id": "MISDREAVUS", + "idleLabel": "MisdreavusAnimationIdle", + "name": "MISDREAVUS" + }, + "MOLTRES": { + "animLabel": "MoltresAnimation", + "back": "moltres_back", + "backLabel": "MoltresBackpic", + "bitmaskLabel": "MoltresBitmasks", + "dexLabel": "MoltresPokedexEntry", + "framesLabel": "MoltresFrames", + "front": "moltres", + "frontLabel": "MoltresFrontpic", + "id": "MOLTRES", + "idleLabel": "MoltresAnimationIdle", + "name": "MOLTRES" + }, + "MR__MIME": { + "animLabel": "MrMimeAnimation", + "back": "mrmime_back", + "backLabel": "MrMimeBackpic", + "bitmaskLabel": "MrMimeBitmasks", + "dexLabel": "MrMimePokedexEntry", + "framesLabel": "MrMimeFrames", + "front": "mrmime", + "frontLabel": "MrMimeFrontpic", + "id": "MR__MIME", + "idleLabel": "MrMimeAnimationIdle", + "name": "MR.MIME" + }, + "MUK": { + "animLabel": "MukAnimation", + "back": "muk_back", + "backLabel": "MukBackpic", + "bitmaskLabel": "MukBitmasks", + "dexLabel": "MukPokedexEntry", + "framesLabel": "MukFrames", + "front": "muk", + "frontLabel": "MukFrontpic", + "id": "MUK", + "idleLabel": "MukAnimationIdle", + "name": "MUK" + }, + "MURKROW": { + "animLabel": "MurkrowAnimation", + "back": "murkrow_back", + "backLabel": "MurkrowBackpic", + "bitmaskLabel": "MurkrowBitmasks", + "dexLabel": "MurkrowPokedexEntry", + "framesLabel": "MurkrowFrames", + "front": "murkrow", + "frontLabel": "MurkrowFrontpic", + "id": "MURKROW", + "idleLabel": "MurkrowAnimationIdle", + "name": "MURKROW" + }, + "NATU": { + "animLabel": "NatuAnimation", + "back": "natu_back", + "backLabel": "NatuBackpic", + "bitmaskLabel": "NatuBitmasks", + "dexLabel": "NatuPokedexEntry", + "framesLabel": "NatuFrames", + "front": "natu", + "frontLabel": "NatuFrontpic", + "id": "NATU", + "idleLabel": "NatuAnimationIdle", + "name": "NATU" + }, + "NIDOKING": { + "animLabel": "NidokingAnimation", + "back": "nidoking_back", + "backLabel": "NidokingBackpic", + "bitmaskLabel": "NidokingBitmasks", + "dexLabel": "NidokingPokedexEntry", + "framesLabel": "NidokingFrames", + "front": "nidoking", + "frontLabel": "NidokingFrontpic", + "id": "NIDOKING", + "idleLabel": "NidokingAnimationIdle", + "name": "NIDOKING" + }, + "NIDOQUEEN": { + "animLabel": "NidoqueenAnimation", + "back": "nidoqueen_back", + "backLabel": "NidoqueenBackpic", + "bitmaskLabel": "NidoqueenBitmasks", + "dexLabel": "NidoqueenPokedexEntry", + "framesLabel": "NidoqueenFrames", + "front": "nidoqueen", + "frontLabel": "NidoqueenFrontpic", + "id": "NIDOQUEEN", + "idleLabel": "NidoqueenAnimationIdle", + "name": "NIDOQUEEN" + }, + "NIDORAN_F": { + "animLabel": "NidoranFAnimation", + "back": "nidoranf_back", + "backLabel": "NidoranFBackpic", + "bitmaskLabel": "NidoranFBitmasks", + "dexLabel": "NidoranFPokedexEntry", + "framesLabel": "NidoranFFrames", + "front": "nidoranf", + "frontLabel": "NidoranFFrontpic", + "id": "NIDORAN_F", + "idleLabel": "NidoranFAnimationIdle", + "name": "NIDORAN♀" + }, + "NIDORAN_M": { + "animLabel": "NidoranMAnimation", + "back": "nidoranm_back", + "backLabel": "NidoranMBackpic", + "bitmaskLabel": "NidoranMBitmasks", + "dexLabel": "NidoranMPokedexEntry", + "framesLabel": "NidoranMFrames", + "front": "nidoranm", + "frontLabel": "NidoranMFrontpic", + "id": "NIDORAN_M", + "idleLabel": "NidoranMAnimationIdle", + "name": "NIDORAN♂" + }, + "NIDORINA": { + "animLabel": "NidorinaAnimation", + "back": "nidorina_back", + "backLabel": "NidorinaBackpic", + "bitmaskLabel": "NidorinaBitmasks", + "dexLabel": "NidorinaPokedexEntry", + "framesLabel": "NidorinaFrames", + "front": "nidorina", + "frontLabel": "NidorinaFrontpic", + "id": "NIDORINA", + "idleLabel": "NidorinaAnimationIdle", + "name": "NIDORINA" + }, + "NIDORINO": { + "animLabel": "NidorinoAnimation", + "back": "nidorino_back", + "backLabel": "NidorinoBackpic", + "bitmaskLabel": "NidorinoBitmasks", + "dexLabel": "NidorinoPokedexEntry", + "framesLabel": "NidorinoFrames", + "front": "nidorino", + "frontLabel": "NidorinoFrontpic", + "id": "NIDORINO", + "idleLabel": "NidorinoAnimationIdle", + "name": "NIDORINO" + }, + "NINETALES": { + "animLabel": "NinetalesAnimation", + "back": "ninetales_back", + "backLabel": "NinetalesBackpic", + "bitmaskLabel": "NinetalesBitmasks", + "dexLabel": "NinetalesPokedexEntry", + "framesLabel": "NinetalesFrames", + "front": "ninetales", + "frontLabel": "NinetalesFrontpic", + "id": "NINETALES", + "idleLabel": "NinetalesAnimationIdle", + "name": "NINETALES" + }, + "NOCTOWL": { + "animLabel": "NoctowlAnimation", + "back": "noctowl_back", + "backLabel": "NoctowlBackpic", + "bitmaskLabel": "NoctowlBitmasks", + "dexLabel": "NoctowlPokedexEntry", + "framesLabel": "NoctowlFrames", + "front": "noctowl", + "frontLabel": "NoctowlFrontpic", + "id": "NOCTOWL", + "idleLabel": "NoctowlAnimationIdle", + "name": "NOCTOWL" + }, + "OCTILLERY": { + "animLabel": "OctilleryAnimation", + "back": "octillery_back", + "backLabel": "OctilleryBackpic", + "bitmaskLabel": "OctilleryBitmasks", + "dexLabel": "OctilleryPokedexEntry", + "framesLabel": "OctilleryFrames", + "front": "octillery", + "frontLabel": "OctilleryFrontpic", + "id": "OCTILLERY", + "idleLabel": "OctilleryAnimationIdle", + "name": "OCTILLERY" + }, + "ODDISH": { + "animLabel": "OddishAnimation", + "back": "oddish_back", + "backLabel": "OddishBackpic", + "bitmaskLabel": "OddishBitmasks", + "dexLabel": "OddishPokedexEntry", + "framesLabel": "OddishFrames", + "front": "oddish", + "frontLabel": "OddishFrontpic", + "id": "ODDISH", + "idleLabel": "OddishAnimationIdle", + "name": "ODDISH" + }, + "OMANYTE": { + "animLabel": "OmanyteAnimation", + "back": "omanyte_back", + "backLabel": "OmanyteBackpic", + "bitmaskLabel": "OmanyteBitmasks", + "dexLabel": "OmanytePokedexEntry", + "framesLabel": "OmanyteFrames", + "front": "omanyte", + "frontLabel": "OmanyteFrontpic", + "id": "OMANYTE", + "idleLabel": "OmanyteAnimationIdle", + "name": "OMANYTE" + }, + "OMASTAR": { + "animLabel": "OmastarAnimation", + "back": "omastar_back", + "backLabel": "OmastarBackpic", + "bitmaskLabel": "OmastarBitmasks", + "dexLabel": "OmastarPokedexEntry", + "framesLabel": "OmastarFrames", + "front": "omastar", + "frontLabel": "OmastarFrontpic", + "id": "OMASTAR", + "idleLabel": "OmastarAnimationIdle", + "name": "OMASTAR" + }, + "ONIX": { + "animLabel": "OnixAnimation", + "back": "onix_back", + "backLabel": "OnixBackpic", + "bitmaskLabel": "OnixBitmasks", + "dexLabel": "OnixPokedexEntry", + "framesLabel": "OnixFrames", + "front": "onix", + "frontLabel": "OnixFrontpic", + "id": "ONIX", + "idleLabel": "OnixAnimationIdle", + "name": "ONIX" + }, + "PARAS": { + "animLabel": "ParasAnimation", + "back": "paras_back", + "backLabel": "ParasBackpic", + "bitmaskLabel": "ParasBitmasks", + "dexLabel": "ParasPokedexEntry", + "framesLabel": "ParasFrames", + "front": "paras", + "frontLabel": "ParasFrontpic", + "id": "PARAS", + "idleLabel": "ParasAnimationIdle", + "name": "PARAS" + }, + "PARASECT": { + "animLabel": "ParasectAnimation", + "back": "parasect_back", + "backLabel": "ParasectBackpic", + "bitmaskLabel": "ParasectBitmasks", + "dexLabel": "ParasectPokedexEntry", + "framesLabel": "ParasectFrames", + "front": "parasect", + "frontLabel": "ParasectFrontpic", + "id": "PARASECT", + "idleLabel": "ParasectAnimationIdle", + "name": "PARASECT" + }, + "PERSIAN": { + "animLabel": "PersianAnimation", + "back": "persian_back", + "backLabel": "PersianBackpic", + "bitmaskLabel": "PersianBitmasks", + "dexLabel": "PersianPokedexEntry", + "framesLabel": "PersianFrames", + "front": "persian", + "frontLabel": "PersianFrontpic", + "id": "PERSIAN", + "idleLabel": "PersianAnimationIdle", + "name": "PERSIAN" + }, + "PHANPY": { + "animLabel": "PhanpyAnimation", + "back": "phanpy_back", + "backLabel": "PhanpyBackpic", + "bitmaskLabel": "PhanpyBitmasks", + "dexLabel": "PhanpyPokedexEntry", + "framesLabel": "PhanpyFrames", + "front": "phanpy", + "frontLabel": "PhanpyFrontpic", + "id": "PHANPY", + "idleLabel": "PhanpyAnimationIdle", + "name": "PHANPY" + }, + "PICHU": { + "animLabel": "PichuAnimation", + "back": "pichu_back", + "backLabel": "PichuBackpic", + "bitmaskLabel": "PichuBitmasks", + "dexLabel": "PichuPokedexEntry", + "framesLabel": "PichuFrames", + "front": "pichu", + "frontLabel": "PichuFrontpic", + "id": "PICHU", + "idleLabel": "PichuAnimationIdle", + "name": "PICHU" + }, + "PIDGEOT": { + "animLabel": "PidgeotAnimation", + "back": "pidgeot_back", + "backLabel": "PidgeotBackpic", + "bitmaskLabel": "PidgeotBitmasks", + "dexLabel": "PidgeotPokedexEntry", + "framesLabel": "PidgeotFrames", + "front": "pidgeot", + "frontLabel": "PidgeotFrontpic", + "id": "PIDGEOT", + "idleLabel": "PidgeotAnimationIdle", + "name": "PIDGEOT" + }, + "PIDGEOTTO": { + "animLabel": "PidgeottoAnimation", + "back": "pidgeotto_back", + "backLabel": "PidgeottoBackpic", + "bitmaskLabel": "PidgeottoBitmasks", + "dexLabel": "PidgeottoPokedexEntry", + "framesLabel": "PidgeottoFrames", + "front": "pidgeotto", + "frontLabel": "PidgeottoFrontpic", + "id": "PIDGEOTTO", + "idleLabel": "PidgeottoAnimationIdle", + "name": "PIDGEOTTO" + }, + "PIDGEY": { + "animLabel": "PidgeyAnimation", + "back": "pidgey_back", + "backLabel": "PidgeyBackpic", + "bitmaskLabel": "PidgeyBitmasks", + "dexLabel": "PidgeyPokedexEntry", + "framesLabel": "PidgeyFrames", + "front": "pidgey", + "frontLabel": "PidgeyFrontpic", + "id": "PIDGEY", + "idleLabel": "PidgeyAnimationIdle", + "name": "PIDGEY" + }, + "PIKACHU": { + "animLabel": "PikachuAnimation", + "back": "pikachu_back", + "backLabel": "PikachuBackpic", + "bitmaskLabel": "PikachuBitmasks", + "dexLabel": "PikachuPokedexEntry", + "framesLabel": "PikachuFrames", + "front": "pikachu", + "frontLabel": "PikachuFrontpic", + "id": "PIKACHU", + "idleLabel": "PikachuAnimationIdle", + "name": "PIKACHU" + }, + "PILOSWINE": { + "animLabel": "PiloswineAnimation", + "back": "piloswine_back", + "backLabel": "PiloswineBackpic", + "bitmaskLabel": "PiloswineBitmasks", + "dexLabel": "PiloswinePokedexEntry", + "framesLabel": "PiloswineFrames", + "front": "piloswine", + "frontLabel": "PiloswineFrontpic", + "id": "PILOSWINE", + "idleLabel": "PiloswineAnimationIdle", + "name": "PILOSWINE" + }, + "PINECO": { + "animLabel": "PinecoAnimation", + "back": "pineco_back", + "backLabel": "PinecoBackpic", + "bitmaskLabel": "PinecoBitmasks", + "dexLabel": "PinecoPokedexEntry", + "framesLabel": "PinecoFrames", + "front": "pineco", + "frontLabel": "PinecoFrontpic", + "id": "PINECO", + "idleLabel": "PinecoAnimationIdle", + "name": "PINECO" + }, + "PINSIR": { + "animLabel": "PinsirAnimation", + "back": "pinsir_back", + "backLabel": "PinsirBackpic", + "bitmaskLabel": "PinsirBitmasks", + "dexLabel": "PinsirPokedexEntry", + "framesLabel": "PinsirFrames", + "front": "pinsir", + "frontLabel": "PinsirFrontpic", + "id": "PINSIR", + "idleLabel": "PinsirAnimationIdle", + "name": "PINSIR" + }, + "POLITOED": { + "animLabel": "PolitoedAnimation", + "back": "politoed_back", + "backLabel": "PolitoedBackpic", + "bitmaskLabel": "PolitoedBitmasks", + "dexLabel": "PolitoedPokedexEntry", + "framesLabel": "PolitoedFrames", + "front": "politoed", + "frontLabel": "PolitoedFrontpic", + "id": "POLITOED", + "idleLabel": "PolitoedAnimationIdle", + "name": "POLITOED" + }, + "POLIWAG": { + "animLabel": "PoliwagAnimation", + "back": "poliwag_back", + "backLabel": "PoliwagBackpic", + "bitmaskLabel": "PoliwagBitmasks", + "dexLabel": "PoliwagPokedexEntry", + "framesLabel": "PoliwagFrames", + "front": "poliwag", + "frontLabel": "PoliwagFrontpic", + "id": "POLIWAG", + "idleLabel": "PoliwagAnimationIdle", + "name": "POLIWAG" + }, + "POLIWHIRL": { + "animLabel": "PoliwhirlAnimation", + "back": "poliwhirl_back", + "backLabel": "PoliwhirlBackpic", + "bitmaskLabel": "PoliwhirlBitmasks", + "dexLabel": "PoliwhirlPokedexEntry", + "framesLabel": "PoliwhirlFrames", + "front": "poliwhirl", + "frontLabel": "PoliwhirlFrontpic", + "id": "POLIWHIRL", + "idleLabel": "PoliwhirlAnimationIdle", + "name": "POLIWHIRL" + }, + "POLIWRATH": { + "animLabel": "PoliwrathAnimation", + "back": "poliwrath_back", + "backLabel": "PoliwrathBackpic", + "bitmaskLabel": "PoliwrathBitmasks", + "dexLabel": "PoliwrathPokedexEntry", + "framesLabel": "PoliwrathFrames", + "front": "poliwrath", + "frontLabel": "PoliwrathFrontpic", + "id": "POLIWRATH", + "idleLabel": "PoliwrathAnimationIdle", + "name": "POLIWRATH" + }, + "PONYTA": { + "animLabel": "PonytaAnimation", + "back": "ponyta_back", + "backLabel": "PonytaBackpic", + "bitmaskLabel": "PonytaBitmasks", + "dexLabel": "PonytaPokedexEntry", + "framesLabel": "PonytaFrames", + "front": "ponyta", + "frontLabel": "PonytaFrontpic", + "id": "PONYTA", + "idleLabel": "PonytaAnimationIdle", + "name": "PONYTA" + }, + "PORYGON": { + "animLabel": "PorygonAnimation", + "back": "porygon_back", + "backLabel": "PorygonBackpic", + "bitmaskLabel": "PorygonBitmasks", + "dexLabel": "PorygonPokedexEntry", + "framesLabel": "PorygonFrames", + "front": "porygon", + "frontLabel": "PorygonFrontpic", + "id": "PORYGON", + "idleLabel": "PorygonAnimationIdle", + "name": "PORYGON" + }, + "PORYGON2": { + "animLabel": "Porygon2Animation", + "back": "porygon2_back", + "backLabel": "Porygon2Backpic", + "bitmaskLabel": "Porygon2Bitmasks", + "dexLabel": "Porygon2PokedexEntry", + "framesLabel": "Porygon2Frames", + "front": "porygon2", + "frontLabel": "Porygon2Frontpic", + "id": "PORYGON2", + "idleLabel": "Porygon2AnimationIdle", + "name": "PORYGON2" + }, + "PRIMEAPE": { + "animLabel": "PrimeapeAnimation", + "back": "primeape_back", + "backLabel": "PrimeapeBackpic", + "bitmaskLabel": "PrimeapeBitmasks", + "dexLabel": "PrimeapePokedexEntry", + "framesLabel": "PrimeapeFrames", + "front": "primeape", + "frontLabel": "PrimeapeFrontpic", + "id": "PRIMEAPE", + "idleLabel": "PrimeapeAnimationIdle", + "name": "PRIMEAPE" + }, + "PSYDUCK": { + "animLabel": "PsyduckAnimation", + "back": "psyduck_back", + "backLabel": "PsyduckBackpic", + "bitmaskLabel": "PsyduckBitmasks", + "dexLabel": "PsyduckPokedexEntry", + "framesLabel": "PsyduckFrames", + "front": "psyduck", + "frontLabel": "PsyduckFrontpic", + "id": "PSYDUCK", + "idleLabel": "PsyduckAnimationIdle", + "name": "PSYDUCK" + }, + "PUPITAR": { + "animLabel": "PupitarAnimation", + "back": "pupitar_back", + "backLabel": "PupitarBackpic", + "bitmaskLabel": "PupitarBitmasks", + "dexLabel": "PupitarPokedexEntry", + "framesLabel": "PupitarFrames", + "front": "pupitar", + "frontLabel": "PupitarFrontpic", + "id": "PUPITAR", + "idleLabel": "PupitarAnimationIdle", + "name": "PUPITAR" + }, + "QUAGSIRE": { + "animLabel": "QuagsireAnimation", + "back": "quagsire_back", + "backLabel": "QuagsireBackpic", + "bitmaskLabel": "QuagsireBitmasks", + "dexLabel": "QuagsirePokedexEntry", + "framesLabel": "QuagsireFrames", + "front": "quagsire", + "frontLabel": "QuagsireFrontpic", + "id": "QUAGSIRE", + "idleLabel": "QuagsireAnimationIdle", + "name": "QUAGSIRE" + }, + "QUILAVA": { + "animLabel": "QuilavaAnimation", + "back": "quilava_back", + "backLabel": "QuilavaBackpic", + "bitmaskLabel": "QuilavaBitmasks", + "dexLabel": "QuilavaPokedexEntry", + "framesLabel": "QuilavaFrames", + "front": "quilava", + "frontLabel": "QuilavaFrontpic", + "id": "QUILAVA", + "idleLabel": "QuilavaAnimationIdle", + "name": "QUILAVA" + }, + "QWILFISH": { + "animLabel": "QwilfishAnimation", + "back": "qwilfish_back", + "backLabel": "QwilfishBackpic", + "bitmaskLabel": "QwilfishBitmasks", + "dexLabel": "QwilfishPokedexEntry", + "framesLabel": "QwilfishFrames", + "front": "qwilfish", + "frontLabel": "QwilfishFrontpic", + "id": "QWILFISH", + "idleLabel": "QwilfishAnimationIdle", + "name": "QWILFISH" + }, + "RAICHU": { + "animLabel": "RaichuAnimation", + "back": "raichu_back", + "backLabel": "RaichuBackpic", + "bitmaskLabel": "RaichuBitmasks", + "dexLabel": "RaichuPokedexEntry", + "framesLabel": "RaichuFrames", + "front": "raichu", + "frontLabel": "RaichuFrontpic", + "id": "RAICHU", + "idleLabel": "RaichuAnimationIdle", + "name": "RAICHU" + }, + "RAIKOU": { + "animLabel": "RaikouAnimation", + "back": "raikou_back", + "backLabel": "RaikouBackpic", + "bitmaskLabel": "RaikouBitmasks", + "dexLabel": "RaikouPokedexEntry", + "framesLabel": "RaikouFrames", + "front": "raikou", + "frontLabel": "RaikouFrontpic", + "id": "RAIKOU", + "idleLabel": "RaikouAnimationIdle", + "name": "RAIKOU" + }, + "RAPIDASH": { + "animLabel": "RapidashAnimation", + "back": "rapidash_back", + "backLabel": "RapidashBackpic", + "bitmaskLabel": "RapidashBitmasks", + "dexLabel": "RapidashPokedexEntry", + "framesLabel": "RapidashFrames", + "front": "rapidash", + "frontLabel": "RapidashFrontpic", + "id": "RAPIDASH", + "idleLabel": "RapidashAnimationIdle", + "name": "RAPIDASH" + }, + "RATICATE": { + "animLabel": "RaticateAnimation", + "back": "raticate_back", + "backLabel": "RaticateBackpic", + "bitmaskLabel": "RaticateBitmasks", + "dexLabel": "RaticatePokedexEntry", + "framesLabel": "RaticateFrames", + "front": "raticate", + "frontLabel": "RaticateFrontpic", + "id": "RATICATE", + "idleLabel": "RaticateAnimationIdle", + "name": "RATICATE" + }, + "RATTATA": { + "animLabel": "RattataAnimation", + "back": "rattata_back", + "backLabel": "RattataBackpic", + "bitmaskLabel": "RattataBitmasks", + "dexLabel": "RattataPokedexEntry", + "framesLabel": "RattataFrames", + "front": "rattata", + "frontLabel": "RattataFrontpic", + "id": "RATTATA", + "idleLabel": "RattataAnimationIdle", + "name": "RATTATA" + }, + "REMORAID": { + "animLabel": "RemoraidAnimation", + "back": "remoraid_back", + "backLabel": "RemoraidBackpic", + "bitmaskLabel": "RemoraidBitmasks", + "dexLabel": "RemoraidPokedexEntry", + "framesLabel": "RemoraidFrames", + "front": "remoraid", + "frontLabel": "RemoraidFrontpic", + "id": "REMORAID", + "idleLabel": "RemoraidAnimationIdle", + "name": "REMORAID" + }, + "RHYDON": { + "animLabel": "RhydonAnimation", + "back": "rhydon_back", + "backLabel": "RhydonBackpic", + "bitmaskLabel": "RhydonBitmasks", + "dexLabel": "RhydonPokedexEntry", + "framesLabel": "RhydonFrames", + "front": "rhydon", + "frontLabel": "RhydonFrontpic", + "id": "RHYDON", + "idleLabel": "RhydonAnimationIdle", + "name": "RHYDON" + }, + "RHYHORN": { + "animLabel": "RhyhornAnimation", + "back": "rhyhorn_back", + "backLabel": "RhyhornBackpic", + "bitmaskLabel": "RhyhornBitmasks", + "dexLabel": "RhyhornPokedexEntry", + "framesLabel": "RhyhornFrames", + "front": "rhyhorn", + "frontLabel": "RhyhornFrontpic", + "id": "RHYHORN", + "idleLabel": "RhyhornAnimationIdle", + "name": "RHYHORN" + }, + "SANDSHREW": { + "animLabel": "SandshrewAnimation", + "back": "sandshrew_back", + "backLabel": "SandshrewBackpic", + "bitmaskLabel": "SandshrewBitmasks", + "dexLabel": "SandshrewPokedexEntry", + "framesLabel": "SandshrewFrames", + "front": "sandshrew", + "frontLabel": "SandshrewFrontpic", + "id": "SANDSHREW", + "idleLabel": "SandshrewAnimationIdle", + "name": "SANDSHREW" + }, + "SANDSLASH": { + "animLabel": "SandslashAnimation", + "back": "sandslash_back", + "backLabel": "SandslashBackpic", + "bitmaskLabel": "SandslashBitmasks", + "dexLabel": "SandslashPokedexEntry", + "framesLabel": "SandslashFrames", + "front": "sandslash", + "frontLabel": "SandslashFrontpic", + "id": "SANDSLASH", + "idleLabel": "SandslashAnimationIdle", + "name": "SANDSLASH" + }, + "SCIZOR": { + "animLabel": "ScizorAnimation", + "back": "scizor_back", + "backLabel": "ScizorBackpic", + "bitmaskLabel": "ScizorBitmasks", + "dexLabel": "ScizorPokedexEntry", + "framesLabel": "ScizorFrames", + "front": "scizor", + "frontLabel": "ScizorFrontpic", + "id": "SCIZOR", + "idleLabel": "ScizorAnimationIdle", + "name": "SCIZOR" + }, + "SCYTHER": { + "animLabel": "ScytherAnimation", + "back": "scyther_back", + "backLabel": "ScytherBackpic", + "bitmaskLabel": "ScytherBitmasks", + "dexLabel": "ScytherPokedexEntry", + "framesLabel": "ScytherFrames", + "front": "scyther", + "frontLabel": "ScytherFrontpic", + "id": "SCYTHER", + "idleLabel": "ScytherAnimationIdle", + "name": "SCYTHER" + }, + "SEADRA": { + "animLabel": "SeadraAnimation", + "back": "seadra_back", + "backLabel": "SeadraBackpic", + "bitmaskLabel": "SeadraBitmasks", + "dexLabel": "SeadraPokedexEntry", + "framesLabel": "SeadraFrames", + "front": "seadra", + "frontLabel": "SeadraFrontpic", + "id": "SEADRA", + "idleLabel": "SeadraAnimationIdle", + "name": "SEADRA" + }, + "SEAKING": { + "animLabel": "SeakingAnimation", + "back": "seaking_back", + "backLabel": "SeakingBackpic", + "bitmaskLabel": "SeakingBitmasks", + "dexLabel": "SeakingPokedexEntry", + "framesLabel": "SeakingFrames", + "front": "seaking", + "frontLabel": "SeakingFrontpic", + "id": "SEAKING", + "idleLabel": "SeakingAnimationIdle", + "name": "SEAKING" + }, + "SEEL": { + "animLabel": "SeelAnimation", + "back": "seel_back", + "backLabel": "SeelBackpic", + "bitmaskLabel": "SeelBitmasks", + "dexLabel": "SeelPokedexEntry", + "framesLabel": "SeelFrames", + "front": "seel", + "frontLabel": "SeelFrontpic", + "id": "SEEL", + "idleLabel": "SeelAnimationIdle", + "name": "SEEL" + }, + "SENTRET": { + "animLabel": "SentretAnimation", + "back": "sentret_back", + "backLabel": "SentretBackpic", + "bitmaskLabel": "SentretBitmasks", + "dexLabel": "SentretPokedexEntry", + "framesLabel": "SentretFrames", + "front": "sentret", + "frontLabel": "SentretFrontpic", + "id": "SENTRET", + "idleLabel": "SentretAnimationIdle", + "name": "SENTRET" + }, + "SHELLDER": { + "animLabel": "ShellderAnimation", + "back": "shellder_back", + "backLabel": "ShellderBackpic", + "bitmaskLabel": "ShellderBitmasks", + "dexLabel": "ShellderPokedexEntry", + "framesLabel": "ShellderFrames", + "front": "shellder", + "frontLabel": "ShellderFrontpic", + "id": "SHELLDER", + "idleLabel": "ShellderAnimationIdle", + "name": "SHELLDER" + }, + "SHUCKLE": { + "animLabel": "ShuckleAnimation", + "back": "shuckle_back", + "backLabel": "ShuckleBackpic", + "bitmaskLabel": "ShuckleBitmasks", + "dexLabel": "ShucklePokedexEntry", + "framesLabel": "ShuckleFrames", + "front": "shuckle", + "frontLabel": "ShuckleFrontpic", + "id": "SHUCKLE", + "idleLabel": "ShuckleAnimationIdle", + "name": "SHUCKLE" + }, + "SKARMORY": { + "animLabel": "SkarmoryAnimation", + "back": "skarmory_back", + "backLabel": "SkarmoryBackpic", + "bitmaskLabel": "SkarmoryBitmasks", + "dexLabel": "SkarmoryPokedexEntry", + "framesLabel": "SkarmoryFrames", + "front": "skarmory", + "frontLabel": "SkarmoryFrontpic", + "id": "SKARMORY", + "idleLabel": "SkarmoryAnimationIdle", + "name": "SKARMORY" + }, + "SKIPLOOM": { + "animLabel": "SkiploomAnimation", + "back": "skiploom_back", + "backLabel": "SkiploomBackpic", + "bitmaskLabel": "SkiploomBitmasks", + "dexLabel": "SkiploomPokedexEntry", + "framesLabel": "SkiploomFrames", + "front": "skiploom", + "frontLabel": "SkiploomFrontpic", + "id": "SKIPLOOM", + "idleLabel": "SkiploomAnimationIdle", + "name": "SKIPLOOM" + }, + "SLOWBRO": { + "animLabel": "SlowbroAnimation", + "back": "slowbro_back", + "backLabel": "SlowbroBackpic", + "bitmaskLabel": "SlowbroBitmasks", + "dexLabel": "SlowbroPokedexEntry", + "framesLabel": "SlowbroFrames", + "front": "slowbro", + "frontLabel": "SlowbroFrontpic", + "id": "SLOWBRO", + "idleLabel": "SlowbroAnimationIdle", + "name": "SLOWBRO" + }, + "SLOWKING": { + "animLabel": "SlowkingAnimation", + "back": "slowking_back", + "backLabel": "SlowkingBackpic", + "bitmaskLabel": "SlowkingBitmasks", + "dexLabel": "SlowkingPokedexEntry", + "framesLabel": "SlowkingFrames", + "front": "slowking", + "frontLabel": "SlowkingFrontpic", + "id": "SLOWKING", + "idleLabel": "SlowkingAnimationIdle", + "name": "SLOWKING" + }, + "SLOWPOKE": { + "animLabel": "SlowpokeAnimation", + "back": "slowpoke_back", + "backLabel": "SlowpokeBackpic", + "bitmaskLabel": "SlowpokeBitmasks", + "dexLabel": "SlowpokePokedexEntry", + "framesLabel": "SlowpokeFrames", + "front": "slowpoke", + "frontLabel": "SlowpokeFrontpic", + "id": "SLOWPOKE", + "idleLabel": "SlowpokeAnimationIdle", + "name": "SLOWPOKE" + }, + "SLUGMA": { + "animLabel": "SlugmaAnimation", + "back": "slugma_back", + "backLabel": "SlugmaBackpic", + "bitmaskLabel": "SlugmaBitmasks", + "dexLabel": "SlugmaPokedexEntry", + "framesLabel": "SlugmaFrames", + "front": "slugma", + "frontLabel": "SlugmaFrontpic", + "id": "SLUGMA", + "idleLabel": "SlugmaAnimationIdle", + "name": "SLUGMA" + }, + "SMEARGLE": { + "animLabel": "SmeargleAnimation", + "back": "smeargle_back", + "backLabel": "SmeargleBackpic", + "bitmaskLabel": "SmeargleBitmasks", + "dexLabel": "SmearglePokedexEntry", + "framesLabel": "SmeargleFrames", + "front": "smeargle", + "frontLabel": "SmeargleFrontpic", + "id": "SMEARGLE", + "idleLabel": "SmeargleAnimationIdle", + "name": "SMEARGLE" + }, + "SMOOCHUM": { + "animLabel": "SmoochumAnimation", + "back": "smoochum_back", + "backLabel": "SmoochumBackpic", + "bitmaskLabel": "SmoochumBitmasks", + "dexLabel": "SmoochumPokedexEntry", + "framesLabel": "SmoochumFrames", + "front": "smoochum", + "frontLabel": "SmoochumFrontpic", + "id": "SMOOCHUM", + "idleLabel": "SmoochumAnimationIdle", + "name": "SMOOCHUM" + }, + "SNEASEL": { + "animLabel": "SneaselAnimation", + "back": "sneasel_back", + "backLabel": "SneaselBackpic", + "bitmaskLabel": "SneaselBitmasks", + "dexLabel": "SneaselPokedexEntry", + "framesLabel": "SneaselFrames", + "front": "sneasel", + "frontLabel": "SneaselFrontpic", + "id": "SNEASEL", + "idleLabel": "SneaselAnimationIdle", + "name": "SNEASEL" + }, + "SNORLAX": { + "animLabel": "SnorlaxAnimation", + "back": "snorlax_back", + "backLabel": "SnorlaxBackpic", + "bitmaskLabel": "SnorlaxBitmasks", + "dexLabel": "SnorlaxPokedexEntry", + "framesLabel": "SnorlaxFrames", + "front": "snorlax", + "frontLabel": "SnorlaxFrontpic", + "id": "SNORLAX", + "idleLabel": "SnorlaxAnimationIdle", + "name": "SNORLAX" + }, + "SNUBBULL": { + "animLabel": "SnubbullAnimation", + "back": "snubbull_back", + "backLabel": "SnubbullBackpic", + "bitmaskLabel": "SnubbullBitmasks", + "dexLabel": "SnubbullPokedexEntry", + "framesLabel": "SnubbullFrames", + "front": "snubbull", + "frontLabel": "SnubbullFrontpic", + "id": "SNUBBULL", + "idleLabel": "SnubbullAnimationIdle", + "name": "SNUBBULL" + }, + "SPEAROW": { + "animLabel": "SpearowAnimation", + "back": "spearow_back", + "backLabel": "SpearowBackpic", + "bitmaskLabel": "SpearowBitmasks", + "dexLabel": "SpearowPokedexEntry", + "framesLabel": "SpearowFrames", + "front": "spearow", + "frontLabel": "SpearowFrontpic", + "id": "SPEAROW", + "idleLabel": "SpearowAnimationIdle", + "name": "SPEAROW" + }, + "SPINARAK": { + "animLabel": "SpinarakAnimation", + "back": "spinarak_back", + "backLabel": "SpinarakBackpic", + "bitmaskLabel": "SpinarakBitmasks", + "dexLabel": "SpinarakPokedexEntry", + "framesLabel": "SpinarakFrames", + "front": "spinarak", + "frontLabel": "SpinarakFrontpic", + "id": "SPINARAK", + "idleLabel": "SpinarakAnimationIdle", + "name": "SPINARAK" + }, + "SQUIRTLE": { + "animLabel": "SquirtleAnimation", + "back": "squirtle_back", + "backLabel": "SquirtleBackpic", + "bitmaskLabel": "SquirtleBitmasks", + "dexLabel": "SquirtlePokedexEntry", + "framesLabel": "SquirtleFrames", + "front": "squirtle", + "frontLabel": "SquirtleFrontpic", + "id": "SQUIRTLE", + "idleLabel": "SquirtleAnimationIdle", + "name": "SQUIRTLE" + }, + "STANTLER": { + "animLabel": "StantlerAnimation", + "back": "stantler_back", + "backLabel": "StantlerBackpic", + "bitmaskLabel": "StantlerBitmasks", + "dexLabel": "StantlerPokedexEntry", + "framesLabel": "StantlerFrames", + "front": "stantler", + "frontLabel": "StantlerFrontpic", + "id": "STANTLER", + "idleLabel": "StantlerAnimationIdle", + "name": "STANTLER" + }, + "STARMIE": { + "animLabel": "StarmieAnimation", + "back": "starmie_back", + "backLabel": "StarmieBackpic", + "bitmaskLabel": "StarmieBitmasks", + "dexLabel": "StarmiePokedexEntry", + "framesLabel": "StarmieFrames", + "front": "starmie", + "frontLabel": "StarmieFrontpic", + "id": "STARMIE", + "idleLabel": "StarmieAnimationIdle", + "name": "STARMIE" + }, + "STARYU": { + "animLabel": "StaryuAnimation", + "back": "staryu_back", + "backLabel": "StaryuBackpic", + "bitmaskLabel": "StaryuBitmasks", + "dexLabel": "StaryuPokedexEntry", + "framesLabel": "StaryuFrames", + "front": "staryu", + "frontLabel": "StaryuFrontpic", + "id": "STARYU", + "idleLabel": "StaryuAnimationIdle", + "name": "STARYU" + }, + "STEELIX": { + "animLabel": "SteelixAnimation", + "back": "steelix_back", + "backLabel": "SteelixBackpic", + "bitmaskLabel": "SteelixBitmasks", + "dexLabel": "SteelixPokedexEntry", + "framesLabel": "SteelixFrames", + "front": "steelix", + "frontLabel": "SteelixFrontpic", + "id": "STEELIX", + "idleLabel": "SteelixAnimationIdle", + "name": "STEELIX" + }, + "SUDOWOODO": { + "animLabel": "SudowoodoAnimation", + "back": "sudowoodo_back", + "backLabel": "SudowoodoBackpic", + "bitmaskLabel": "SudowoodoBitmasks", + "dexLabel": "SudowoodoPokedexEntry", + "framesLabel": "SudowoodoFrames", + "front": "sudowoodo", + "frontLabel": "SudowoodoFrontpic", + "id": "SUDOWOODO", + "idleLabel": "SudowoodoAnimationIdle", + "name": "SUDOWOODO" + }, + "SUICUNE": { + "animLabel": "SuicuneAnimation", + "back": "suicune_back", + "backLabel": "SuicuneBackpic", + "bitmaskLabel": "SuicuneBitmasks", + "dexLabel": "SuicunePokedexEntry", + "framesLabel": "SuicuneFrames", + "front": "suicune", + "frontLabel": "SuicuneFrontpic", + "id": "SUICUNE", + "idleLabel": "SuicuneAnimationIdle", + "name": "SUICUNE" + }, + "SUNFLORA": { + "animLabel": "SunfloraAnimation", + "back": "sunflora_back", + "backLabel": "SunfloraBackpic", + "bitmaskLabel": "SunfloraBitmasks", + "dexLabel": "SunfloraPokedexEntry", + "framesLabel": "SunfloraFrames", + "front": "sunflora", + "frontLabel": "SunfloraFrontpic", + "id": "SUNFLORA", + "idleLabel": "SunfloraAnimationIdle", + "name": "SUNFLORA" + }, + "SUNKERN": { + "animLabel": "SunkernAnimation", + "back": "sunkern_back", + "backLabel": "SunkernBackpic", + "bitmaskLabel": "SunkernBitmasks", + "dexLabel": "SunkernPokedexEntry", + "framesLabel": "SunkernFrames", + "front": "sunkern", + "frontLabel": "SunkernFrontpic", + "id": "SUNKERN", + "idleLabel": "SunkernAnimationIdle", + "name": "SUNKERN" + }, + "SWINUB": { + "animLabel": "SwinubAnimation", + "back": "swinub_back", + "backLabel": "SwinubBackpic", + "bitmaskLabel": "SwinubBitmasks", + "dexLabel": "SwinubPokedexEntry", + "framesLabel": "SwinubFrames", + "front": "swinub", + "frontLabel": "SwinubFrontpic", + "id": "SWINUB", + "idleLabel": "SwinubAnimationIdle", + "name": "SWINUB" + }, + "TANGELA": { + "animLabel": "TangelaAnimation", + "back": "tangela_back", + "backLabel": "TangelaBackpic", + "bitmaskLabel": "TangelaBitmasks", + "dexLabel": "TangelaPokedexEntry", + "framesLabel": "TangelaFrames", + "front": "tangela", + "frontLabel": "TangelaFrontpic", + "id": "TANGELA", + "idleLabel": "TangelaAnimationIdle", + "name": "TANGELA" + }, + "TAUROS": { + "animLabel": "TaurosAnimation", + "back": "tauros_back", + "backLabel": "TaurosBackpic", + "bitmaskLabel": "TaurosBitmasks", + "dexLabel": "TaurosPokedexEntry", + "framesLabel": "TaurosFrames", + "front": "tauros", + "frontLabel": "TaurosFrontpic", + "id": "TAUROS", + "idleLabel": "TaurosAnimationIdle", + "name": "TAUROS" + }, + "TEDDIURSA": { + "animLabel": "TeddiursaAnimation", + "back": "teddiursa_back", + "backLabel": "TeddiursaBackpic", + "bitmaskLabel": "TeddiursaBitmasks", + "dexLabel": "TeddiursaPokedexEntry", + "framesLabel": "TeddiursaFrames", + "front": "teddiursa", + "frontLabel": "TeddiursaFrontpic", + "id": "TEDDIURSA", + "idleLabel": "TeddiursaAnimationIdle", + "name": "TEDDIURSA" + }, + "TENTACOOL": { + "animLabel": "TentacoolAnimation", + "back": "tentacool_back", + "backLabel": "TentacoolBackpic", + "bitmaskLabel": "TentacoolBitmasks", + "dexLabel": "TentacoolPokedexEntry", + "framesLabel": "TentacoolFrames", + "front": "tentacool", + "frontLabel": "TentacoolFrontpic", + "id": "TENTACOOL", + "idleLabel": "TentacoolAnimationIdle", + "name": "TENTACOOL" + }, + "TENTACRUEL": { + "animLabel": "TentacruelAnimation", + "back": "tentacruel_back", + "backLabel": "TentacruelBackpic", + "bitmaskLabel": "TentacruelBitmasks", + "dexLabel": "TentacruelPokedexEntry", + "framesLabel": "TentacruelFrames", + "front": "tentacruel", + "frontLabel": "TentacruelFrontpic", + "id": "TENTACRUEL", + "idleLabel": "TentacruelAnimationIdle", + "name": "TENTACRUEL" + }, + "TOGEPI": { + "animLabel": "TogepiAnimation", + "back": "togepi_back", + "backLabel": "TogepiBackpic", + "bitmaskLabel": "TogepiBitmasks", + "dexLabel": "TogepiPokedexEntry", + "framesLabel": "TogepiFrames", + "front": "togepi", + "frontLabel": "TogepiFrontpic", + "id": "TOGEPI", + "idleLabel": "TogepiAnimationIdle", + "name": "TOGEPI" + }, + "TOGETIC": { + "animLabel": "TogeticAnimation", + "back": "togetic_back", + "backLabel": "TogeticBackpic", + "bitmaskLabel": "TogeticBitmasks", + "dexLabel": "TogeticPokedexEntry", + "framesLabel": "TogeticFrames", + "front": "togetic", + "frontLabel": "TogeticFrontpic", + "id": "TOGETIC", + "idleLabel": "TogeticAnimationIdle", + "name": "TOGETIC" + }, + "TOTODILE": { + "animLabel": "TotodileAnimation", + "back": "totodile_back", + "backLabel": "TotodileBackpic", + "bitmaskLabel": "TotodileBitmasks", + "dexLabel": "TotodilePokedexEntry", + "framesLabel": "TotodileFrames", + "front": "totodile", + "frontLabel": "TotodileFrontpic", + "id": "TOTODILE", + "idleLabel": "TotodileAnimationIdle", + "name": "TOTODILE" + }, + "TYPHLOSION": { + "animLabel": "TyphlosionAnimation", + "back": "typhlosion_back", + "backLabel": "TyphlosionBackpic", + "bitmaskLabel": "TyphlosionBitmasks", + "dexLabel": "TyphlosionPokedexEntry", + "framesLabel": "TyphlosionFrames", + "front": "typhlosion", + "frontLabel": "TyphlosionFrontpic", + "id": "TYPHLOSION", + "idleLabel": "TyphlosionAnimationIdle", + "name": "TYPHLOSION" + }, + "TYRANITAR": { + "animLabel": "TyranitarAnimation", + "back": "tyranitar_back", + "backLabel": "TyranitarBackpic", + "bitmaskLabel": "TyranitarBitmasks", + "dexLabel": "TyranitarPokedexEntry", + "framesLabel": "TyranitarFrames", + "front": "tyranitar", + "frontLabel": "TyranitarFrontpic", + "id": "TYRANITAR", + "idleLabel": "TyranitarAnimationIdle", + "name": "TYRANITAR" + }, + "TYROGUE": { + "animLabel": "TyrogueAnimation", + "back": "tyrogue_back", + "backLabel": "TyrogueBackpic", + "bitmaskLabel": "TyrogueBitmasks", + "dexLabel": "TyroguePokedexEntry", + "framesLabel": "TyrogueFrames", + "front": "tyrogue", + "frontLabel": "TyrogueFrontpic", + "id": "TYROGUE", + "idleLabel": "TyrogueAnimationIdle", + "name": "TYROGUE" + }, + "UMBREON": { + "animLabel": "UmbreonAnimation", + "back": "umbreon_back", + "backLabel": "UmbreonBackpic", + "bitmaskLabel": "UmbreonBitmasks", + "dexLabel": "UmbreonPokedexEntry", + "framesLabel": "UmbreonFrames", + "front": "umbreon", + "frontLabel": "UmbreonFrontpic", + "id": "UMBREON", + "idleLabel": "UmbreonAnimationIdle", + "name": "UMBREON" + }, + "UNOWN": { + "animLabel": "UnownAnimation", + "back": null, + "backLabel": null, + "bitmaskLabel": "UnownBitmasks", + "dexLabel": "UnownPokedexEntry", + "framesLabel": "UnownFrames", + "front": null, + "frontLabel": null, + "id": "UNOWN", + "idleLabel": "UnownAnimationIdle", + "name": "UNOWN" + }, + "URSARING": { + "animLabel": "UrsaringAnimation", + "back": "ursaring_back", + "backLabel": "UrsaringBackpic", + "bitmaskLabel": "UrsaringBitmasks", + "dexLabel": "UrsaringPokedexEntry", + "framesLabel": "UrsaringFrames", + "front": "ursaring", + "frontLabel": "UrsaringFrontpic", + "id": "URSARING", + "idleLabel": "UrsaringAnimationIdle", + "name": "URSARING" + }, + "VAPOREON": { + "animLabel": "VaporeonAnimation", + "back": "vaporeon_back", + "backLabel": "VaporeonBackpic", + "bitmaskLabel": "VaporeonBitmasks", + "dexLabel": "VaporeonPokedexEntry", + "framesLabel": "VaporeonFrames", + "front": "vaporeon", + "frontLabel": "VaporeonFrontpic", + "id": "VAPOREON", + "idleLabel": "VaporeonAnimationIdle", + "name": "VAPOREON" + }, + "VENOMOTH": { + "animLabel": "VenomothAnimation", + "back": "venomoth_back", + "backLabel": "VenomothBackpic", + "bitmaskLabel": "VenomothBitmasks", + "dexLabel": "VenomothPokedexEntry", + "framesLabel": "VenomothFrames", + "front": "venomoth", + "frontLabel": "VenomothFrontpic", + "id": "VENOMOTH", + "idleLabel": "VenomothAnimationIdle", + "name": "VENOMOTH" + }, + "VENONAT": { + "animLabel": "VenonatAnimation", + "back": "venonat_back", + "backLabel": "VenonatBackpic", + "bitmaskLabel": "VenonatBitmasks", + "dexLabel": "VenonatPokedexEntry", + "framesLabel": "VenonatFrames", + "front": "venonat", + "frontLabel": "VenonatFrontpic", + "id": "VENONAT", + "idleLabel": "VenonatAnimationIdle", + "name": "VENONAT" + }, + "VENUSAUR": { + "animLabel": "VenusaurAnimation", + "back": "venusaur_back", + "backLabel": "VenusaurBackpic", + "bitmaskLabel": "VenusaurBitmasks", + "dexLabel": "VenusaurPokedexEntry", + "framesLabel": "VenusaurFrames", + "front": "venusaur", + "frontLabel": "VenusaurFrontpic", + "id": "VENUSAUR", + "idleLabel": "VenusaurAnimationIdle", + "name": "VENUSAUR" + }, + "VICTREEBEL": { + "animLabel": "VictreebelAnimation", + "back": "victreebel_back", + "backLabel": "VictreebelBackpic", + "bitmaskLabel": "VictreebelBitmasks", + "dexLabel": "VictreebelPokedexEntry", + "framesLabel": "VictreebelFrames", + "front": "victreebel", + "frontLabel": "VictreebelFrontpic", + "id": "VICTREEBEL", + "idleLabel": "VictreebelAnimationIdle", + "name": "VICTREEBEL" + }, + "VILEPLUME": { + "animLabel": "VileplumeAnimation", + "back": "vileplume_back", + "backLabel": "VileplumeBackpic", + "bitmaskLabel": "VileplumeBitmasks", + "dexLabel": "VileplumePokedexEntry", + "framesLabel": "VileplumeFrames", + "front": "vileplume", + "frontLabel": "VileplumeFrontpic", + "id": "VILEPLUME", + "idleLabel": "VileplumeAnimationIdle", + "name": "VILEPLUME" + }, + "VOLTORB": { + "animLabel": "VoltorbAnimation", + "back": "voltorb_back", + "backLabel": "VoltorbBackpic", + "bitmaskLabel": "VoltorbBitmasks", + "dexLabel": "VoltorbPokedexEntry", + "framesLabel": "VoltorbFrames", + "front": "voltorb", + "frontLabel": "VoltorbFrontpic", + "id": "VOLTORB", + "idleLabel": "VoltorbAnimationIdle", + "name": "VOLTORB" + }, + "VULPIX": { + "animLabel": "VulpixAnimation", + "back": "vulpix_back", + "backLabel": "VulpixBackpic", + "bitmaskLabel": "VulpixBitmasks", + "dexLabel": "VulpixPokedexEntry", + "framesLabel": "VulpixFrames", + "front": "vulpix", + "frontLabel": "VulpixFrontpic", + "id": "VULPIX", + "idleLabel": "VulpixAnimationIdle", + "name": "VULPIX" + }, + "WARTORTLE": { + "animLabel": "WartortleAnimation", + "back": "wartortle_back", + "backLabel": "WartortleBackpic", + "bitmaskLabel": "WartortleBitmasks", + "dexLabel": "WartortlePokedexEntry", + "framesLabel": "WartortleFrames", + "front": "wartortle", + "frontLabel": "WartortleFrontpic", + "id": "WARTORTLE", + "idleLabel": "WartortleAnimationIdle", + "name": "WARTORTLE" + }, + "WEEDLE": { + "animLabel": "WeedleAnimation", + "back": "weedle_back", + "backLabel": "WeedleBackpic", + "bitmaskLabel": "WeedleBitmasks", + "dexLabel": "WeedlePokedexEntry", + "framesLabel": "WeedleFrames", + "front": "weedle", + "frontLabel": "WeedleFrontpic", + "id": "WEEDLE", + "idleLabel": "WeedleAnimationIdle", + "name": "WEEDLE" + }, + "WEEPINBELL": { + "animLabel": "WeepinbellAnimation", + "back": "weepinbell_back", + "backLabel": "WeepinbellBackpic", + "bitmaskLabel": "WeepinbellBitmasks", + "dexLabel": "WeepinbellPokedexEntry", + "framesLabel": "WeepinbellFrames", + "front": "weepinbell", + "frontLabel": "WeepinbellFrontpic", + "id": "WEEPINBELL", + "idleLabel": "WeepinbellAnimationIdle", + "name": "WEEPINBELL" + }, + "WEEZING": { + "animLabel": "WeezingAnimation", + "back": "weezing_back", + "backLabel": "WeezingBackpic", + "bitmaskLabel": "WeezingBitmasks", + "dexLabel": "WeezingPokedexEntry", + "framesLabel": "WeezingFrames", + "front": "weezing", + "frontLabel": "WeezingFrontpic", + "id": "WEEZING", + "idleLabel": "WeezingAnimationIdle", + "name": "WEEZING" + }, + "WIGGLYTUFF": { + "animLabel": "WigglytuffAnimation", + "back": "wigglytuff_back", + "backLabel": "WigglytuffBackpic", + "bitmaskLabel": "WigglytuffBitmasks", + "dexLabel": "WigglytuffPokedexEntry", + "framesLabel": "WigglytuffFrames", + "front": "wigglytuff", + "frontLabel": "WigglytuffFrontpic", + "id": "WIGGLYTUFF", + "idleLabel": "WigglytuffAnimationIdle", + "name": "WIGGLYTUFF" + }, + "WOBBUFFET": { + "animLabel": "WobbuffetAnimation", + "back": "wobbuffet_back", + "backLabel": "WobbuffetBackpic", + "bitmaskLabel": "WobbuffetBitmasks", + "dexLabel": "WobbuffetPokedexEntry", + "framesLabel": "WobbuffetFrames", + "front": "wobbuffet", + "frontLabel": "WobbuffetFrontpic", + "id": "WOBBUFFET", + "idleLabel": "WobbuffetAnimationIdle", + "name": "WOBBUFFET" + }, + "WOOPER": { + "animLabel": "WooperAnimation", + "back": "wooper_back", + "backLabel": "WooperBackpic", + "bitmaskLabel": "WooperBitmasks", + "dexLabel": "WooperPokedexEntry", + "framesLabel": "WooperFrames", + "front": "wooper", + "frontLabel": "WooperFrontpic", + "id": "WOOPER", + "idleLabel": "WooperAnimationIdle", + "name": "WOOPER" + }, + "XATU": { + "animLabel": "XatuAnimation", + "back": "xatu_back", + "backLabel": "XatuBackpic", + "bitmaskLabel": "XatuBitmasks", + "dexLabel": "XatuPokedexEntry", + "framesLabel": "XatuFrames", + "front": "xatu", + "frontLabel": "XatuFrontpic", + "id": "XATU", + "idleLabel": "XatuAnimationIdle", + "name": "XATU" + }, + "YANMA": { + "animLabel": "YanmaAnimation", + "back": "yanma_back", + "backLabel": "YanmaBackpic", + "bitmaskLabel": "YanmaBitmasks", + "dexLabel": "YanmaPokedexEntry", + "framesLabel": "YanmaFrames", + "front": "yanma", + "frontLabel": "YanmaFrontpic", + "id": "YANMA", + "idleLabel": "YanmaAnimationIdle", + "name": "YANMA" + }, + "ZAPDOS": { + "animLabel": "ZapdosAnimation", + "back": "zapdos_back", + "backLabel": "ZapdosBackpic", + "bitmaskLabel": "ZapdosBitmasks", + "dexLabel": "ZapdosPokedexEntry", + "framesLabel": "ZapdosFrames", + "front": "zapdos", + "frontLabel": "ZapdosFrontpic", + "id": "ZAPDOS", + "idleLabel": "ZapdosAnimationIdle", + "name": "ZAPDOS" + }, + "ZUBAT": { + "animLabel": "ZubatAnimation", + "back": "zubat_back", + "backLabel": "ZubatBackpic", + "bitmaskLabel": "ZubatBitmasks", + "dexLabel": "ZubatPokedexEntry", + "framesLabel": "ZubatFrames", + "front": "zubat", + "frontLabel": "ZubatFrontpic", + "id": "ZUBAT", + "idleLabel": "ZubatAnimationIdle", + "name": "ZUBAT" + } + }, + "romSha1": "f4cd194bdee0d04ca4eac29e09b8e4e9d818c133", + "symbols": { + "AbraBackpic": [ + 86, + 25647 + ], + "AbraFrontpic": [ + 81, + 30783 + ], + "AbraPokedexEntry": [ + 96, + 28885 + ], + "AerodactylBackpic": [ + 86, + 25982 + ], + "AerodactylFrontpic": [ + 75, + 24187 + ], + "AerodactylPokedexEntry": [ + 115, + 17782 + ], + "AerodactylPuzzleLZ": [ + 56, + 23035 + ], + "AipomBackpic": [ + 86, + 27985 + ], + "AipomFrontpic": [ + 81, + 30322 + ], + "AipomPokedexEntry": [ + 115, + 23057 + ], + "AlakazamBackpic": [ + 81, + 29861 + ], + "AlakazamFrontpic": [ + 74, + 17581 + ], + "AlakazamPokedexEntry": [ + 110, + 16384 + ], + "AlphabeticalPokedexOrder": [ + 16, + 19557 + ], + "AlreadyAsleepText": [ + 32, + 20929 + ], + "AlreadyConfusedText": [ + 32, + 19916 + ], + "AlreadyParalyzedText": [ + 32, + 21906 + ], + "AlreadyPoisonedText": [ + 32, + 20986 + ], + "AmpharosBackpic": [ + 88, + 26249 + ], + "AmpharosFrontpic": [ + 77, + 22993 + ], + "AmpharosPokedexEntry": [ + 115, + 22054 + ], + "AnimObjGFX": [ + 51, + 31990 + ], + "AnimateFlowerTile": [ + 63, + 17773 + ], + "AnimateFlowerTile.FlowerTileFrames": [ + 63, + 17804 + ], + "AnimateFountainTile": [ + 63, + 17287 + ], + "AnimateFrontpic": [ + 52, + 16526 + ], + "AnimateHallOfFame.String_NewHallOfFamer": [ + 33, + 25900 + ], + "AnimateLavaBubbleTile1": [ + 63, + 17868 + ], + "AnimateLavaBubbleTile2": [ + 63, + 17899 + ], + "AnimateTowerPillarTile": [ + 63, + 17989 + ], + "AnimateTrademonFrontpic": [ + 19, + 22558 + ], + "AnimateWaterPalette": [ + 63, + 18135 + ], + "AnimateWaterTile": [ + 63, + 17410 + ], + "AnimateWaterTile.WaterTileFrames": [ + 63, + 17436 + ], + "AnimateWhirlpoolTile": [ + 63, + 18040 + ], + "AnimationIdlePointers": [ + 52, + 22179 + ], + "AnimationPointers": [ + 52, + 18069 + ], + "ArbokBackpic": [ + 88, + 22126 + ], + "ArbokFrontpic": [ + 73, + 28016 + ], + "ArbokPokedexEntry": [ + 96, + 24637 + ], + "ArcanineBackpic": [ + 83, + 30930 + ], + "ArcanineFrontpic": [ + 73, + 23940 + ], + "ArcaninePokedexEntry": [ + 96, + 28444 + ], + "AriadosBackpic": [ + 86, + 19864 + ], + "AriadosFrontpic": [ + 77, + 16384 + ], + "AriadosPokedexEntry": [ + 115, + 20611 + ], + "ArticunoBackpic": [ + 88, + 28867 + ], + "ArticunoFrontpic": [ + 72, + 26326 + ], + "ArticunoPokedexEntry": [ + 115, + 17970 + ], + "AsciiFontGFX": [ + 92, + 23985 + ], + "AttackMissed2Text": [ + 32, + 20531 + ], + "AttackMissedText": [ + 32, + 20512 + ], + "AzumarillBackpic": [ + 88, + 26780 + ], + "AzumarillFrontpic": [ + 76, + 29842 + ], + "AzumarillPokedexEntry": [ + 115, + 22385 + ], + "BTTrainerClassSprites": [ + 92, + 19344 + ], + "BadgeGFX": [ + 9, + 24643 + ], + "BadlyPoisonedText": [ + 32, + 20966 + ], + "BargainShopData": [ + 5, + 23633 + ], + "BaseData": [ + 20, + 21540 + ], + "BattleAnimFrameData": [ + 51, + 26718 + ], + "BattleAnimOAMData": [ + 51, + 28334 + ], + "BattleAnimObjects": [ + 51, + 19286 + ], + "BattleAnimations": [ + 50, + 20591 + ], + "BattleObjectPals": [ + 2, + 22428 + ], + "BattleText_AnEGGCantBattle": [ + 32, + 19238 + ], + "BattleText_CantEscape": [ + 32, + 19360 + ], + "BattleText_CantEscape2": [ + 32, + 19259 + ], + "BattleText_EnemyFled": [ + 32, + 18383 + ], + "BattleText_EnemyIsAboutToUseWillPlayerChangeMon": [ + 32, + 19146 + ], + "BattleText_EnemyMonFainted": [ + 32, + 18856 + ], + "BattleText_EnemySentOut": [ + 32, + 19192 + ], + "BattleText_EnemyWasDefeated": [ + 32, + 18906 + ], + "BattleText_GotAwaySafely": [ + 32, + 19319 + ], + "BattleText_ItemHealedConfusion": [ + 32, + 19883 + ], + "BattleText_ItemsCantBeUsedHere": [ + 32, + 19443 + ], + "BattleText_LinkErrorBattleCanceled": [ + 32, + 22627 + ], + "BattleText_MonCantBeRecalled": [ + 32, + 19490 + ], + "BattleText_MonFainted": [ + 32, + 19061 + ], + "BattleText_MonHasNoMovesLeft": [ + 32, + 19570 + ], + "BattleText_MonIsAlreadyOut": [ + 32, + 19469 + ], + "BattleText_MonsLightScreenFell": [ + 32, + 18663 + ], + "BattleText_MonsReflectFaded": [ + 32, + 18693 + ], + "BattleText_NoTimeLeftToday": [ + 32, + 22670 + ], + "BattleText_PlayerPickedUpPayDayMoney": [ + 32, + 18224 + ], + "BattleText_RainContinuesToFall": [ + 32, + 18719 + ], + "BattleText_SafeguardFaded": [ + 32, + 18642 + ], + "BattleText_StringBuffer1GrewToLevel": [ + 32, + 19612 + ], + "BattleText_TargetRecoveredWithItem": [ + 32, + 18560 + ], + "BattleText_TargetWasHitByFutureSight": [ + 32, + 18614 + ], + "BattleText_TargetsEncoreEnded": [ + 32, + 19594 + ], + "BattleText_TheMoveIsDisabled": [ + 32, + 19547 + ], + "BattleText_TheRainStopped": [ + 32, + 18791 + ], + "BattleText_TheSandstormRages": [ + 32, + 18769 + ], + "BattleText_TheSandstormSubsided": [ + 32, + 18831 + ], + "BattleText_TheSunlightFaded": [ + 32, + 18810 + ], + "BattleText_TheSunlightIsStrong": [ + 32, + 18744 + ], + "BattleText_TheresNoEscapeFromTrainerBattle": [ + 32, + 19273 + ], + "BattleText_TheresNoPPLeftForThisMove": [ + 32, + 19513 + ], + "BattleText_TheresNoWillToBattle": [ + 32, + 19211 + ], + "BattleText_UseNextMon": [ + 32, + 19075 + ], + "BattleText_UserFledUsingAStringBuffer1": [ + 32, + 19337 + ], + "BattleText_UserHurtBySpikes": [ + 32, + 19374 + ], + "BattleText_UserRecoveredPPUsing": [ + 32, + 18585 + ], + "BattleText_UserWasReleasedFromStringBuffer1": [ + 32, + 19957 + ], + "BattleText_UsersHurtByStringBuffer1": [ + 32, + 19938 + ], + "BattleText_UsersStringBuffer1Activated": [ + 32, + 19422 + ], + "BattleText_WildFled": [ + 32, + 18365 + ], + "BattleText_WildMonIsAngry": [ + 32, + 19665 + ], + "BattleText_WildMonIsEating": [ + 32, + 19642 + ], + "BattleTowerMons": [ + 126, + 17488 + ], + "BattleTowerTrainerData": [ + 124, + 16384 + ], + "BattleTowerTrainers": [ + 126, + 16718 + ], + "BattleWinSlideInEnemyTrainerFrontpic": [ + 15, + 27608 + ], + "BayleefBackpic": [ + 83, + 22165 + ], + "BayleefFrontpic": [ + 77, + 26563 + ], + "BayleefPokedexEntry": [ + 115, + 18921 + ], + "BeatUpAttackText": [ + 32, + 22573 + ], + "BecameConfusedText": [ + 32, + 19863 + ], + "BeedrillBackpic": [ + 83, + 30536 + ], + "BeedrillFrontpic": [ + 73, + 22902 + ], + "BeedrillPokedexEntry": [ + 96, + 23690 + ], + "BeganToNapText": [ + 32, + 20273 + ], + "BellChimedText": [ + 32, + 20896 + ], + "BellossomBackpic": [ + 85, + 31844 + ], + "BellossomFrontpic": [ + 78, + 19124 + ], + "BellossomPokedexEntry": [ + 115, + 22170 + ], + "BellsproutBackpic": [ + 88, + 18720 + ], + "BellsproutFrontpic": [ + 82, + 23595 + ], + "BellsproutPokedexEntry": [ + 110, + 16801 + ], + "BellyDrumText": [ + 32, + 22482 + ], + "BillsPCOrangePalette": [ + 2, + 20534 + ], + "BitmasksPointers": [ + 52, + 25839 + ], + "BlastoiseBackpic": [ + 85, + 20786 + ], + "BlastoiseFrontpic": [ + 73, + 16540 + ], + "BlastoisePokedexEntry": [ + 96, + 23033 + ], + "BlewSpikesText": [ + 32, + 22414 + ], + "BlisseyBackpic": [ + 88, + 25981 + ], + "BlisseyFrontpic": [ + 75, + 19884 + ], + "BlisseyPokedexEntry": [ + 116, + 21751 + ], + "BlownAwayText": [ + 32, + 21202 + ], + "BoltEmote": [ + 65, + 17552 + ], + "BracedItselfText": [ + 32, + 22265 + ], + "BugCatchingContestantEventFlagTable": [ + 4, + 31230 + ], + "BugContestPrizeNoRoomText": [ + 108, + 18636 + ], + "BulbasaurBackpic": [ + 88, + 17563 + ], + "BulbasaurFrontpic": [ + 83, + 32109 + ], + "BulbasaurPokedexEntry": [ + 96, + 22165 + ], + "ButItFailedText": [ + 32, + 21744 + ], + "ButterfreeBackpic": [ + 81, + 28473 + ], + "ButterfreeFrontpic": [ + 74, + 25162 + ], + "ButterfreePokedexEntry": [ + 96, + 23365 + ], + "CalPic": [ + 86, + 17084 + ], + "CantEscapeNowText": [ + 32, + 22007 + ], + "CardFlipLZ01": [ + 56, + 19734 + ], + "CardFlipLZ02": [ + 56, + 20136 + ], + "CardFlipLZ03": [ + 56, + 19675 + ], + "CardFlipOffButtonGFX": [ + 56, + 19702 + ], + "CardFlipOnButtonGFX": [ + 56, + 19718 + ], + "CardFlipTilemap": [ + 56, + 20748 + ], + "CardLargeSpriteAndFolderGFX": [ + 94, + 25023 + ], + "CardSpriteGFX": [ + 94, + 26191 + ], + "CardStatusGFX": [ + 9, + 21795 + ], + "CardTradeGFX": [ + 65, + 22832 + ], + "CardTradeSpriteGFX": [ + 65, + 23856 + ], + "CaterpieBackpic": [ + 88, + 20434 + ], + "CaterpieFrontpic": [ + 87, + 23750 + ], + "CaterpiePokedexEntry": [ + 96, + 23136 + ], + "CelebiBackpic": [ + 82, + 31339 + ], + "CelebiFrontpic": [ + 81, + 17889 + ], + "CelebiPokedexEntry": [ + 116, + 22732 + ], + "ChanseyBackpic": [ + 87, + 28388 + ], + "ChanseyFrontpic": [ + 79, + 17666 + ], + "ChanseyPokedexEntry": [ + 110, + 21550 + ], + "CharizardBackpic": [ + 85, + 23674 + ], + "CharizardFrontpic": [ + 73, + 31031 + ], + "CharizardPokedexEntry": [ + 96, + 22707 + ], + "CharmanderBackpic": [ + 87, + 29899 + ], + "CharmanderFrontpic": [ + 80, + 24677 + ], + "CharmanderPokedexEntry": [ + 96, + 22491 + ], + "CharmeleonBackpic": [ + 84, + 18725 + ], + "CharmeleonFrontpic": [ + 76, + 26753 + ], + "CharmeleonPokedexEntry": [ + 96, + 22602 + ], + "ChikoritaBackpic": [ + 87, + 27468 + ], + "ChikoritaFrontpic": [ + 79, + 22680 + ], + "ChikoritaPokedexEntry": [ + 115, + 18824 + ], + "ChinchouBackpic": [ + 87, + 28082 + ], + "ChinchouFrontpic": [ + 80, + 22018 + ], + "ChinchouPokedexEntry": [ + 115, + 20837 + ], + "ChooseMobileCenterAttrmap": [ + 92, + 26965 + ], + "ChooseMobileCenterTilemap": [ + 92, + 26245 + ], + "ChrisBackpic": [ + 10, + 31258 + ], + "ChrisCardPic": [ + 34, + 17253 + ], + "ChrisPic": [ + 34, + 18601 + ], + "ChrisSilhouetteGFX": [ + 94, + 23535 + ], + "ChrisSpriteGFX": [ + 48, + 16384 + ], + "ClampedByText": [ + 32, + 20057 + ], + "ClefableBackpic": [ + 82, + 27514 + ], + "ClefableFrontpic": [ + 78, + 22484 + ], + "ClefablePokedexEntry": [ + 96, + 25904 + ], + "ClefairyBackpic": [ + 85, + 31142 + ], + "ClefairyFrontpic": [ + 82, + 16839 + ], + "ClefairyPokedexEntry": [ + 96, + 25800 + ], + "CleffaBackpic": [ + 88, + 29888 + ], + "CleffaFrontpic": [ + 82, + 24921 + ], + "CleffaPokedexEntry": [ + 115, + 21180 + ], + "ClockTilemapRLE": [ + 36, + 21979 + ], + "CloysterBackpic": [ + 81, + 31700 + ], + "CloysterFrontpic": [ + 74, + 26980 + ], + "CloysterPokedexEntry": [ + 110, + 19161 + ], + "CoinVendor_Buy500CoinsText": [ + 108, + 18511 + ], + "CoinVendor_Buy50CoinsText": [ + 108, + 18480 + ], + "CoinVendor_CancelText": [ + 108, + 18605 + ], + "CoinVendor_CoinCaseFullText": [ + 108, + 18572 + ], + "CoinVendor_IntroText": [ + 108, + 18403 + ], + "CoinVendor_NoCoinCaseText": [ + 108, + 18332 + ], + "CoinVendor_NotEnoughMoneyText": [ + 108, + 18543 + ], + "CoinVendor_WelcomeText": [ + 108, + 18303 + ], + "CoinsScatteredText": [ + 32, + 21557 + ], + "ConfusedNoMoreText": [ + 32, + 19841 + ], + "ContestMons": [ + 37, + 32135 + ], + "ContestResults_ConsolationPrizeText": [ + 108, + 18049 + ], + "ContestResults_DidNotWinText": [ + 108, + 18103 + ], + "ContestResults_JoinUsNextTimeText": [ + 108, + 18011 + ], + "ContestResults_PartyFullText": [ + 108, + 18189 + ], + "ContestResults_PlayerWonAPrizeText": [ + 108, + 17953 + ], + "ContestResults_ReadyToJudgeText": [ + 108, + 17855 + ], + "ContestResults_ReturnPartyText": [ + 108, + 18137 + ], + "CopiedStatsText": [ + 32, + 22518 + ], + "CopyBackpic": [ + 15, + 31792 + ], + "CopyrightGFX": [ + 57, + 16384 + ], + "CorsolaBackpic": [ + 87, + 27775 + ], + "CorsolaFrontpic": [ + 80, + 20370 + ], + "CorsolaPokedexEntry": [ + 116, + 19560 + ], + "CoveredByVeilText": [ + 32, + 22300 + ], + "CrashedText": [ + 32, + 20550 + ], + "CreditsBorderGFX": [ + 66, + 23588 + ], + "CreditsDittoGFX": [ + 66, + 25780 + ], + "CreditsIgglybuffGFX": [ + 66, + 26804 + ], + "CreditsPalettes": [ + 66, + 23402 + ], + "CreditsPichuGFX": [ + 66, + 23732 + ], + "CreditsSmoochumGFX": [ + 66, + 24756 + ], + "Cries": [ + 58, + 20912 + ], + "CriticalHitText": [ + 32, + 20614 + ], + "CrobatBackpic": [ + 88, + 25712 + ], + "CrobatFrontpic": [ + 79, + 17028 + ], + "CrobatPokedexEntry": [ + 115, + 20725 + ], + "CroconawBackpic": [ + 83, + 25793 + ], + "CroconawFrontpic": [ + 75, + 26730 + ], + "CroconawPokedexEntry": [ + 115, + 19588 + ], + "CuboneBackpic": [ + 83, + 28566 + ], + "CuboneFrontpic": [ + 80, + 25205 + ], + "CubonePokedexEntry": [ + 110, + 20584 + ], + "CyndaquilBackpic": [ + 83, + 21755 + ], + "CyndaquilFrontpic": [ + 79, + 31025 + ], + "CyndaquilPokedexEntry": [ + 115, + 19133 + ], + "DecorationDesc_GiantOrnament.BigDollScript": [ + 9, + 28643 + ], + "DecorationDesc_NullPoster": [ + 9, + 28600 + ], + "DecorationDesc_OrnamentOrConsole.OrnamentConsoleScript": [ + 9, + 28629 + ], + "DecorationDesc_PosterPointers": [ + 9, + 28548 + ], + "DefrostedOpponentText": [ + 32, + 21067 + ], + "DelibirdBackpic": [ + 86, + 18826 + ], + "DelibirdFrontpic": [ + 79, + 27496 + ], + "DelibirdPokedexEntry": [ + 116, + 19889 + ], + "DestinyBondEffectText": [ + 32, + 20822 + ], + "DewgongBackpic": [ + 89, + 18066 + ], + "DewgongFrontpic": [ + 74, + 28781 + ], + "DewgongPokedexEntry": [ + 110, + 18740 + ], + "DialpadAttrmap": [ + 94, + 28221 + ], + "DialpadCursorGFX": [ + 94, + 29797 + ], + "DialpadGFX": [ + 94, + 28581 + ], + "DialpadTilemap": [ + 94, + 27861 + ], + "DidntAffect1Text": [ + 32, + 21772 + ], + "DidntAffect2Text": [ + 32, + 21792 + ], + "DifficultBookshelfText": [ + 108, + 17242 + ], + "DiglettBackpic": [ + 89, + 22698 + ], + "DiglettFrontpic": [ + 86, + 17782 + ], + "DiglettPokedexEntry": [ + 96, + 27440 + ], + "DiplomaGFX": [ + 119, + 22533 + ], + "DiplomaPage1Tilemap": [ + 119, + 23627 + ], + "DiplomaPalettes": [ + 2, + 30273 + ], + "DisabledMoveText": [ + 32, + 20226 + ], + "DisabledNoMoreText": [ + 32, + 19769 + ], + "DittoBackpic": [ + 88, + 29123 + ], + "DittoFrontpic": [ + 85, + 29734 + ], + "DittoPokedexEntry": [ + 115, + 16700 + ], + "DodrioBackpic": [ + 82, + 16384 + ], + "DodrioFrontpic": [ + 75, + 16384 + ], + "DodrioPokedexEntry": [ + 110, + 18506 + ], + "DoduoBackpic": [ + 83, + 23380 + ], + "DoduoFrontpic": [ + 82, + 27942 + ], + "DoduoPokedexEntry": [ + 110, + 18391 + ], + "DoesntAffectText": [ + 32, + 20593 + ], + "DoneTileAnimation": [ + 63, + 17147 + ], + "DonphanBackpic": [ + 89, + 19006 + ], + "DonphanFrontpic": [ + 75, + 30892 + ], + "DonphanPokedexEntry": [ + 116, + 20652 + ], + "DownpourText": [ + 32, + 22435 + ], + "DraggedOutText": [ + 32, + 21828 + ], + "DragonairBackpic": [ + 87, + 26854 + ], + "DragonairFrontpic": [ + 78, + 30389 + ], + "DragonairPokedexEntry": [ + 115, + 18396 + ], + "DragoniteBackpic": [ + 88, + 22684 + ], + "DragoniteFrontpic": [ + 75, + 21614 + ], + "DragonitePokedexEntry": [ + 115, + 18499 + ], + "DratiniBackpic": [ + 88, + 31139 + ], + "DratiniFrontpic": [ + 82, + 28797 + ], + "DratiniPokedexEntry": [ + 115, + 18273 + ], + "DrawPocketName.tilemap": [ + 4, + 18913 + ], + "DreamEatenText": [ + 32, + 21031 + ], + "DrowzeeBackpic": [ + 88, + 30140 + ], + "DrowzeeFrontpic": [ + 78, + 17762 + ], + "DrowzeePokedexEntry": [ + 110, + 19722 + ], + "Drumkits": [ + 58, + 20050 + ], + "DudeBackpic": [ + 10, + 31658 + ], + "DugtrioBackpic": [ + 86, + 26650 + ], + "DugtrioFrontpic": [ + 78, + 29090 + ], + "DugtrioPokedexEntry": [ + 96, + 27549 + ], + "DunsparceBackpic": [ + 89, + 18772 + ], + "DunsparceFrontpic": [ + 77, + 32161 + ], + "DunsparcePokedexEntry": [ + 116, + 17811 + ], + "EZChatCursorGFX": [ + 34, + 21515 + ], + "EeveeBackpic": [ + 84, + 24109 + ], + "EeveeFrontpic": [ + 81, + 28008 + ], + "EeveePokedexEntry": [ + 115, + 16818 + ], + "EggHatchGFX": [ + 5, + 29587 + ], + "EggMovePointers": [ + 8, + 31505 + ], + "EggPic": [ + 84, + 23727 + ], + "EkansBackpic": [ + 85, + 26897 + ], + "EkansFrontpic": [ + 80, + 16384 + ], + "EkansPokedexEntry": [ + 96, + 24532 + ], + "ElectabuzzBackpic": [ + 84, + 19886 + ], + "ElectabuzzFrontpic": [ + 76, + 23602 + ], + "ElectabuzzPokedexEntry": [ + 110, + 22826 + ], + "ElectroBallMobileGFX": [ + 92, + 21924 + ], + "ElectrodeBackpic": [ + 88, + 31388 + ], + "ElectrodeFrontpic": [ + 84, + 32370 + ], + "ElectrodePokedexEntry": [ + 110, + 20249 + ], + "ElekidBackpic": [ + 82, + 30918 + ], + "ElekidFrontpic": [ + 77, + 28671 + ], + "ElekidPokedexEntry": [ + 116, + 21400 + ], + "ElevatorFloorNames": [ + 4, + 30083 + ], + "EliminatedStatsText": [ + 32, + 21622 + ], + "EnduredText": [ + 32, + 20144 + ], + "EnemyHPBarBorderGFX": [ + 62, + 19136 + ], + "EnemyHitTimesText": [ + 32, + 21240 + ], + "EnteiBackpic": [ + 86, + 22947 + ], + "EnteiFrontpic": [ + 72, + 29643 + ], + "EnteiPokedexEntry": [ + 116, + 21981 + ], + "EnvironmentColorsPointers": [ + 2, + 29305 + ], + "EspeonBackpic": [ + 86, + 28318 + ], + "EspeonFrontpic": [ + 79, + 23902 + ], + "EspeonPokedexEntry": [ + 116, + 16725 + ], + "EvadedText": [ + 32, + 21511 + ], + "EvosAttacksPointers": [ + 16, + 26033 + ], + "ExeggcuteBackpic": [ + 85, + 25468 + ], + "ExeggcuteFrontpic": [ + 76, + 18010 + ], + "ExeggcutePokedexEntry": [ + 110, + 20356 + ], + "ExeggutorBackpic": [ + 81, + 25194 + ], + "ExeggutorFrontpic": [ + 76, + 28304 + ], + "ExeggutorPokedexEntry": [ + 110, + 20470 + ], + "ExpBarGFX": [ + 62, + 19216 + ], + "ExpBarPalette": [ + 2, + 26826 + ], + "FarfetchDBackpic": [ + 83, + 20522 + ], + "FarfetchDFrontpic": [ + 77, + 25143 + ], + "FarfetchDPokedexEntry": [ + 110, + 18284 + ], + "FastAsleepText": [ + 32, + 19687 + ], + "FearowBackpic": [ + 88, + 30641 + ], + "FearowFrontpic": [ + 73, + 30033 + ], + "FearowPokedexEntry": [ + 96, + 24420 + ], + "FellAsleepText": [ + 32, + 20913 + ], + "FellInLoveText": [ + 32, + 22283 + ], + "FemalePokegearPals": [ + 2, + 30553 + ], + "FeraligatrBackpic": [ + 82, + 28370 + ], + "FeraligatrFrontpic": [ + 72, + 21725 + ], + "FeraligatrPokedexEntry": [ + 115, + 19705 + ], + "FireSpinTrapText": [ + 32, + 20020 + ], + "FishEmote": [ + 65, + 17680 + ], + "FishGroups": [ + 36, + 25736 + ], + "FishingGFX": [ + 46, + 17650 + ], + "FlaaffyBackpic": [ + 85, + 16753 + ], + "FlaaffyFrontpic": [ + 79, + 29271 + ], + "FlaaffyPokedexEntry": [ + 115, + 21944 + ], + "FlareonBackpic": [ + 83, + 31323 + ], + "FlareonFrontpic": [ + 77, + 24429 + ], + "FlareonPokedexEntry": [ + 115, + 17132 + ], + "FledFromBattleText": [ + 32, + 21164 + ], + "FledInFearText": [ + 32, + 21185 + ], + "FlickeringCaveEntrancePalette": [ + 63, + 18206 + ], + "FlinchedText": [ + 32, + 19738 + ], + "Font": [ + 62, + 16896 + ], + "FontBattleExtra": [ + 62, + 17920 + ], + "FontExtra": [ + 62, + 16384 + ], + "FontsExtra2_UpArrowGFX": [ + 62, + 21540 + ], + "FontsExtra_SolidBlackGFX": [ + 62, + 21012 + ], + "Footprints": [ + 62, + 21556 + ], + "ForesawAttackText": [ + 32, + 22551 + ], + "ForestTreeLeftAnimation": [ + 63, + 17500 + ], + "ForestTreeLeftAnimation2": [ + 63, + 17650 + ], + "ForestTreeRightAnimation": [ + 63, + 17604 + ], + "ForestTreeRightAnimation2": [ + 63, + 17692 + ], + "ForretressBackpic": [ + 89, + 24454 + ], + "ForretressFrontpic": [ + 78, + 31035 + ], + "ForretressPokedexEntry": [ + 116, + 17700 + ], + "Frames": [ + 62, + 18432 + ], + "FramesPointers": [ + 53, + 16384 + ], + "FrozenSolidText": [ + 32, + 19718 + ], + "FullyParalyzedText": [ + 32, + 21885 + ], + "FurretBackpic": [ + 85, + 24752 + ], + "FurretFrontpic": [ + 79, + 30444 + ], + "FurretPokedexEntry": [ + 115, + 19933 + ], + "GSIntro_GetMonFrontpic": [ + 20, + 20972 + ], + "GameFreakDittoGFX": [ + 66, + 21511 + ], + "GameFreakDittoPaletteFade": [ + 57, + 18348 + ], + "GameFreakLogoGFX": [ + 57, + 18380 + ], + "GastlyBackpic": [ + 88, + 30391 + ], + "GastlyFrontpic": [ + 76, + 29076 + ], + "GastlyPokedexEntry": [ + 110, + 19274 + ], + "GengarBackpic": [ + 86, + 29978 + ], + "GengarFrontpic": [ + 78, + 27124 + ], + "GengarPokedexEntry": [ + 110, + 19493 + ], + "GeodudeBackpic": [ + 85, + 18595 + ], + "GeodudeFrontpic": [ + 85, + 21872 + ], + "GeodudePokedexEntry": [ + 110, + 17313 + ], + "GetAnimatedEnemyFrontpic": [ + 20, + 20739 + ], + "GetAnimatedFrontpic": [ + 20, + 20619 + ], + "GetBattleMonBackpic": [ + 15, + 29757 + ], + "GetChrisBackpic": [ + 34, + 18480 + ], + "GetEggFrontpic": [ + 5, + 29220 + ], + "GetEnemyMonFrontpic": [ + 15, + 29820 + ], + "GetHatchlingFrontpic": [ + 5, + 29244 + ], + "GetKrisBackpic": [ + 34, + 20169 + ], + "GetMonBackpic": [ + 20, + 20844 + ], + "GetMonFrontpic": [ + 20, + 20599 + ], + "GetPlayerBackpic": [ + 34, + 18469 + ], + "GetTrademonFrontpic": [ + 19, + 22525 + ], + "GetTrainerBackpic": [ + 15, + 31743 + ], + "GettingPumpedText": [ + 32, + 21304 + ], + "GirafarigBackpic": [ + 85, + 22594 + ], + "GirafarigFrontpic": [ + 78, + 17075 + ], + "GirafarigPokedexEntry": [ + 116, + 17478 + ], + "GligarBackpic": [ + 84, + 17948 + ], + "GligarFrontpic": [ + 77, + 31469 + ], + "GligarPokedexEntry": [ + 116, + 17924 + ], + "GloomBackpic": [ + 83, + 29354 + ], + "GloomFrontpic": [ + 79, + 28683 + ], + "GloomPokedexEntry": [ + 96, + 26790 + ], + "GolbatBackpic": [ + 89, + 23622 + ], + "GolbatFrontpic": [ + 76, + 16384 + ], + "GolbatPokedexEntry": [ + 96, + 26564 + ], + "GoldeenBackpic": [ + 83, + 31716 + ], + "GoldeenFrontpic": [ + 79, + 23292 + ], + "GoldeenPokedexEntry": [ + 110, + 22054 + ], + "GolduckBackpic": [ + 86, + 22266 + ], + "GolduckFrontpic": [ + 74, + 21463 + ], + "GolduckPokedexEntry": [ + 96, + 27989 + ], + "GolemBackpic": [ + 89, + 22308 + ], + "GolemFrontpic": [ + 77, + 19351 + ], + "GolemPokedexEntry": [ + 110, + 17537 + ], + "GotAnEncoreText": [ + 32, + 20745 + ], + "GotMoneyForWinningText": [ + 32, + 18878 + ], + "GranbullBackpic": [ + 87, + 24687 + ], + "GranbullFrontpic": [ + 79, + 19566 + ], + "GranbullPokedexEntry": [ + 116, + 18250 + ], + "GrassRustleGFX": [ + 65, + 17824 + ], + "GravelerBackpic": [ + 86, + 27652 + ], + "GravelerFrontpic": [ + 78, + 24480 + ], + "GravelerPokedexEntry": [ + 110, + 17420 + ], + "GrimerBackpic": [ + 88, + 19007 + ], + "GrimerFrontpic": [ + 80, + 28350 + ], + "GrimerPokedexEntry": [ + 110, + 18850 + ], + "GrowlitheBackpic": [ + 84, + 28641 + ], + "GrowlitheFrontpic": [ + 82, + 17747 + ], + "GrowlithePokedexEntry": [ + 96, + 28330 + ], + "GrowthRates": [ + 20, + 20218 + ], + "GyaradosBackpic": [ + 82, + 25356 + ], + "GyaradosFrontpic": [ + 74, + 18566 + ], + "GyaradosPokedexEntry": [ + 115, + 16485 + ], + "GymStatue_CityGymText": [ + 108, + 18254 + ], + "GymStatue_WinningTrainersText": [ + 108, + 18268 + ], + "HOF_AnimateFrontpic": [ + 52, + 18030 + ], + "HOF_LoadTrainerFrontpic": [ + 34, + 18496 + ], + "HOF_SlideBackpic": [ + 33, + 26165 + ], + "HOF_SlideFrontpic": [ + 33, + 26179 + ], + "HPBarPals": [ + 2, + 26814 + ], + "HPExpBarBorderGFX": [ + 62, + 19168 + ], + "HPIsFullText": [ + 32, + 21812 + ], + "HappinessText1": [ + 108, + 18825 + ], + "HappinessText2": [ + 108, + 18772 + ], + "HappinessText3": [ + 108, + 18730 + ], + "HappyEmote": [ + 65, + 17360 + ], + "HasANightmareText": [ + 32, + 18466 + ], + "HasNoPPLeftText": [ + 32, + 20410 + ], + "HasSubstituteText": [ + 32, + 21368 + ], + "HaunterBackpic": [ + 87, + 20251 + ], + "HaunterFrontpic": [ + 79, + 20819 + ], + "HaunterPokedexEntry": [ + 110, + 19389 + ], + "HaveWantGFX": [ + 95, + 16515 + ], + "HaveWantMap": [ + 95, + 19331 + ], + "HealMachineAnim.HealMachineGFX": [ + 4, + 25596 + ], + "HealMachineAnim.palettes": [ + 4, + 25681 + ], + "HeartEmote": [ + 65, + 17488 + ], + "HeldItemIcons": [ + 35, + 27127 + ], + "HeracrossBackpic": [ + 84, + 21042 + ], + "HeracrossFrontpic": [ + 76, + 32123 + ], + "HeracrossPokedexEntry": [ + 116, + 18695 + ], + "HitmonchanBackpic": [ + 85, + 19694 + ], + "HitmonchanFrontpic": [ + 79, + 31604 + ], + "HitmonchanPokedexEntry": [ + 110, + 20900 + ], + "HitmonleeBackpic": [ + 88, + 22963 + ], + "HitmonleeFrontpic": [ + 73, + 20802 + ], + "HitmonleePokedexEntry": [ + 110, + 20799 + ], + "HitmontopBackpic": [ + 81, + 31242 + ], + "HitmontopFrontpic": [ + 75, + 18143 + ], + "HitmontopPokedexEntry": [ + 116, + 21184 + ], + "HoOhBackpic": [ + 86, + 24304 + ], + "HoOhFrontpic": [ + 72, + 17902 + ], + "HoOhPokedexEntry": [ + 116, + 22621 + ], + "HoOhPuzzleLZ": [ + 56, + 22699 + ], + "HomepageText": [ + 108, + 17609 + ], + "HookedPokemonAttackedText": [ + 32, + 18268 + ], + "HoothootBackpic": [ + 87, + 16384 + ], + "HoothootFrontpic": [ + 81, + 32158 + ], + "HoothootPokedexEntry": [ + 115, + 20056 + ], + "HoppipBackpic": [ + 87, + 24063 + ], + "HoppipFrontpic": [ + 83, + 23784 + ], + "HoppipPokedexEntry": [ + 115, + 22709 + ], + "HorseaBackpic": [ + 88, + 27568 + ], + "HorseaFrontpic": [ + 86, + 23626 + ], + "HorseaPokedexEntry": [ + 110, + 21843 + ], + "HoundoomBackpic": [ + 88, + 18431 + ], + "HoundoomFrontpic": [ + 77, + 21541 + ], + "HoundoomPokedexEntry": [ + 116, + 20326 + ], + "HoundourBackpic": [ + 89, + 17111 + ], + "HoundourFrontpic": [ + 81, + 16887 + ], + "HoundourPokedexEntry": [ + 116, + 20223 + ], + "HungOnText": [ + 32, + 20121 + ], + "HurtByBurnText": [ + 32, + 18424 + ], + "HurtByCurseText": [ + 32, + 18486 + ], + "HurtByPoisonText": [ + 32, + 18402 + ], + "HurtItselfText": [ + 32, + 19807 + ], + "HypnoBackpic": [ + 83, + 24992 + ], + "HypnoFrontpic": [ + 75, + 27574 + ], + "HypnoPokedexEntry": [ + 110, + 19840 + ], + "IconPointers": [ + 35, + 27583 + ], + "Icons": [ + 35, + 27661 + ], + "IdentifiedText": [ + 32, + 22193 + ], + "IgglybuffBackpic": [ + 88, + 26515 + ], + "IgglybuffFrontpic": [ + 85, + 25826 + ], + "IgglybuffPokedexEntry": [ + 115, + 21292 + ], + "IgnoredOrders2Text": [ + 32, + 22608 + ], + "IgnoredOrdersText": [ + 32, + 20326 + ], + "IgnoredSleepingText": [ + 32, + 20347 + ], + "InLoveWithText": [ + 32, + 20164 + ], + "IncenseBurnerText": [ + 108, + 17480 + ], + "InfatuationText": [ + 32, + 20186 + ], + "InitRoamMons": [ + 10, + 25248 + ], + "InitializeEventsScript": [ + 47, + 17371 + ], + "IntroBackgroundAttrmap": [ + 57, + 24269 + ], + "IntroBackgroundGFX": [ + 57, + 23677 + ], + "IntroBackgroundPalette": [ + 57, + 24285 + ], + "IntroBackgroundTilemap": [ + 57, + 24173 + ], + "IntroCrystalUnownsAttrmap": [ + 57, + 26477 + ], + "IntroCrystalUnownsGFX": [ + 57, + 26157 + ], + "IntroCrystalUnownsPalette": [ + 57, + 26525 + ], + "IntroCrystalUnownsTilemap": [ + 57, + 26413 + ], + "IntroGrass1GFX": [ + 57, + 31133 + ], + "IntroGrass2GFX": [ + 57, + 31197 + ], + "IntroGrass3GFX": [ + 57, + 31261 + ], + "IntroGrass4GFX": [ + 57, + 31325 + ], + "IntroPichuWooperGFX": [ + 57, + 22829 + ], + "IntroPulseGFX": [ + 57, + 25421 + ], + "IntroSuicuneBackAttrmap": [ + 57, + 30605 + ], + "IntroSuicuneBackGFX": [ + 57, + 29357 + ], + "IntroSuicuneBackTilemap": [ + 57, + 30397 + ], + "IntroSuicuneCloseAttrmap": [ + 57, + 27917 + ], + "IntroSuicuneCloseGFX": [ + 57, + 26653 + ], + "IntroSuicuneClosePalette": [ + 57, + 28013 + ], + "IntroSuicuneCloseTilemap": [ + 57, + 27709 + ], + "IntroSuicuneJumpAttrmap": [ + 57, + 30381 + ], + "IntroSuicuneJumpGFX": [ + 57, + 28141 + ], + "IntroSuicuneJumpTilemap": [ + 57, + 30285 + ], + "IntroSuicunePalette": [ + 57, + 30685 + ], + "IntroSuicuneRunGFX": [ + 57, + 21853 + ], + "IntroUnownAAttrmap": [ + 57, + 25629 + ], + "IntroUnownATilemap": [ + 57, + 25565 + ], + "IntroUnownBackGFX": [ + 57, + 30813 + ], + "IntroUnownHIAttrmap": [ + 57, + 25725 + ], + "IntroUnownHITilemap": [ + 57, + 25645 + ], + "IntroUnownsAttrmap": [ + 57, + 25949 + ], + "IntroUnownsGFX": [ + 57, + 24413 + ], + "IntroUnownsPalette": [ + 57, + 26029 + ], + "IntroUnownsTilemap": [ + 57, + 25757 + ], + "Intro_FadeUnownWordPals.FastFadePalettes": [ + 57, + 21467 + ], + "Intro_FadeUnownWordPals.SlowFadePalettes": [ + 57, + 21499 + ], + "Intro_LoadMagikarpPalettes.MagikarpBGPal": [ + 2, + 19247 + ], + "Intro_LoadMagikarpPalettes.MagikarpOBPal": [ + 2, + 19255 + ], + "Intro_RotatePalettesLeftFrontpic": [ + 1, + 24938 + ], + "Intro_Scene20_AppearUnown.pal1": [ + 57, + 21389 + ], + "Intro_Scene20_AppearUnown.pal2": [ + 57, + 21397 + ], + "Intro_Scene24_ApplyPaletteFade.FadePals": [ + 57, + 20892 + ], + "Intro_WipeInFrontpic": [ + 1, + 24962 + ], + "IsConfusedText": [ + 32, + 19791 + ], + "ItFailedText": [ + 32, + 21760 + ], + "ItemAttributes": [ + 1, + 26561 + ], + "ItemDescriptions": [ + 114, + 18823 + ], + "ItemNames": [ + 114, + 16384 + ], + "IvysaurBackpic": [ + 86, + 21239 + ], + "IvysaurFrontpic": [ + 79, + 29858 + ], + "IvysaurPokedexEntry": [ + 96, + 22274 + ], + "JigglypuffBackpic": [ + 88, + 32125 + ], + "JigglypuffFrontpic": [ + 81, + 20355 + ], + "JigglypuffPokedexEntry": [ + 96, + 26227 + ], + "JohtoGrassWildMons": [ + 10, + 26089 + ], + "JohtoMap": [ + 36, + 24575 + ], + "JohtoWaterWildMons": [ + 10, + 28957 + ], + "JolteonBackpic": [ + 87, + 23435 + ], + "JolteonFrontpic": [ + 75, + 30069 + ], + "JolteonPokedexEntry": [ + 115, + 17035 + ], + "JumpluffBackpic": [ + 85, + 20059 + ], + "JumpluffFrontpic": [ + 77, + 29373 + ], + "JumpluffPokedexEntry": [ + 115, + 22936 + ], + "JynxBackpic": [ + 89, + 23443 + ], + "JynxFrontpic": [ + 79, + 26304 + ], + "JynxPokedexEntry": [ + 110, + 22725 + ], + "KabutoBackpic": [ + 87, + 17677 + ], + "KabutoFrontpic": [ + 85, + 28675 + ], + "KabutoPokedexEntry": [ + 115, + 17559 + ], + "KabutoPuzzleLZ": [ + 56, + 23467 + ], + "KabutopsBackpic": [ + 83, + 30142 + ], + "KabutopsFrontpic": [ + 77, + 22268 + ], + "KabutopsPokedexEntry": [ + 115, + 17671 + ], + "KadabraBackpic": [ + 85, + 24034 + ], + "KadabraFrontpic": [ + 76, + 27531 + ], + "KadabraPokedexEntry": [ + 96, + 28988 + ], + "KakunaBackpic": [ + 88, + 24897 + ], + "KakunaFrontpic": [ + 86, + 26316 + ], + "KakunaPokedexEntry": [ + 96, + 23587 + ], + "KangaskhanBackpic": [ + 78, + 32325 + ], + "KangaskhanFrontpic": [ + 74, + 19540 + ], + "KangaskhanPokedexEntry": [ + 110, + 21750 + ], + "KantoGrassWildMons": [ + 10, + 29300 + ], + "KantoMap": [ + 36, + 24936 + ], + "KantoWaterWildMons": [ + 10, + 30711 + ], + "KingdraBackpic": [ + 85, + 30086 + ], + "KingdraFrontpic": [ + 76, + 25971 + ], + "KingdraPokedexEntry": [ + 116, + 20433 + ], + "KinglerBackpic": [ + 86, + 29647 + ], + "KinglerFrontpic": [ + 76, + 17198 + ], + "KinglerPokedexEntry": [ + 110, + 20037 + ], + "KoffingBackpic": [ + 84, + 26383 + ], + "KoffingFrontpic": [ + 81, + 24231 + ], + "KoffingPokedexEntry": [ + 110, + 21110 + ], + "KrabbyBackpic": [ + 82, + 31759 + ], + "KrabbyFrontpic": [ + 81, + 19374 + ], + "KrabbyPokedexEntry": [ + 110, + 19933 + ], + "KrisBackpic": [ + 34, + 20182 + ], + "KrisCardPic": [ + 34, + 17813 + ], + "KrisFishingGFX": [ + 46, + 17794 + ], + "KrisPic": [ + 34, + 19385 + ], + "KrisSilhouetteGFX": [ + 94, + 24095 + ], + "Landmarks": [ + 114, + 26819 + ], + "LanturnBackpic": [ + 88, + 19293 + ], + "LanturnFrontpic": [ + 81, + 25666 + ], + "LanturnPokedexEntry": [ + 115, + 20958 + ], + "LaprasBackpic": [ + 87, + 26238 + ], + "LaprasFrontpic": [ + 77, + 18615 + ], + "LaprasPokedexEntry": [ + 115, + 16589 + ], + "LarvitarBackpic": [ + 87, + 31996 + ], + "LarvitarFrontpic": [ + 83, + 18460 + ], + "LarvitarPokedexEntry": [ + 116, + 22172 + ], + "LavaBubbleTileFrames": [ + 63, + 17925 + ], + "LeaderGFX": [ + 9, + 21891 + ], + "LedianBackpic": [ + 84, + 20272 + ], + "LedianFrontpic": [ + 79, + 28090 + ], + "LedianPokedexEntry": [ + 115, + 20384 + ], + "LedybaBackpic": [ + 83, + 21344 + ], + "LedybaFrontpic": [ + 78, + 26466 + ], + "LedybaPokedexEntry": [ + 115, + 20268 + ], + "LeechSeedSapsText": [ + 32, + 18446 + ], + "LickitungBackpic": [ + 87, + 19288 + ], + "LickitungFrontpic": [ + 76, + 25184 + ], + "LickitungPokedexEntry": [ + 110, + 21008 + ], + "LightScreenEffectText": [ + 32, + 21684 + ], + "LoadOpponentTrainerAndPokemon.resample": [ + 126, + 16418 + ], + "LoadOrientedFrontpic": [ + 20, + 21234 + ], + "LoadQuestionMarkPic.QuestionMarkLZ": [ + 119, + 24801 + ], + "LoadRandomBattleTowerMon.resample": [ + 126, + 16537 + ], + "LoafingAroundText": [ + 32, + 20249 + ], + "LookTownMapText": [ + 108, + 17549 + ], + "LostAgainstText": [ + 32, + 19129 + ], + "LugiaBackpic": [ + 88, + 21000 + ], + "LugiaFrontpic": [ + 72, + 25198 + ], + "LugiaPokedexEntry": [ + 116, + 22505 + ], + "MachampBackpic": [ + 81, + 23749 + ], + "MachampFrontpic": [ + 72, + 19205 + ], + "MachampPokedexEntry": [ + 110, + 16691 + ], + "MachokeBackpic": [ + 84, + 29391 + ], + "MachokeFrontpic": [ + 75, + 20751 + ], + "MachokePokedexEntry": [ + 110, + 16587 + ], + "MachopBackpic": [ + 86, + 25312 + ], + "MachopFrontpic": [ + 80, + 21481 + ], + "MachopPokedexEntry": [ + 110, + 16485 + ], + "MadeSubstituteText": [ + 32, + 21346 + ], + "MagazineBookshelfText": [ + 108, + 17315 + ], + "MagbyBackpic": [ + 87, + 26546 + ], + "MagbyFrontpic": [ + 80, + 18673 + ], + "MagbyPokedexEntry": [ + 116, + 21518 + ], + "MagcargoBackpic": [ + 83, + 29748 + ], + "MagcargoFrontpic": [ + 76, + 18818 + ], + "MagcargoPokedexEntry": [ + 116, + 19233 + ], + "MagikarpBackpic": [ + 86, + 19519 + ], + "MagikarpFrontpic": [ + 80, + 24148 + ], + "MagikarpPokedexEntry": [ + 115, + 16384 + ], + "MagmarBackpic": [ + 83, + 24590 + ], + "MagmarFrontpic": [ + 77, + 27266 + ], + "MagmarPokedexEntry": [ + 110, + 22932 + ], + "MagnemiteBackpic": [ + 86, + 32596 + ], + "MagnemiteFrontpic": [ + 86, + 31620 + ], + "MagnemitePokedexEntry": [ + 110, + 18077 + ], + "MagnetTrainBGTiles": [ + 35, + 19842 + ], + "MagnetTrainTilemap": [ + 35, + 20223 + ], + "MagnetonBackpic": [ + 82, + 29224 + ], + "MagnetonFrontpic": [ + 78, + 29741 + ], + "MagnetonPokedexEntry": [ + 110, + 18186 + ], + "MagnitudeText": [ + 32, + 22353 + ], + "MalePokegearPals": [ + 2, + 30505 + ], + "MankeyBackpic": [ + 83, + 24187 + ], + "MankeyFrontpic": [ + 81, + 17390 + ], + "MankeyPokedexEntry": [ + 96, + 28093 + ], + "MantineBackpic": [ + 89, + 20850 + ], + "MantineFrontpic": [ + 76, + 24395 + ], + "MantinePokedexEntry": [ + 116, + 20005 + ], + "MapGroupPointers": [ + 37, + 16384 + ], + "MapGroupRoofs": [ + 7, + 16417 + ], + "MapObjectPals": [ + 2, + 29801 + ], + "MareepBackpic": [ + 85, + 29028 + ], + "MareepFrontpic": [ + 81, + 27076 + ], + "MareepPokedexEntry": [ + 115, + 21841 + ], + "MarillBackpic": [ + 88, + 30890 + ], + "MarillFrontpic": [ + 82, + 19558 + ], + "MarillPokedexEntry": [ + 115, + 22277 + ], + "MarowakBackpic": [ + 85, + 18227 + ], + "MarowakFrontpic": [ + 77, + 30074 + ], + "MarowakPokedexEntry": [ + 110, + 20690 + ], + "MartSignText": [ + 108, + 17820 + ], + "Marts": [ + 5, + 24745 + ], + "MeganiumBackpic": [ + 87, + 18644 + ], + "MeganiumFrontpic": [ + 73, + 18692 + ], + "MeganiumPokedexEntry": [ + 115, + 19028 + ], + "MenuHeaders_UnownWalls": [ + 34, + 28373 + ], + "MeowthBackpic": [ + 87, + 18322 + ], + "MeowthFrontpic": [ + 80, + 29901 + ], + "MeowthPokedexEntry": [ + 96, + 27656 + ], + "MerchandiseShelfText": [ + 108, + 17522 + ], + "MetapodBackpic": [ + 88, + 31880 + ], + "MetapodFrontpic": [ + 87, + 22169 + ], + "MetapodPokedexEntry": [ + 96, + 23246 + ], + "MewBackpic": [ + 86, + 24977 + ], + "MewFrontpic": [ + 79, + 25107 + ], + "MewPokedexEntry": [ + 115, + 18709 + ], + "MewtwoBackpic": [ + 86, + 16384 + ], + "MewtwoFrontpic": [ + 73, + 29025 + ], + "MewtwoPokedexEntry": [ + 115, + 18606 + ], + "MiltankBackpic": [ + 84, + 21812 + ], + "MiltankFrontpic": [ + 79, + 20194 + ], + "MiltankPokedexEntry": [ + 116, + 21634 + ], + "MimicLearnedMoveText": [ + 32, + 21478 + ], + "MirrorMoveFailedText": [ + 32, + 21953 + ], + "MisdreavusBackpic": [ + 85, + 21149 + ], + "MisdreavusFrontpic": [ + 81, + 16384 + ], + "MisdreavusPokedexEntry": [ + 116, + 17153 + ], + "MistText": [ + 32, + 21259 + ], + "MobileAdapterCheckGFX": [ + 91, + 19619 + ], + "MobileAdapterPalettes": [ + 66, + 21447 + ], + "MobileCable1GFX": [ + 66, + 20935 + ], + "MobileCable2GFX": [ + 66, + 21191 + ], + "MobileCard2GFX": [ + 94, + 24655 + ], + "MobileCardGFX": [ + 94, + 23023 + ], + "MobileCardListGFX": [ + 94, + 29881 + ], + "MobileDialingFrameGFX": [ + 65, + 25876 + ], + "MobileDialingGFX": [ + 69, + 24602 + ], + "MobileDownArrowGFX": [ + 18, + 18635 + ], + "MobileMenuGFX": [ + 18, + 23564 + ], + "MobilePasswordAttrmap": [ + 92, + 26605 + ], + "MobilePasswordPalettes": [ + 92, + 23921 + ], + "MobilePhoneTilesGFX": [ + 62, + 21044 + ], + "MobileSelectGFX": [ + 95, + 18819 + ], + "MobileSplashScreenPalettes": [ + 91, + 18691 + ], + "MobileSystemSplashScreen_InitGFX.Attrmap": [ + 91, + 18331 + ], + "MobileSystemSplashScreen_InitGFX.Tilemap": [ + 91, + 17971 + ], + "MobileSystemSplashScreen_InitGFX.Tiles": [ + 91, + 16755 + ], + "MobileTradeAnim_GetFrontpic": [ + 66, + 16873 + ], + "MobileTradeAttrmapLZ": [ + 66, + 20647 + ], + "MobileTradeBGPalettes": [ + 66, + 20743 + ], + "MobileTradeGFX": [ + 66, + 19879 + ], + "MobileTradeLightsGFX": [ + 64, + 29346 + ], + "MobileTradeLightsPalettes": [ + 64, + 29410 + ], + "MobileTradeOB1Palettes": [ + 66, + 20807 + ], + "MobileTradeOB2Palettes": [ + 66, + 20871 + ], + "MobileTradeSpritesGFX": [ + 66, + 19751 + ], + "MobileTradeTilemapLZ": [ + 66, + 20455 + ], + "MobileUpArrowGFX": [ + 18, + 18627 + ], + "MoltresBackpic": [ + 83, + 16802 + ], + "MoltresFrontpic": [ + 73, + 25984 + ], + "MoltresPokedexEntry": [ + 115, + 18182 + ], + "MonMenuIcons": [ + 35, + 27332 + ], + "MoveDescriptions": [ + 11, + 19282 + ], + "MoveNames": [ + 114, + 24361 + ], + "Moves": [ + 16, + 23291 + ], + "MrMimeBackpic": [ + 88, + 20149 + ], + "MrMimeFrontpic": [ + 76, + 31370 + ], + "MrMimePokedexEntry": [ + 110, + 22506 + ], + "MukBackpic": [ + 86, + 18479 + ], + "MukFrontpic": [ + 75, + 25884 + ], + "MukPokedexEntry": [ + 110, + 18940 + ], + "MurkrowBackpic": [ + 86, + 20896 + ], + "MurkrowFrontpic": [ + 78, + 23815 + ], + "MurkrowPokedexEntry": [ + 116, + 16926 + ], + "Music": [ + 58, + 20590 + ], + "Music_AfterTheRivalFight": [ + 61, + 20775 + ], + "Music_AzaleaTown": [ + 61, + 22504 + ], + "Music_BattleTowerLobby": [ + 94, + 21643 + ], + "Music_BattleTowerTheme": [ + 94, + 18569 + ], + "Music_Bicycle": [ + 59, + 24857 + ], + "Music_BuenasPassword": [ + 94, + 16723 + ], + "Music_BugCatchingContest": [ + 61, + 31766 + ], + "Music_BurnedTower": [ + 61, + 27289 + ], + "Music_CeladonCity": [ + 61, + 17243 + ], + "Music_ChampionBattle": [ + 58, + 27073 + ], + "Music_CherrygroveCity": [ + 61, + 23299 + ], + "Music_Clair": [ + 7, + 31373 + ], + "Music_ContestResults": [ + 59, + 31550 + ], + "Music_Credits": [ + 7, + 29468 + ], + "Music_CrystalOpening": [ + 94, + 18152 + ], + "Music_DancingHall": [ + 59, + 31164 + ], + "Music_DarkCave": [ + 58, + 25278 + ], + "Music_DragonsDen": [ + 59, + 27880 + ], + "Music_EcruteakCity": [ + 59, + 29392 + ], + "Music_ElmsLab": [ + 58, + 24640 + ], + "Music_Evolution": [ + 60, + 18145 + ], + "Music_GameCorner": [ + 59, + 23917 + ], + "Music_GoldSilverOpening": [ + 61, + 28757 + ], + "Music_GoldSilverOpening2": [ + 61, + 29448 + ], + "Music_GoldenrodCity": [ + 58, + 29779 + ], + "Music_Gym": [ + 61, + 18606 + ], + "Music_GymLeaderVictory": [ + 61, + 17964 + ], + "Music_HallOfFame": [ + 60, + 17855 + ], + "Music_HealPokemon": [ + 60, + 18071 + ], + "Music_IndigoPlateau": [ + 59, + 26706 + ], + "Music_JohtoGymBattle": [ + 58, + 25869 + ], + "Music_JohtoTrainerBattle": [ + 61, + 24726 + ], + "Music_JohtoWildBattle": [ + 61, + 24005 + ], + "Music_JohtoWildBattleNight": [ + 61, + 24668 + ], + "Music_KantoGymBattle": [ + 59, + 18208 + ], + "Music_KantoTrainerBattle": [ + 59, + 18938 + ], + "Music_KantoWildBattle": [ + 59, + 20589 + ], + "Music_LakeOfRage": [ + 59, + 26309 + ], + "Music_LakeOfRageRocketRadio": [ + 59, + 30129 + ], + "Music_LavenderTown": [ + 59, + 30511 + ], + "Music_Lighthouse": [ + 59, + 25961 + ], + "Music_LookBeauty": [ + 59, + 28222 + ], + "Music_LookHiker": [ + 61, + 29713 + ], + "Music_LookKimonoGirl": [ + 61, + 31160 + ], + "Music_LookLass": [ + 59, + 22427 + ], + "Music_LookMysticalMan": [ + 94, + 17467 + ], + "Music_LookOfficer": [ + 59, + 22573 + ], + "Music_LookPokemaniac": [ + 58, + 32225 + ], + "Music_LookRival": [ + 61, + 20345 + ], + "Music_LookRocket": [ + 61, + 29858 + ], + "Music_LookSage": [ + 59, + 25438 + ], + "Music_LookYoungster": [ + 61, + 26307 + ], + "Music_MagnetTrain": [ + 59, + 30192 + ], + "Music_MainMenu": [ + 61, + 30973 + ], + "Music_MobileAdapter": [ + 7, + 31879 + ], + "Music_MobileAdapterMenu": [ + 94, + 16415 + ], + "Music_MobileCenter": [ + 94, + 22045 + ], + "Music_Mom": [ + 61, + 27634 + ], + "Music_MtMoon": [ + 59, + 23337 + ], + "Music_MtMoonSquare": [ + 61, + 18453 + ], + "Music_NationalPark": [ + 61, + 21736 + ], + "Music_NewBarkTown": [ + 58, + 29395 + ], + "Music_Nothing": [ + 58, + 20899 + ], + "Music_PalletTown": [ + 61, + 19212 + ], + "Music_PokeFluteChannel": [ + 61, + 31507 + ], + "Music_PokemonCenter": [ + 59, + 21958 + ], + "Music_PokemonChannel": [ + 59, + 25695 + ], + "Music_PokemonLullaby": [ + 61, + 28025 + ], + "Music_PokemonMarch": [ + 61, + 28195 + ], + "Music_PostCredits": [ + 51, + 32158 + ], + "Music_Printer": [ + 60, + 18429 + ], + "Music_ProfOak": [ + 61, + 19946 + ], + "Music_ProfOaksPokemonTalk": [ + 61, + 19615 + ], + "Music_RivalBattle": [ + 58, + 22351 + ], + "Music_RocketBattle": [ + 58, + 23407 + ], + "Music_RocketHideout": [ + 59, + 27509 + ], + "Music_RocketTheme": [ + 61, + 30317 + ], + "Music_Route1": [ + 59, + 16384 + ], + "Music_Route12": [ + 59, + 17670 + ], + "Music_Route2": [ + 59, + 22749 + ], + "Music_Route26": [ + 59, + 28594 + ], + "Music_Route29": [ + 60, + 17286 + ], + "Music_Route3": [ + 59, + 17098 + ], + "Music_Route30": [ + 59, + 31745 + ], + "Music_Route36": [ + 58, + 21737 + ], + "Music_Route37": [ + 59, + 26955 + ], + "Music_RuinsOfAlphInterior": [ + 58, + 32158 + ], + "Music_RuinsOfAlphRadio": [ + 59, + 28107 + ], + "Music_SSAqua": [ + 58, + 28057 + ], + "Music_ShowMeAround": [ + 59, + 23512 + ], + "Music_SproutTower": [ + 61, + 26996 + ], + "Music_SuccessfulCapture": [ + 61, + 17922 + ], + "Music_SuicuneBattle": [ + 94, + 19329 + ], + "Music_Surf": [ + 61, + 20874 + ], + "Music_TinTower": [ + 61, + 26641 + ], + "Music_TitleScreen": [ + 58, + 30728 + ], + "Music_TrainerVictory": [ + 58, + 32427 + ], + "Music_UnionCave": [ + 61, + 23648 + ], + "Music_VermilionCity": [ + 58, + 30326 + ], + "Music_VictoryRoad": [ + 61, + 27762 + ], + "Music_VioletCity": [ + 60, + 16384 + ], + "Music_ViridianCity": [ + 61, + 16384 + ], + "Music_WildPokemonVictory": [ + 61, + 17688 + ], + "MustRechargeText": [ + 32, + 19751 + ], + "MysteryGiftGFX": [ + 65, + 21080 + ], + "NPCTradeCableText": [ + 63, + 20347 + ], + "NPCTrades": [ + 63, + 20056 + ], + "NamingScreenGFX_Border": [ + 4, + 23735 + ], + "NamingScreenGFX_Cursor": [ + 4, + 23751 + ], + "NamingScreenGFX_MiddleLine": [ + 4, + 24165 + ], + "NamingScreenGFX_UnderLine": [ + 4, + 24173 + ], + "NatuBackpic": [ + 89, + 21902 + ], + "NatuFrontpic": [ + 88, + 23241 + ], + "NatuPokedexEntry": [ + 115, + 21629 + ], + "NewPokedexEntry": [ + 62, + 30839 + ], + "NewPokedexOrder": [ + 16, + 19808 + ], + "NidokingBackpic": [ + 81, + 21328 + ], + "NidokingFrontpic": [ + 72, + 22902 + ], + "NidokingPokedexEntry": [ + 96, + 25699 + ], + "NidoqueenBackpic": [ + 84, + 19113 + ], + "NidoqueenFrontpic": [ + 73, + 19748 + ], + "NidoqueenPokedexEntry": [ + 96, + 25390 + ], + "NidoranFBackpic": [ + 86, + 27318 + ], + "NidoranFFrontpic": [ + 80, + 31442 + ], + "NidoranFPokedexEntry": [ + 96, + 25163 + ], + "NidoranMBackpic": [ + 83, + 20933 + ], + "NidoranMFrontpic": [ + 80, + 26783 + ], + "NidoranMPokedexEntry": [ + 96, + 25490 + ], + "NidorinaBackpic": [ + 84, + 27891 + ], + "NidorinaFrontpic": [ + 77, + 23714 + ], + "NidorinaPokedexEntry": [ + 96, + 25270 + ], + "NidorinoBackpic": [ + 84, + 31259 + ], + "NidorinoFrontpic": [ + 75, + 28411 + ], + "NidorinoPokedexEntry": [ + 96, + 25597 + ], + "NinetalesBackpic": [ + 82, + 25791 + ], + "NinetalesFrontpic": [ + 72, + 20498 + ], + "NinetalesPokedexEntry": [ + 96, + 26124 + ], + "NoPPLeftText": [ + 32, + 20377 + ], + "NoctowlBackpic": [ + 87, + 16708 + ], + "NoctowlFrontpic": [ + 77, + 17131 + ], + "NoctowlPokedexEntry": [ + 115, + 20153 + ], + "NotVeryEffectiveText": [ + 32, + 20673 + ], + "NothingHappenedText": [ + 32, + 21721 + ], + "NurseAskHealText": [ + 108, + 16762 + ], + "NurseDayText": [ + 108, + 16427 + ], + "NurseGoodbyeText": [ + 108, + 16907 + ], + "NurseMornText": [ + 108, + 16384 + ], + "NurseNiteText": [ + 108, + 16463 + ], + "NursePokerusText": [ + 108, + 16961 + ], + "NurseReturnPokemonText": [ + 108, + 16855 + ], + "NurseTakePokemonText": [ + 108, + 16829 + ], + "OctilleryBackpic": [ + 88, + 27044 + ], + "OctilleryFrontpic": [ + 79, + 25706 + ], + "OctilleryPokedexEntry": [ + 116, + 19778 + ], + "OddishBackpic": [ + 88, + 32370 + ], + "OddishFrontpic": [ + 83, + 22572 + ], + "OddishPokedexEntry": [ + 96, + 26678 + ], + "OmanyteBackpic": [ + 84, + 16384 + ], + "OmanyteFrontpic": [ + 82, + 22257 + ], + "OmanytePokedexEntry": [ + 115, + 17345 + ], + "OmanytePuzzleLZ": [ + 56, + 23707 + ], + "OmastarBackpic": [ + 74, + 32337 + ], + "OmastarFrontpic": [ + 78, + 27781 + ], + "OmastarPokedexEntry": [ + 115, + 17454 + ], + "OneHitKOText": [ + 32, + 20631 + ], + "OnixBackpic": [ + 84, + 26005 + ], + "OnixFrontpic": [ + 75, + 19015 + ], + "OnixPokedexEntry": [ + 110, + 19608 + ], + "OverworldSprites": [ + 5, + 18230 + ], + "PCMailGFX": [ + 56, + 29853 + ], + "PackGFX": [ + 4, + 20502 + ], + "PackMenuGFX": [ + 4, + 19222 + ], + "PadFrontpic": [ + 20, + 21163 + ], + "PalPacket_Pack": [ + 2, + 23606 + ], + "ParalyzedText": [ + 32, + 21848 + ], + "ParasBackpic": [ + 88, + 16384 + ], + "ParasFrontpic": [ + 81, + 20842 + ], + "ParasPokedexEntry": [ + 96, + 27007 + ], + "ParasectBackpic": [ + 80, + 32454 + ], + "ParasectFrontpic": [ + 76, + 30607 + ], + "ParasectPokedexEntry": [ + 96, + 27106 + ], + "PartyMenuOBPals": [ + 2, + 30337 + ], + "PasswordBottomTilemap": [ + 92, + 25885 + ], + "PasswordShiftTilemap": [ + 92, + 26105 + ], + "PasswordTopTilemap": [ + 92, + 25745 + ], + "PerishCountText": [ + 32, + 18532 + ], + "PersianBackpic": [ + 84, + 22580 + ], + "PersianFrontpic": [ + 76, + 19617 + ], + "PersianPokedexEntry": [ + 96, + 27768 + ], + "PhanpyBackpic": [ + 88, + 24347 + ], + "PhanpyFrontpic": [ + 83, + 17633 + ], + "PhanpyPokedexEntry": [ + 116, + 20536 + ], + "PhoneContacts": [ + 36, + 17503 + ], + "PhoneOutOfAreaScript": [ + 36, + 18007 + ], + "PhoneScript_JustTalkToThem": [ + 36, + 18016 + ], + "PhoneTilemapRLE": [ + 36, + 21898 + ], + "PichuAnimatedMobileGFX": [ + 92, + 19734 + ], + "PichuBackpic": [ + 88, + 18142 + ], + "PichuBorderMobileBGPalettes": [ + 69, + 29518 + ], + "PichuBorderMobileGFX": [ + 92, + 22600 + ], + "PichuBorderMobileOBPalettes": [ + 69, + 29454 + ], + "PichuBorderMobileTilemapAttrmap": [ + 69, + 29526 + ], + "PichuFrontpic": [ + 84, + 19500 + ], + "PichuPokedexEntry": [ + 115, + 21059 + ], + "PictureBookshelfText": [ + 108, + 17272 + ], + "PidgeotBackpic": [ + 86, + 23965 + ], + "PidgeotFrontpic": [ + 74, + 23325 + ], + "PidgeotPokedexEntry": [ + 96, + 24023 + ], + "PidgeottoBackpic": [ + 88, + 19579 + ], + "PidgeottoFrontpic": [ + 78, + 25807 + ], + "PidgeottoPokedexEntry": [ + 96, + 23925 + ], + "PidgeyBackpic": [ + 84, + 16776 + ], + "PidgeyFrontpic": [ + 80, + 31948 + ], + "PidgeyPokedexEntry": [ + 96, + 23802 + ], + "PikachuBackpic": [ + 85, + 28321 + ], + "PikachuFrontpic": [ + 80, + 17533 + ], + "PikachuPokedexEntry": [ + 96, + 24749 + ], + "PiloswineBackpic": [ + 89, + 21277 + ], + "PiloswineFrontpic": [ + 78, + 28437 + ], + "PiloswinePokedexEntry": [ + 116, + 19457 + ], + "PinecoBackpic": [ + 87, + 21850 + ], + "PinecoFrontpic": [ + 81, + 18880 + ], + "PinecoPokedexEntry": [ + 116, + 17584 + ], + "PinsirBackpic": [ + 82, + 26657 + ], + "PinsirFrontpic": [ + 75, + 31711 + ], + "PinsirPokedexEntry": [ + 110, + 23035 + ], + "PlaceUnownPrinterFrontpic": [ + 5, + 28076 + ], + "PlayerHitTimesText": [ + 32, + 21221 + ], + "PokeComNurseDayText": [ + 108, + 16598 + ], + "PokeComNurseMornText": [ + 108, + 16522 + ], + "PokeComNurseNiteText": [ + 108, + 16667 + ], + "PokeComNursePokerusText": [ + 108, + 17110 + ], + "PokecenterSignText": [ + 108, + 17791 + ], + "PokedexCursorPalette": [ + 2, + 20418 + ], + "PokedexDataPointerTable": [ + 17, + 17272 + ], + "PokedexLZ": [ + 16, + 21774 + ], + "PokedexQuestionMarkPalette": [ + 2, + 20410 + ], + "PokedexSlowpokeLZ": [ + 16, + 22192 + ], + "PokegearGFX": [ + 119, + 25316 + ], + "PokegearPhoneIconGFX": [ + 62, + 20260 + ], + "PokegearSpritesGFX": [ + 36, + 21725 + ], + "PokemonCries": [ + 60, + 26503 + ], + "PokemonFellFromTreeText": [ + 32, + 18296 + ], + "PokemonNames": [ + 20, + 29572 + ], + "PokemonNewsGFX": [ + 95, + 26366 + ], + "PokemonNewsPalettes": [ + 95, + 28662 + ], + "PokemonNewsTileAttrmap": [ + 95, + 27534 + ], + "PokemonPalettes": [ + 2, + 26830 + ], + "PokemonPicPointers": [ + 72, + 16384 + ], + "PokemonProfPic": [ + 86, + 16734 + ], + "PolitoedBackpic": [ + 88, + 22405 + ], + "PolitoedFrontpic": [ + 76, + 22806 + ], + "PolitoedPokedexEntry": [ + 115, + 22603 + ], + "PoliwagBackpic": [ + 89, + 17351 + ], + "PoliwagFrontpic": [ + 84, + 25626 + ], + "PoliwagPokedexEntry": [ + 96, + 28561 + ], + "PoliwhirlBackpic": [ + 86, + 24641 + ], + "PoliwhirlFrontpic": [ + 77, + 20086 + ], + "PoliwhirlPokedexEntry": [ + 96, + 28669 + ], + "PoliwrathBackpic": [ + 84, + 30141 + ], + "PoliwrathFrontpic": [ + 75, + 22474 + ], + "PoliwrathPokedexEntry": [ + 96, + 28774 + ], + "PonytaBackpic": [ + 84, + 24868 + ], + "PonytaFrontpic": [ + 78, + 23150 + ], + "PonytaPokedexEntry": [ + 110, + 17650 + ], + "Porygon2Backpic": [ + 87, + 21211 + ], + "Porygon2Frontpic": [ + 80, + 30417 + ], + "Porygon2PokedexEntry": [ + 116, + 20748 + ], + "PorygonBackpic": [ + 87, + 32294 + ], + "PorygonFrontpic": [ + 80, + 27307 + ], + "PorygonPokedexEntry": [ + 115, + 17235 + ], + "PredefPals": [ + 2, + 24054 + ], + "PrepMonFrontpic": [ + 0, + 14214 + ], + "PresentFailedText": [ + 32, + 22587 + ], + "PrimeapeBackpic": [ + 85, + 24393 + ], + "PrimeapeFrontpic": [ + 74, + 31456 + ], + "PrimeapePokedexEntry": [ + 96, + 28217 + ], + "ProtectedByMistText": [ + 32, + 21281 + ], + "ProtectedByText": [ + 32, + 21929 + ], + "ProtectedItselfText": [ + 32, + 22117 + ], + "ProtectingItselfText": [ + 32, + 22138 + ], + "PsyduckBackpic": [ + 85, + 27965 + ], + "PsyduckFrontpic": [ + 81, + 22782 + ], + "PsyduckPokedexEntry": [ + 96, + 27886 + ], + "PupitarBackpic": [ + 84, + 20657 + ], + "PupitarFrontpic": [ + 82, + 23149 + ], + "PupitarPokedexEntry": [ + 116, + 22281 + ], + "PutACurseText": [ + 32, + 22077 + ], + "PuzzlePieceBorderData.TileBordersGFX": [ + 56, + 22307 + ], + "QuagsireBackpic": [ + 87, + 22803 + ], + "QuagsireFrontpic": [ + 79, + 32183 + ], + "QuagsirePokedexEntry": [ + 116, + 16605 + ], + "QuestionEmote": [ + 65, + 17296 + ], + "QuilavaBackpic": [ + 83, + 25393 + ], + "QuilavaFrontpic": [ + 73, + 32028 + ], + "QuilavaPokedexEntry": [ + 115, + 19251 + ], + "QwilfishBackpic": [ + 84, + 17558 + ], + "QwilfishFrontpic": [ + 82, + 29649 + ], + "QwilfishPokedexEntry": [ + 116, + 18353 + ], + "RadioTilemapRLE": [ + 36, + 21773 + ], + "RageBuildingText": [ + 32, + 20723 + ], + "RaichuBackpic": [ + 84, + 29766 + ], + "RaichuFrontpic": [ + 74, + 30565 + ], + "RaichuPokedexEntry": [ + 96, + 24855 + ], + "RaikouBackpic": [ + 89, + 16627 + ], + "RaikouFrontpic": [ + 72, + 24064 + ], + "RaikouPokedexEntry": [ + 116, + 21867 + ], + "RapidashBackpic": [ + 83, + 22976 + ], + "RapidashFrontpic": [ + 73, + 17624 + ], + "RapidashPokedexEntry": [ + 110, + 17758 + ], + "RaticateBackpic": [ + 85, + 26183 + ], + "RaticateFrontpic": [ + 76, + 21213 + ], + "RaticatePokedexEntry": [ + 96, + 24227 + ], + "RattataBackpic": [ + 86, + 30308 + ], + "RattataFrontpic": [ + 80, + 22553 + ], + "RattataPokedexEntry": [ + 96, + 24125 + ], + "ReadTileToAnimBuffer": [ + 63, + 18082 + ], + "ReceivedItemText": [ + 108, + 17992 + ], + "RecoilText": [ + 32, + 21325 + ], + "RecoveredUsingText": [ + 32, + 19394 + ], + "ReflectEffectText": [ + 32, + 21703 + ], + "RegainedHealthText": [ + 32, + 20492 + ], + "RegisteredNumber1Text": [ + 108, + 18884 + ], + "RegisteredNumber2Text": [ + 108, + 18913 + ], + "ReleasedByText": [ + 32, + 22372 + ], + "RemoraidBackpic": [ + 85, + 29381 + ], + "RemoraidFrontpic": [ + 84, + 25247 + ], + "RemoraidPokedexEntry": [ + 116, + 19667 + ], + "RestedText": [ + 32, + 20457 + ], + "RhydonBackpic": [ + 83, + 18047 + ], + "RhydonFrontpic": [ + 74, + 20510 + ], + "RhydonPokedexEntry": [ + 110, + 21445 + ], + "RhyhornBackpic": [ + 87, + 30200 + ], + "RhyhornFrontpic": [ + 74, + 22399 + ], + "RhyhornPokedexEntry": [ + 110, + 21341 + ], + "RoamMaps": [ + 10, + 25615 + ], + "RockMonMaps": [ + 46, + 17093 + ], + "RoofPals": [ + 2, + 30057 + ], + "Roofs": [ + 7, + 16444 + ], + "RotateUnownFrontpic": [ + 56, + 16384 + ], + "SFX": [ + 58, + 21116 + ], + "SadEmote": [ + 65, + 17424 + ], + "SafeguardProtectText": [ + 32, + 22323 + ], + "SandshrewBackpic": [ + 83, + 26192 + ], + "SandshrewFrontpic": [ + 80, + 30930 + ], + "SandshrewPokedexEntry": [ + 96, + 24964 + ], + "SandslashBackpic": [ + 83, + 19286 + ], + "SandslashFrontpic": [ + 75, + 29244 + ], + "SandslashPokedexEntry": [ + 96, + 25063 + ], + "SandstormBrewedText": [ + 32, + 22244 + ], + "SandstormHitsText": [ + 32, + 18509 + ], + "ScizorBackpic": [ + 85, + 22233 + ], + "ScizorFrontpic": [ + 73, + 21852 + ], + "ScizorPokedexEntry": [ + 116, + 18465 + ], + "ScrollTileDown": [ + 63, + 17258 + ], + "ScrollTileLeft": [ + 63, + 17191 + ], + "ScrollTileRight": [ + 63, + 17211 + ], + "ScrollTileRightLeft": [ + 63, + 17161 + ], + "ScrollTileUp": [ + 63, + 17231 + ], + "ScytherBackpic": [ + 85, + 18962 + ], + "ScytherFrontpic": [ + 75, + 23333 + ], + "ScytherPokedexEntry": [ + 110, + 22609 + ], + "SeadraBackpic": [ + 83, + 19698 + ], + "SeadraFrontpic": [ + 80, + 18103 + ], + "SeadraPokedexEntry": [ + 110, + 21944 + ], + "SeakingBackpic": [ + 85, + 27253 + ], + "SeakingFrontpic": [ + 75, + 25036 + ], + "SeakingPokedexEntry": [ + 110, + 22160 + ], + "SeelBackpic": [ + 87, + 22486 + ], + "SeelFrontpic": [ + 79, + 16384 + ], + "SeelPokedexEntry": [ + 110, + 18623 + ], + "SelectStartGFX": [ + 71, + 22142 + ], + "SentAllToMomText": [ + 32, + 19005 + ], + "SentHalfToMomText": [ + 32, + 18986 + ], + "SentSomeToMomText": [ + 32, + 18940 + ], + "SentretBackpic": [ + 88, + 27829 + ], + "SentretFrontpic": [ + 85, + 17122 + ], + "SentretPokedexEntry": [ + 115, + 19819 + ], + "SharedPainText": [ + 32, + 20763 + ], + "ShedLeechSeedText": [ + 32, + 22394 + ], + "ShellderBackpic": [ + 83, + 27381 + ], + "ShellderFrontpic": [ + 82, + 24038 + ], + "ShellderPokedexEntry": [ + 110, + 19048 + ], + "ShockEmote": [ + 65, + 17232 + ], + "Shrink1Pic": [ + 19, + 21065 + ], + "Shrink2Pic": [ + 19, + 21209 + ], + "ShuckleBackpic": [ + 89, + 17830 + ], + "ShuckleFrontpic": [ + 84, + 24489 + ], + "ShucklePokedexEntry": [ + 116, + 18574 + ], + "SkarmoryBackpic": [ + 88, + 31634 + ], + "SkarmoryFrontpic": [ + 74, + 27888 + ], + "SkarmoryPokedexEntry": [ + 116, + 20108 + ], + "SketchedText": [ + 32, + 20803 + ], + "SkiploomBackpic": [ + 87, + 28692 + ], + "SkiploomFrontpic": [ + 81, + 26608 + ], + "SkiploomPokedexEntry": [ + 115, + 22824 + ], + "SleepEmote": [ + 65, + 17616 + ], + "Slots1LZ": [ + 36, + 29773 + ], + "Slots2LZ": [ + 36, + 30157 + ], + "Slots3LZ": [ + 36, + 30765 + ], + "SlotsTilemap": [ + 36, + 29533 + ], + "SlowbroBackpic": [ + 86, + 21582 + ], + "SlowbroFrontpic": [ + 74, + 24245 + ], + "SlowbroPokedexEntry": [ + 110, + 17973 + ], + "SlowkingBackpic": [ + 82, + 30496 + ], + "SlowkingFrontpic": [ + 75, + 17265 + ], + "SlowkingPokedexEntry": [ + 116, + 17042 + ], + "SlowpokeBackpic": [ + 89, + 18537 + ], + "SlowpokeFrontpic": [ + 82, + 18200 + ], + "SlowpokePokedexEntry": [ + 110, + 17875 + ], + "SlugmaBackpic": [ + 87, + 17354 + ], + "SlugmaFrontpic": [ + 82, + 17293 + ], + "SlugmaPokedexEntry": [ + 116, + 19128 + ], + "SmeargleBackpic": [ + 85, + 23314 + ], + "SmeargleFrontpic": [ + 78, + 21817 + ], + "SmearglePokedexEntry": [ + 116, + 20960 + ], + "SmoochumBackpic": [ + 88, + 17853 + ], + "SmoochumFrontpic": [ + 82, + 18653 + ], + "SmoochumPokedexEntry": [ + 116, + 21291 + ], + "SneaselBackpic": [ + 86, + 20209 + ], + "SneaselFrontpic": [ + 78, + 18446 + ], + "SneaselPokedexEntry": [ + 116, + 18810 + ], + "SnorlaxBackpic": [ + 82, + 32597 + ], + "SnorlaxFrontpic": [ + 78, + 19802 + ], + "SnorlaxPokedexEntry": [ + 115, + 17871 + ], + "SnubbullBackpic": [ + 86, + 29316 + ], + "SnubbullFrontpic": [ + 79, + 18933 + ], + "SnubbullPokedexEntry": [ + 116, + 18144 + ], + "SpawnPoints": [ + 5, + 21163 + ], + "SpearowBackpic": [ + 87, + 25619 + ], + "SpearowFrontpic": [ + 80, + 23619 + ], + "SpearowPokedexEntry": [ + 96, + 24323 + ], + "SpecialPhoneCallList": [ + 36, + 17959 + ], + "SpikesText": [ + 32, + 22161 + ], + "SpinarakBackpic": [ + 89, + 16384 + ], + "SpinarakFrontpic": [ + 87, + 28994 + ], + "SpinarakPokedexEntry": [ + 115, + 20498 + ], + "SpiteEffectText": [ + 32, + 20863 + ], + "SpriteMons": [ + 5, + 17557 + ], + "SquirtleBackpic": [ + 89, + 17591 + ], + "SquirtleFrontpic": [ + 81, + 23266 + ], + "SquirtlePokedexEntry": [ + 96, + 22822 + ], + "Stadium2N64Attrmap": [ + 92, + 29975 + ], + "Stadium2N64GFX": [ + 92, + 28447 + ], + "Stadium2N64Tilemap": [ + 92, + 29615 + ], + "StandingTileFrame": [ + 63, + 18035 + ], + "StandingTileFrame8": [ + 63, + 17151 + ], + "StantlerBackpic": [ + 85, + 22954 + ], + "StantlerFrontpic": [ + 76, + 20415 + ], + "StantlerPokedexEntry": [ + 116, + 20852 + ], + "StarmieBackpic": [ + 83, + 32501 + ], + "StarmieFrontpic": [ + 78, + 25144 + ], + "StarmiePokedexEntry": [ + 110, + 22390 + ], + "StartPerishText": [ + 32, + 22210 + ], + "StartedNightmareText": [ + 32, + 22027 + ], + "StaryuBackpic": [ + 88, + 19864 + ], + "StaryuFrontpic": [ + 80, + 28868 + ], + "StaryuPokedexEntry": [ + 110, + 22274 + ], + "StatsScreenPagePals": [ + 2, + 20306 + ], + "StatsScreenPageTilesGFX": [ + 62, + 18864 + ], + "StatsScreen_PlaceFrontpic": [ + 19, + 25126 + ], + "StdScripts": [ + 47, + 16384 + ], + "SteelixBackpic": [ + 84, + 22196 + ], + "SteelixFrontpic": [ + 74, + 16585 + ], + "SteelixPokedexEntry": [ + 116, + 18044 + ], + "StoleText": [ + 32, + 21978 + ], + "StoringEnergyText": [ + 32, + 20078 + ], + "SubFadedText": [ + 32, + 21456 + ], + "SubTookDamageText": [ + 32, + 21421 + ], + "SuckedHealthText": [ + 32, + 21008 + ], + "SudowoodoBackpic": [ + 85, + 16384 + ], + "SudowoodoFrontpic": [ + 80, + 16959 + ], + "SudowoodoPokedexEntry": [ + 115, + 22489 + ], + "SuicuneBackpic": [ + 82, + 30073 + ], + "SuicuneFrontpic": [ + 72, + 30736 + ], + "SuicunePokedexEntry": [ + 116, + 22082 + ], + "SunGotBrightText": [ + 32, + 22456 + ], + "SunfloraBackpic": [ + 83, + 17218 + ], + "SunfloraFrontpic": [ + 79, + 21443 + ], + "SunfloraPokedexEntry": [ + 115, + 23279 + ], + "SunkernBackpic": [ + 87, + 25929 + ], + "SunkernFrontpic": [ + 84, + 30888 + ], + "SunkernPokedexEntry": [ + 115, + 23175 + ], + "SuperEffectiveText": [ + 32, + 20650 + ], + "SwarmGrassWildMons": [ + 10, + 30928 + ], + "SwarmWaterWildMons": [ + 10, + 31023 + ], + "SwinubBackpic": [ + 85, + 32544 + ], + "SwinubFrontpic": [ + 87, + 31698 + ], + "SwinubPokedexEntry": [ + 116, + 19341 + ], + "TMHMMoves": [ + 4, + 22138 + ], + "TVText": [ + 108, + 17598 + ], + "TangelaBackpic": [ + 89, + 20163 + ], + "TangelaFrontpic": [ + 79, + 18300 + ], + "TangelaPokedexEntry": [ + 110, + 21644 + ], + "TaurosBackpic": [ + 89, + 19472 + ], + "TaurosFrontpic": [ + 72, + 27437 + ], + "TaurosPokedexEntry": [ + 110, + 23151 + ], + "TeamRocketOathText": [ + 108, + 17369 + ], + "TeddiursaBackpic": [ + 88, + 24071 + ], + "TeddiursaFrontpic": [ + 83, + 28171 + ], + "TeddiursaPokedexEntry": [ + 116, + 18906 + ], + "TentacoolBackpic": [ + 88, + 16976 + ], + "TentacoolFrontpic": [ + 82, + 24480 + ], + "TentacoolPokedexEntry": [ + 110, + 17105 + ], + "TentacruelBackpic": [ + 84, + 28266 + ], + "TentacruelFrontpic": [ + 77, + 27969 + ], + "TentacruelPokedexEntry": [ + 110, + 17207 + ], + "Text_BallCaught": [ + 113, + 23319 + ], + "Text_BattleEffectActivate": [ + 112, + 19654 + ], + "Text_BattleFoeEffectActivate": [ + 112, + 19691 + ], + "Text_BattleUser": [ + 112, + 19726 + ], + "Text_BreedHuh": [ + 112, + 19888 + ], + "Text_Gained": [ + 112, + 17052 + ], + "Text_MoveForgetCount": [ + 113, + 22336 + ], + "Text_NPCTraded": [ + 111, + 21545 + ], + "Text_PlayedPokeFlute": [ + 113, + 23620 + ], + "TheEndGFX": [ + 50, + 32046 + ], + "TiedAgainstText": [ + 32, + 18923 + ], + "TilesetBGPalette": [ + 2, + 29465 + ], + "Tilesets": [ + 19, + 21910 + ], + "TimeFishGroups": [ + 36, + 26223 + ], + "TitleCrystalGFX": [ + 67, + 31982 + ], + "TitleLogoGFX": [ + 67, + 29478 + ], + "TitleScreenPalettes": [ + 67, + 32478 + ], + "TitleSuicuneGFX": [ + 67, + 28486 + ], + "TogepiBackpic": [ + 88, + 25442 + ], + "TogepiFrontpic": [ + 87, + 19609 + ], + "TogepiPokedexEntry": [ + 115, + 21398 + ], + "TogeticBackpic": [ + 85, + 17491 + ], + "TogeticFrontpic": [ + 78, + 31681 + ], + "TogeticPokedexEntry": [ + 115, + 21506 + ], + "TooWeakSubText": [ + 32, + 21389 + ], + "TookAimText": [ + 32, + 20790 + ], + "TookDownWithItText": [ + 32, + 20698 + ], + "TotodileBackpic": [ + 86, + 28984 + ], + "TotodileFrontpic": [ + 80, + 19809 + ], + "TotodilePokedexEntry": [ + 115, + 19476 + ], + "TownMapGFX": [ + 62, + 19360 + ], + "TownMapPals.PalMap": [ + 36, + 24395 + ], + "TradeAnim_AnimateFrontpic": [ + 10, + 21639 + ], + "TradeAnim_GetFrontpic": [ + 10, + 21649 + ], + "TradeAnim_ShowFrontpic": [ + 10, + 21699 + ], + "TradeAnim_ShowGetmonFrontpic": [ + 10, + 21696 + ], + "TradeAnim_ShowGivemonFrontpic": [ + 10, + 21691 + ], + "TradeArrowLeftGFX": [ + 10, + 22827 + ], + "TradeArrowRightGFX": [ + 10, + 22811 + ], + "TradeBallGFX": [ + 10, + 23259 + ], + "TradeBubbleGFX": [ + 10, + 22875 + ], + "TradeCableGFX": [ + 10, + 22843 + ], + "TradeGameBoyLZ": [ + 10, + 22939 + ], + "TradeGameBoyTilemap": [ + 10, + 22727 + ], + "TradeLinkTubeTilemap": [ + 10, + 22775 + ], + "TradePoofGFX": [ + 10, + 23355 + ], + "TradeTexts": [ + 63, + 20307 + ], + "TradedForText": [ + 63, + 20352 + ], + "TrainerCardGFX": [ + 34, + 18373 + ], + "TrainerCard_JohtoBadgesOAM": [ + 9, + 21705 + ], + "TrainerClassAttributes": [ + 14, + 21916 + ], + "TrainerClassNames": [ + 11, + 16879 + ], + "TrainerEncounterMusic": [ + 58, + 20519 + ], + "TrainerGroups": [ + 14, + 22937 + ], + "TrainerPalettes": [ + 2, + 28878 + ], + "TrainerPicPointers": [ + 74, + 16384 + ], + "Trainers": [ + 14, + 23071 + ], + "TransformedText": [ + 32, + 21657 + ], + "TransformedTypeText": [ + 32, + 21586 + ], + "TrashCanText": [ + 108, + 17702 + ], + "TreeMonMaps": [ + 46, + 16990 + ], + "TreeMons": [ + 46, + 17128 + ], + "TurnedAwayText": [ + 32, + 20308 + ], + "TypeMatchups": [ + 13, + 19377 + ], + "TypeNames": [ + 20, + 18811 + ], + "TyphlosionBackpic": [ + 84, + 18337 + ], + "TyphlosionFrontpic": [ + 72, + 31821 + ], + "TyphlosionPokedexEntry": [ + 115, + 19360 + ], + "TyranitarBackpic": [ + 83, + 18873 + ], + "TyranitarFrontpic": [ + 73, + 24963 + ], + "TyranitarPokedexEntry": [ + 116, + 22395 + ], + "TyrogueBackpic": [ + 85, + 21511 + ], + "TyrogueFrontpic": [ + 81, + 26138 + ], + "TyroguePokedexEntry": [ + 116, + 21067 + ], + "UmbreonBackpic": [ + 86, + 20553 + ], + "UmbreonFrontpic": [ + 79, + 22062 + ], + "UmbreonPokedexEntry": [ + 116, + 16815 + ], + "UnaffectedText": [ + 32, + 20577 + ], + "UnleashedEnergyText": [ + 32, + 20100 + ], + "UnownABackpic": [ + 89, + 25801 + ], + "UnownAFrontpic": [ + 89, + 22105 + ], + "UnownAnimationIdlePointers": [ + 52, + 25553 + ], + "UnownAnimationPointers": [ + 52, + 25129 + ], + "UnownBBackpic": [ + 89, + 25164 + ], + "UnownBFrontpic": [ + 89, + 18302 + ], + "UnownBitmasksPointers": [ + 52, + 31443 + ], + "UnownCBackpic": [ + 89, + 23263 + ], + "UnownCFrontpic": [ + 87, + 30500 + ], + "UnownDBackpic": [ + 88, + 32614 + ], + "UnownDFrontpic": [ + 84, + 21427 + ], + "UnownEBackpic": [ + 89, + 24893 + ], + "UnownEFrontpic": [ + 75, + 32526 + ], + "UnownFBackpic": [ + 81, + 32615 + ], + "UnownFFrontpic": [ + 89, + 21695 + ], + "UnownFont": [ + 119, + 16384 + ], + "UnownFramesPointers": [ + 54, + 22953 + ], + "UnownGBackpic": [ + 89, + 23971 + ], + "UnownGFrontpic": [ + 88, + 21845 + ], + "UnownHBackpic": [ + 87, + 32591 + ], + "UnownHFrontpic": [ + 88, + 29635 + ], + "UnownIBackpic": [ + 89, + 26907 + ], + "UnownIFrontpic": [ + 89, + 24135 + ], + "UnownJBackpic": [ + 89, + 25033 + ], + "UnownJFrontpic": [ + 89, + 20621 + ], + "UnownKBackpic": [ + 89, + 26045 + ], + "UnownKFrontpic": [ + 89, + 16870 + ], + "UnownLBackpic": [ + 89, + 26391 + ], + "UnownLFrontpic": [ + 89, + 21066 + ], + "UnownMBackpic": [ + 89, + 25924 + ], + "UnownMFrontpic": [ + 89, + 21487 + ], + "UnownNBackpic": [ + 89, + 25678 + ], + "UnownNFrontpic": [ + 89, + 19933 + ], + "UnownOBackpic": [ + 89, + 25295 + ], + "UnownOFrontpic": [ + 88, + 28090 + ], + "UnownPBackpic": [ + 89, + 26813 + ], + "UnownPFrontpic": [ + 89, + 23078 + ], + "UnownPicPointers": [ + 73, + 16384 + ], + "UnownPokedexEntry": [ + 116, + 17267 + ], + "UnownPuzzleCursorGFX": [ + 56, + 22469 + ], + "UnownPuzzleStartCancelLZ": [ + 56, + 22533 + ], + "UnownQBackpic": [ + 89, + 26606 + ], + "UnownQFrontpic": [ + 89, + 22889 + ], + "UnownRBackpic": [ + 89, + 27001 + ], + "UnownRFrontpic": [ + 89, + 24747 + ], + "UnownSBackpic": [ + 89, + 24601 + ], + "UnownSFrontpic": [ + 88, + 27307 + ], + "UnownTBackpic": [ + 89, + 26162 + ], + "UnownTFrontpic": [ + 88, + 28350 + ], + "UnownUBackpic": [ + 89, + 26499 + ], + "UnownUFrontpic": [ + 89, + 22506 + ], + "UnownVBackpic": [ + 89, + 24298 + ], + "UnownVFrontpic": [ + 88, + 24622 + ], + "UnownWBackpic": [ + 89, + 25552 + ], + "UnownWFrontpic": [ + 87, + 29598 + ], + "UnownWalls": [ + 34, + 28348 + ], + "UnownXBackpic": [ + 89, + 26278 + ], + "UnownXFrontpic": [ + 89, + 19703 + ], + "UnownYBackpic": [ + 89, + 26711 + ], + "UnownYFrontpic": [ + 89, + 23798 + ], + "UnownZBackpic": [ + 89, + 25425 + ], + "UnownZFrontpic": [ + 87, + 31399 + ], + "UnusedMobilePulsePalettes": [ + 66, + 20727 + ], + "UnusedRivalLossText": [ + 32, + 19023 + ], + "UnusedRivalWinText": [ + 32, + 19091 + ], + "Unused_HOF_AnimateAlignedFrontpic": [ + 52, + 18025 + ], + "UrsaringBackpic": [ + 88, + 23518 + ], + "UrsaringFrontpic": [ + 77, + 20814 + ], + "UrsaringPokedexEntry": [ + 116, + 19009 + ], + "UsedBindText": [ + 32, + 19985 + ], + "VaporeonBackpic": [ + 88, + 16680 + ], + "VaporeonFrontpic": [ + 78, + 16384 + ], + "VaporeonPokedexEntry": [ + 115, + 16932 + ], + "VenomothBackpic": [ + 82, + 20910 + ], + "VenomothFrontpic": [ + 76, + 22010 + ], + "VenomothPokedexEntry": [ + 96, + 27327 + ], + "VenonatBackpic": [ + 85, + 31493 + ], + "VenonatFrontpic": [ + 81, + 24713 + ], + "VenonatPokedexEntry": [ + 96, + 27219 + ], + "VenusaurBackpic": [ + 83, + 16384 + ], + "VenusaurFrontpic": [ + 72, + 28545 + ], + "VenusaurPokedexEntry": [ + 96, + 22387 + ], + "VictreebelBackpic": [ + 87, + 18000 + ], + "VictreebelFrontpic": [ + 74, + 29673 + ], + "VictreebelPokedexEntry": [ + 110, + 17006 + ], + "VileplumeBackpic": [ + 85, + 19328 + ], + "VileplumeFrontpic": [ + 77, + 25855 + ], + "VileplumePokedexEntry": [ + 96, + 26904 + ], + "VoltorbBackpic": [ + 89, + 20392 + ], + "VoltorbFrontpic": [ + 88, + 20718 + ], + "VoltorbPokedexEntry": [ + 110, + 20144 + ], + "VulpixBackpic": [ + 85, + 26540 + ], + "VulpixFrontpic": [ + 77, + 30774 + ], + "VulpixPokedexEntry": [ + 96, + 26011 + ], + "WaitTileAnimation": [ + 63, + 17150 + ], + "WantsToBattleText": [ + 32, + 18345 + ], + "WartortleBackpic": [ + 85, + 25110 + ], + "WartortleFrontpic": [ + 77, + 17874 + ], + "WartortlePokedexEntry": [ + 96, + 22931 + ], + "WasBurnedText": [ + 32, + 21052 + ], + "WasDefrostedText": [ + 32, + 22059 + ], + "WasDisabledText": [ + 32, + 21533 + ], + "WasFrozenText": [ + 32, + 21085 + ], + "WasPoisonedText": [ + 32, + 20949 + ], + "WasSeededText": [ + 32, + 21496 + ], + "WaveSamples": [ + 58, + 19890 + ], + "WeedleBackpic": [ + 88, + 29379 + ], + "WeedleFrontpic": [ + 86, + 28651 + ], + "WeedlePokedexEntry": [ + 96, + 23476 + ], + "WeepinbellBackpic": [ + 86, + 26984 + ], + "WeepinbellFrontpic": [ + 80, + 19242 + ], + "WeepinbellPokedexEntry": [ + 110, + 16893 + ], + "WeezingBackpic": [ + 86, + 22607 + ], + "WeezingFrontpic": [ + 74, + 26071 + ], + "WeezingPokedexEntry": [ + 110, + 21228 + ], + "WentToSleepText": [ + 32, + 20439 + ], + "WhirlpoolTrapText": [ + 32, + 20004 + ], + "WigglytuffBackpic": [ + 88, + 28609 + ], + "WigglytuffFrontpic": [ + 78, + 20479 + ], + "WigglytuffPokedexEntry": [ + 96, + 26345 + ], + "WildCelebiAppearedText": [ + 32, + 18323 + ], + "WildPokemonAppearedText": [ + 32, + 18246 + ], + "WindowText": [ + 108, + 17568 + ], + "WobbuffetBackpic": [ + 88, + 25172 + ], + "WobbuffetFrontpic": [ + 79, + 26901 + ], + "WobbuffetPokedexEntry": [ + 116, + 17373 + ], + "WokeUpText": [ + 32, + 19706 + ], + "WontDropAnymoreText": [ + 32, + 21135 + ], + "WontObeyText": [ + 32, + 20292 + ], + "WontRiseAnymoreText": [ + 32, + 21106 + ], + "WooperBackpic": [ + 89, + 19239 + ], + "WooperFrontpic": [ + 85, + 30790 + ], + "WooperPokedexEntry": [ + 116, + 16504 + ], + "WrappedByText": [ + 32, + 20036 + ], + "WriteTileFromAnimBuffer": [ + 63, + 18070 + ], + "WrongNumber.script": [ + 36, + 16952 + ], + "XatuBackpic": [ + 87, + 20571 + ], + "XatuFrontpic": [ + 79, + 24508 + ], + "XatuPokedexEntry": [ + 115, + 21737 + ], + "YanmaBackpic": [ + 82, + 26224 + ], + "YanmaFrontpic": [ + 78, + 21149 + ], + "YanmaPokedexEntry": [ + 116, + 16384 + ], + "ZapdosBackpic": [ + 87, + 27161 + ], + "ZapdosFrontpic": [ + 73, + 27004 + ], + "ZapdosPokedexEntry": [ + 115, + 18071 + ], + "ZubatBackpic": [ + 83, + 27776 + ], + "ZubatFrontpic": [ + 81, + 28936 + ], + "ZubatPokedexEntry": [ + 96, + 26461 + ], + "_ActorNameText": [ + 112, + 19834 + ], + "_AlreadyASaveFileText": [ + 113, + 17827 + ], + "_AlreadySetUpText": [ + 111, + 17734 + ], + "_AlreadySurfingText": [ + 112, + 18004 + ], + "_AlreadyUsingStrengthText": [ + 112, + 18257 + ], + "_AnEggCantHoldAnItemText": [ + 112, + 19327 + ], + "_AnimationTypeText": [ + 112, + 20412 + ], + "_AnotherSaveFileText": [ + 113, + 17881 + ], + "_AreWeGeniusesText": [ + 111, + 23908 + ], + "_AreYouABoyOrAreYouAGirlText": [ + 112, + 19619 + ], + "_AskCutText": [ + 112, + 18909 + ], + "_AskDeleteMoveText": [ + 113, + 24282 + ], + "_AskFloorElevatorText": [ + 111, + 21180 + ], + "_AskForgetMoveText": [ + 113, + 22217 + ], + "_AskGiveNicknameText": [ + 113, + 23423 + ], + "_AskHeadbuttText": [ + 112, + 18620 + ], + "_AskItemMoveText": [ + 112, + 19555 + ], + "_AskQuantityThrowAwayText": [ + 112, + 19387 + ], + "_AskRockSmashText": [ + 112, + 18724 + ], + "_AskStrengthText": [ + 112, + 18336 + ], + "_AskSurfText": [ + 112, + 18028 + ], + "_AskThrowAwayText": [ + 112, + 19365 + ], + "_AskWaterfallText": [ + 112, + 18111 + ], + "_AskWhirlpoolText": [ + 112, + 18532 + ], + "_BGEventText": [ + 113, + 18172 + ], + "_BTExcuseMeText": [ + 113, + 22839 + ], + "_BackAlreadyText": [ + 111, + 24114 + ], + "_BadgeRequiredText": [ + 112, + 17831 + ], + "_BallAlmostHadItText": [ + 113, + 23267 + ], + "_BallAppearedCaughtText": [ + 113, + 23235 + ], + "_BallBlockedText": [ + 113, + 24016 + ], + "_BallBoxFullText": [ + 113, + 24122 + ], + "_BallBrokeFreeText": [ + 113, + 23206 + ], + "_BallDodgedText": [ + 113, + 23130 + ], + "_BallDontBeAThiefText": [ + 113, + 24047 + ], + "_BallMissedText": [ + 113, + 23184 + ], + "_BallSentToPCText": [ + 113, + 23352 + ], + "_BallSoCloseText": [ + 113, + 23290 + ], + "_BargainShopComeAgainText": [ + 113, + 20038 + ], + "_BargainShopFinalPriceText": [ + 113, + 19888 + ], + "_BargainShopIntroText": [ + 113, + 19783 + ], + "_BargainShopNoFundsText": [ + 113, + 20008 + ], + "_BargainShopPackFullText": [ + 113, + 19926 + ], + "_BargainShopSoldOutText": [ + 113, + 19959 + ], + "_BargainShopThanksText": [ + 113, + 19917 + ], + "_BattleDugText": [ + 112, + 19820 + ], + "_BattleFlewText": [ + 112, + 19804 + ], + "_BattleGlowingText": [ + 112, + 19790 + ], + "_BattleLoweredHeadText": [ + 112, + 19770 + ], + "_BattleMadeWhirlwindText": [ + 112, + 19730 + ], + "_BattleMonNickCommaText": [ + 112, + 17181 + ], + "_BattleMonNicknameText": [ + 112, + 17175 + ], + "_BattleStatFellText": [ + 112, + 19718 + ], + "_BattleStatSharplyFellText": [ + 112, + 19701 + ], + "_BattleStatWentUpText": [ + 112, + 19680 + ], + "_BattleStatWentWayUpText": [ + 112, + 19664 + ], + "_BattleTookSunlightText": [ + 112, + 19750 + ], + "_BattleTowerReturnWhenReadyText": [ + 113, + 22882 + ], + "_BenFernText1": [ + 111, + 19176 + ], + "_BenFernText2A": [ + 111, + 19190 + ], + "_BenFernText2B": [ + 111, + 19209 + ], + "_BenFernText3A": [ + 111, + 19227 + ], + "_BenFernText3B": [ + 111, + 19241 + ], + "_BenIntroText1": [ + 111, + 19097 + ], + "_BenIntroText2": [ + 111, + 19115 + ], + "_BenIntroText3": [ + 111, + 19126 + ], + "_BidsFarewellToMonText": [ + 111, + 18179 + ], + "_BlindingFlashText": [ + 112, + 17929 + ], + "_BlueCardBalanceText": [ + 113, + 23646 + ], + "_BoostedExpPointsText": [ + 112, + 17065 + ], + "_BootedHMText": [ + 112, + 17284 + ], + "_BootedTMText": [ + 112, + 17267 + ], + "_BouldersMayMoveText": [ + 112, + 18420 + ], + "_BouldersMoveText": [ + 112, + 18392 + ], + "_BreedAppearsToCareForText": [ + 112, + 20109 + ], + "_BreedAskNicknameText": [ + 112, + 19928 + ], + "_BreedBrimmingWithEnergyText": [ + 112, + 20052 + ], + "_BreedClearboxText": [ + 112, + 19896 + ], + "_BreedEggHatchText": [ + 112, + 19898 + ], + "_BreedFriendlyText": [ + 112, + 20140 + ], + "_BreedNoInterestText": [ + 112, + 20079 + ], + "_BreedShowsInterestText": [ + 112, + 20166 + ], + "_BreedingIsNotPossibleText": [ + 111, + 20669 + ], + "_BuenaAskWhichPrizeText": [ + 113, + 22687 + ], + "_BuenaComeAgainText": [ + 113, + 22810 + ], + "_BuenaHereYouGoText": [ + 113, + 22737 + ], + "_BuenaIsThatRightText": [ + 113, + 22716 + ], + "_BuenaNoRoomText": [ + 113, + 22783 + ], + "_BuenaNotEnoughPointsText": [ + 113, + 22752 + ], + "_BuenaOffTheAirText": [ + 111, + 20377 + ], + "_BuenaRadioMidnightText1": [ + 111, + 20203 + ], + "_BuenaRadioMidnightText10": [ + 111, + 20374 + ], + "_BuenaRadioMidnightText2": [ + 111, + 20219 + ], + "_BuenaRadioMidnightText3": [ + 111, + 20237 + ], + "_BuenaRadioMidnightText4": [ + 111, + 20258 + ], + "_BuenaRadioMidnightText5": [ + 111, + 20278 + ], + "_BuenaRadioMidnightText6": [ + 111, + 20299 + ], + "_BuenaRadioMidnightText7": [ + 111, + 20318 + ], + "_BuenaRadioMidnightText8": [ + 111, + 20339 + ], + "_BuenaRadioMidnightText9": [ + 111, + 20358 + ], + "_BuenaRadioText1": [ + 111, + 20082 + ], + "_BuenaRadioText2": [ + 111, + 20103 + ], + "_BuenaRadioText3": [ + 111, + 20122 + ], + "_BuenaRadioText4": [ + 111, + 20142 + ], + "_BuenaRadioText5": [ + 111, + 20151 + ], + "_BuenaRadioText6": [ + 111, + 20169 + ], + "_BuenaRadioText7": [ + 111, + 20188 + ], + "_BugCatchingContestIsOverText": [ + 111, + 21223 + ], + "_BugCatchingContestTimeUpText": [ + 111, + 21194 + ], + "_BurnWasHealedText": [ + 111, + 16618 + ], + "_ButNoSpaceText": [ + 111, + 21297 + ], + "_CGB_GSIntro.ShellderLaprasBGPalette": [ + 2, + 20814 + ], + "_CGB_GSIntro.ShellderLaprasOBPals": [ + 2, + 20822 + ], + "_CGB_GamefreakLogo.GamefreakDittoPalette": [ + 2, + 21793 + ], + "_CGB_PackPals.ChrisPackPals": [ + 2, + 21561 + ], + "_CGB_PackPals.KrisPackPals": [ + 2, + 21609 + ], + "_CameToItsSensesText": [ + 111, + 16750 + ], + "_CanCutText": [ + 112, + 18949 + ], + "_CantAcceptEggText": [ + 111, + 23736 + ], + "_CantCarryItemText": [ + 112, + 18988 + ], + "_CantGetOffBikeText": [ + 112, + 18842 + ], + "_CantRegisterText": [ + 112, + 19525 + ], + "_CantSurfText": [ + 112, + 17983 + ], + "_CantUseDigText": [ + 112, + 18181 + ], + "_CantUseItemText": [ + 112, + 17864 + ], + "_CantUseTeleportText": [ + 112, + 18235 + ], + "_CardFlipChooseACardText": [ + 113, + 22462 + ], + "_CardFlipDarnText": [ + 113, + 22554 + ], + "_CardFlipNotEnoughCoinsText": [ + 113, + 22443 + ], + "_CardFlipPlaceYourBetText": [ + 113, + 22478 + ], + "_CardFlipPlayAgainText": [ + 113, + 22495 + ], + "_CardFlipPlayWithThreeCoinsText": [ + 113, + 22419 + ], + "_CardFlipShuffledText": [ + 113, + 22516 + ], + "_CardFlipYeahText": [ + 113, + 22547 + ], + "_CardFolderOpenText": [ + 111, + 17032 + ], + "_CardWasListedText": [ + 111, + 20957 + ], + "_CaughtAskNicknameText": [ + 112, + 21244 + ], + "_ChangeBoxSaveText": [ + 113, + 17962 + ], + "_ChangeWhichNumberText": [ + 111, + 20574 + ], + "_CheckCDMAAdapterText": [ + 111, + 17340 + ], + "_CheckCellPhoneAdapterText": [ + 111, + 17313 + ], + "_CheckDDIPHSAdapterText": [ + 111, + 17388 + ], + "_CheckDOCOMOPHSAdapterText": [ + 111, + 17361 + ], + "_CheckMobileAdapterText": [ + 111, + 17412 + ], + "_ClearAllSaveDataText": [ + 113, + 22090 + ], + "_ClearTimeLimitText": [ + 111, + 21042 + ], + "_ClockHasResetText": [ + 113, + 16744 + ], + "_ClockIsThisOKText": [ + 113, + 16731 + ], + "_ClockSetWithControlPadText": [ + 113, + 16668 + ], + "_ClockTimeMayBeWrongText": [ + 113, + 16614 + ], + "_ClosingLinkText": [ + 111, + 21027 + ], + "_CoinCaseCountText": [ + 113, + 23675 + ], + "_ComeAgainText": [ + 111, + 24297 + ], + "_ComeBackLaterText": [ + 111, + 23883 + ], + "_ComeBackText": [ + 112, + 17254 + ], + "_CompatibilityShouldTheyBreedText": [ + 111, + 20696 + ], + "_ConfirmPasscodeText": [ + 111, + 16812 + ], + "_CongratulationsYourPokemonText": [ + 113, + 19346 + ], + "_ContainedMoveText": [ + 112, + 17302 + ], + "_ContestAlreadyCaughtText": [ + 112, + 20701 + ], + "_ContestAskSwitchText": [ + 112, + 20687 + ], + "_ContestCaughtMonText": [ + 112, + 20672 + ], + "_ContestJudging_FirstPlaceScoreText": [ + 112, + 20799 + ], + "_ContestJudging_FirstPlaceText": [ + 112, + 20730 + ], + "_ContestJudging_SecondPlaceScoreText": [ + 112, + 20886 + ], + "_ContestJudging_SecondPlaceText": [ + 112, + 20838 + ], + "_ContestJudging_ThirdPlaceScoreText": [ + 112, + 20964 + ], + "_ContestJudging_ThirdPlaceText": [ + 112, + 20917 + ], + "_CoordinatesEventText": [ + 113, + 18182 + ], + "_CorruptedEventText": [ + 113, + 18140 + ], + "_CuredOfPoisonText": [ + 111, + 16571 + ], + "_CutNothingText": [ + 112, + 17900 + ], + "_DSTIsThatOKText": [ + 113, + 24542 + ], + "_DayCareLadyIntroEggText": [ + 111, + 23483 + ], + "_DayCareLadyIntroText": [ + 111, + 23429 + ], + "_DayCareManIntroEggText": [ + 111, + 23256 + ], + "_DayCareManIntroText": [ + 111, + 23209 + ], + "_DaycareDummyText": [ + 111, + 23207 + ], + "_DeleteSavedLoginPasswordText": [ + 113, + 20886 + ], + "_DeletedTheLoginPasswordText": [ + 113, + 20921 + ], + "_DeleterAskWhichMonText": [ + 113, + 24529 + ], + "_DeleterAskWhichMoveText": [ + 113, + 24400 + ], + "_DeleterEggText": [ + 113, + 24343 + ], + "_DeleterForgotMoveText": [ + 113, + 24309 + ], + "_DeleterIntroText": [ + 113, + 24436 + ], + "_DeleterNoComeAgainText": [ + 113, + 24374 + ], + "_DidNotLearnMoveText": [ + 113, + 22191 + ], + "_DoItMonText": [ + 112, + 17126 + ], + "_DoesntConcernABoyText": [ + 111, + 20844 + ], + "_EggDoesNotQualifyText": [ + 113, + 22947 + ], + "_EggPhotoText": [ + 112, + 16417 + ], + "_EmptyMailboxText": [ + 112, + 20195 + ], + "_EndText": [ + 111, + 20813 + ], + "_EndUsedMove1Text": [ + 112, + 19873 + ], + "_EndUsedMove2Text": [ + 112, + 19876 + ], + "_EndUsedMove3Text": [ + 112, + 19879 + ], + "_EndUsedMove4Text": [ + 112, + 19882 + ], + "_EndUsedMove5Text": [ + 112, + 19885 + ], + "_EnemyUsedOnText": [ + 111, + 20399 + ], + "_EnemyWithdrewText": [ + 111, + 20380 + ], + "_EnterNewPasscodeText": [ + 111, + 16775 + ], + "_EnterPasscodeText": [ + 111, + 16977 + ], + "_EnterTheAmountText": [ + 111, + 17502 + ], + "_EnterTheIDNoText": [ + 111, + 17484 + ], + "_EvolvedIntoText": [ + 113, + 19375 + ], + "_EvolvingText": [ + 113, + 19427 + ], + "_ExcuseMeYoureNotReadyText": [ + 113, + 22852 + ], + "_ExpPointsText": [ + 112, + 17097 + ], + "_FernIntroText1": [ + 111, + 19144 + ], + "_FernIntroText2": [ + 111, + 19160 + ], + "_FluteWakeUpText": [ + 113, + 23592 + ], + "_ForABoyText": [ + 111, + 20819 + ], + "_ForAGirlText": [ + 111, + 20831 + ], + "_ForYourMonSendsText": [ + 111, + 18233 + ], + "_ForYourMonWillTradeText": [ + 111, + 18292 + ], + "_FoundAnEggText": [ + 111, + 24320 + ], + "_FoundItemText": [ + 112, + 18972 + ], + "_FourZerosInvalidText": [ + 111, + 16958 + ], + "_FruitBearingTreeText": [ + 111, + 16384 + ], + "_FruitPackIsFullText": [ + 111, + 16446 + ], + "_GearEllipseText": [ + 113, + 22564 + ], + "_GearOutOfServiceText": [ + 113, + 22567 + ], + "_GearTodayText": [ + 113, + 22561 + ], + "_GetFrontpic": [ + 20, + 20645 + ], + "_GoForItMonText": [ + 112, + 17136 + ], + "_GoMonText": [ + 112, + 17119 + ], + "_GoodComeBackText": [ + 112, + 17234 + ], + "_GotBackMonText": [ + 111, + 24095 + ], + "_GotOffBikeText": [ + 112, + 18887 + ], + "_GotOnBikeText": [ + 112, + 18866 + ], + "_GrewToLevelText": [ + 111, + 16719 + ], + "_HallOfFamePC.HOFMaster": [ + 33, + 26364 + ], + "_HallOfFamePC.TimeFamer": [ + 33, + 26380 + ], + "_HaveNoRoomText": [ + 111, + 24226 + ], + "_HeadbuttNothingText": [ + 112, + 18604 + ], + "_HealthReturnedText": [ + 111, + 16675 + ], + "_HerbShopLadyIntroText": [ + 113, + 19496 + ], + "_HerbalLadyComeAgainText": [ + 113, + 19754 + ], + "_HerbalLadyFinalPriceText": [ + 113, + 19630 + ], + "_HerbalLadyHowManyText": [ + 113, + 19619 + ], + "_HerbalLadyNoMoneyText": [ + 113, + 19720 + ], + "_HerbalLadyPackFullText": [ + 113, + 19690 + ], + "_HerbalLadyThanksText": [ + 113, + 19662 + ], + "_HeyItsFruitText": [ + 111, + 16412 + ], + "_HoldStillText": [ + 111, + 24647 + ], + "_HowManyShouldIMakeText": [ + 111, + 16521 + ], + "_HugeWaterfallText": [ + 112, + 18083 + ], + "_IllKeepItThanksText": [ + 111, + 24529 + ], + "_IllRaiseYourMonText": [ + 111, + 23856 + ], + "_IncorrectPasscodeText": [ + 111, + 17010 + ], + "_IsThisOKText": [ + 111, + 17471 + ], + "_ItemBelongsToSomeoneElseText": [ + 113, + 23959 + ], + "_ItemCantGetOnText": [ + 113, + 24093 + ], + "_ItemCantHeldText": [ + 112, + 23561 + ], + "_ItemCantUseOnEggText": [ + 113, + 23888 + ], + "_ItemCantUseOnMonText": [ + 113, + 23468 + ], + "_ItemGotOffText": [ + 113, + 24208 + ], + "_ItemGotOnText": [ + 113, + 24187 + ], + "_ItemLooksBitterText": [ + 113, + 23870 + ], + "_ItemOakWarningText": [ + 113, + 23918 + ], + "_ItemStatRoseText": [ + 113, + 23450 + ], + "_ItemStorageFullText": [ + 112, + 23466 + ], + "_ItemUsedText": [ + 113, + 24168 + ], + "_ItemWontHaveEffectText": [ + 113, + 23990 + ], + "_ItemfinderItemNearbyText": [ + 112, + 19063 + ], + "_ItemfinderNopeText": [ + 112, + 19113 + ], + "_ItemsDiscardedText": [ + 112, + 23242 + ], + "_ItemsOakWarningText": [ + 112, + 23299 + ], + "_ItemsThrowAwayText": [ + 112, + 23213 + ], + "_ItemsTooImportantText": [ + 112, + 23263 + ], + "_ItemsTossOutHowManyText": [ + 112, + 23184 + ], + "_ItsGoingToHatchText": [ + 111, + 20764 + ], + "_JustSawSomeRareMonText": [ + 111, + 21323 + ], + "_KarpGuruRecordText": [ + 112, + 21050 + ], + "_KnowsMoveText": [ + 113, + 24232 + ], + "_LC_DragText1": [ + 111, + 19473 + ], + "_LC_DragText2": [ + 111, + 19493 + ], + "_LC_Text1": [ + 111, + 19257 + ], + "_LC_Text10": [ + 111, + 19433 + ], + "_LC_Text11": [ + 111, + 19454 + ], + "_LC_Text2": [ + 111, + 19277 + ], + "_LC_Text3": [ + 111, + 19296 + ], + "_LC_Text4": [ + 111, + 19315 + ], + "_LC_Text5": [ + 111, + 19334 + ], + "_LC_Text6": [ + 111, + 19354 + ], + "_LC_Text7": [ + 111, + 19375 + ], + "_LC_Text8": [ + 111, + 19394 + ], + "_LC_Text9": [ + 111, + 19414 + ], + "_LastHealthyMonText": [ + 111, + 23807 + ], + "_LearnedMoveText": [ + 113, + 22112 + ], + "_LeftWithDayCareLadyText": [ + 112, + 19955 + ], + "_LeftWithDayCareManText": [ + 112, + 20004 + ], + "_LinkAbnormalMonText": [ + 113, + 16870 + ], + "_LinkAskTradeForText": [ + 113, + 16914 + ], + "_LinkTerminatedText": [ + 111, + 21009 + ], + "_LinkTimeoutText": [ + 113, + 16771 + ], + "_LinkTradeCantBattleText": [ + 113, + 16817 + ], + "_LookAdorableDecoText": [ + 111, + 17879 + ], + "_LookClefairyPosterText": [ + 111, + 17809 + ], + "_LookGiantDecoText": [ + 111, + 17903 + ], + "_LookJigglypuffPosterText": [ + 111, + 17843 + ], + "_LookPikachuPosterText": [ + 111, + 17776 + ], + "_LookTownMapText": [ + 111, + 17757 + ], + "_LuckyNumberMatchPCText": [ + 112, + 21166 + ], + "_LuckyNumberMatchPartyText": [ + 112, + 21089 + ], + "_MagikarpGuruMeasureText": [ + 112, + 20995 + ], + "_MailAlreadyHoldingItemText": [ + 112, + 20305 + ], + "_MailAskSendToPCText": [ + 112, + 23686 + ], + "_MailClearedPutAwayText": [ + 112, + 20217 + ], + "_MailDetachedText": [ + 112, + 23623 + ], + "_MailEggText": [ + 112, + 20337 + ], + "_MailLoseMessageText": [ + 112, + 23586 + ], + "_MailMessageLostText": [ + 112, + 20268 + ], + "_MailMovedFromBoxText": [ + 112, + 20365 + ], + "_MailNoSpaceText": [ + 112, + 23650 + ], + "_MailPackFullText": [ + 112, + 20249 + ], + "_MailSentToPCText": [ + 112, + 23748 + ], + "_MailboxFullText": [ + 112, + 23721 + ], + "_MainMenuTimeUnknownText": [ + 113, + 20866 + ], + "_MartAskMoreText": [ + 113, + 20493 + ], + "_MartBoughtText": [ + 113, + 20526 + ], + "_MartCantBuyText": [ + 113, + 20439 + ], + "_MartComeAgainText": [ + 113, + 20473 + ], + "_MartFinalPriceText": [ + 113, + 19464 + ], + "_MartHowManyText": [ + 113, + 19453 + ], + "_MartNoMoneyText": [ + 113, + 20378 + ], + "_MartPackFullText": [ + 113, + 20407 + ], + "_MartSellHowManyText": [ + 113, + 20275 + ], + "_MartSellPriceText": [ + 113, + 20286 + ], + "_MartThanksText": [ + 113, + 20352 + ], + "_MartWelcomeText": [ + 113, + 20322 + ], + "_MayPassWhirlpoolText": [ + 112, + 18475 + ], + "_MayRegisterItemText": [ + 112, + 23795 + ], + "_MaySmashText": [ + 112, + 18694 + ], + "_MemoryGameDarnText": [ + 112, + 23141 + ], + "_MemoryGameYeahText": [ + 112, + 23131 + ], + "_MobileBattleLessThanOneMinuteLeftText": [ + 113, + 17600 + ], + "_MobileBattleMoreInfoText": [ + 113, + 17013 + ], + "_MobileBattleMustPickThreeMonText": [ + 113, + 16938 + ], + "_MobileBattleNoTimeLeftForLinkingText": [ + 113, + 17639 + ], + "_MobileBattleRemainingTimeText": [ + 113, + 17701 + ], + "_MobileBattleRulesText": [ + 113, + 17048 + ], + "_MobileCardFolderAskDeleteOldText": [ + 113, + 21805 + ], + "_MobileCardFolderAskDeleteText": [ + 113, + 21486 + ], + "_MobileCardFolderAskOpenOldText": [ + 113, + 21725 + ], + "_MobileCardFolderDeleteAreYouSureText": [ + 113, + 21652 + ], + "_MobileCardFolderDeletedText": [ + 113, + 21689 + ], + "_MobileCardFolderFinishRegisteringCardsText": [ + 113, + 21834 + ], + "_MobileCardFolderIntro1Text": [ + 113, + 21048 + ], + "_MobileCardFolderIntro2Text": [ + 113, + 21180 + ], + "_MobileCardFolderIntro3Text": [ + 113, + 21278 + ], + "_MobileCardFolderIntro4Text": [ + 113, + 21396 + ], + "_MobileForPartnersMonText": [ + 111, + 18333 + ], + "_MobileOnlyThreeMonMayEnterText": [ + 113, + 21020 + ], + "_MobilePickThreeMonForBattleText": [ + 113, + 20951 + ], + "_MobilePlayerWillTradeMonText": [ + 111, + 18311 + ], + "_MobilePlayersMonTrade2Text": [ + 111, + 18397 + ], + "_MobilePlayersMonTradeText": [ + 111, + 18352 + ], + "_MobileStadiumEntryText": [ + 113, + 20674 + ], + "_MobileStadiumSuccessText": [ + 113, + 20769 + ], + "_MobileTakeGoodCareOfMonText": [ + 111, + 18371 + ], + "_MobileTakeGoodCareOfText": [ + 111, + 18416 + ], + "_MobileTradeCameBackText": [ + 111, + 18442 + ], + "_MobileUseTheseThreeMonText": [ + 113, + 20980 + ], + "_MomBankWhatDoYouWantToDoText": [ + 111, + 22850 + ], + "_MomBoughtWithYourMoneyText": [ + 111, + 17998 + ], + "_MomFoundADollText": [ + 111, + 18067 + ], + "_MomFoundAnItemText": [ + 111, + 17962 + ], + "_MomHaventSavedThatMuchText": [ + 111, + 22970 + ], + "_MomHiHowAreYouText": [ + 111, + 17941 + ], + "_MomInsufficientFundsInWalletText": [ + 111, + 23025 + ], + "_MomIsThisAboutYourMoneyText": [ + 111, + 22746 + ], + "_MomItsInPCText": [ + 111, + 18035 + ], + "_MomItsInYourRoomText": [ + 111, + 18119 + ], + "_MomJustDoWhatYouCanText": [ + 111, + 23184 + ], + "_MomLeavingText1": [ + 111, + 22399 + ], + "_MomLeavingText2": [ + 111, + 22632 + ], + "_MomLeavingText3": [ + 111, + 22670 + ], + "_MomLostGearBookletText": [ + 113, + 24785 + ], + "_MomNotEnoughRoomInBankText": [ + 111, + 23051 + ], + "_MomNotEnoughRoomInWalletText": [ + 111, + 22999 + ], + "_MomSaveMoneyText": [ + 111, + 22937 + ], + "_MomStartSavingMoneyText": [ + 111, + 23077 + ], + "_MomStoreMoneyText": [ + 111, + 22875 + ], + "_MomStoredMoneyText": [ + 111, + 23131 + ], + "_MomTakeMoneyText": [ + 111, + 22906 + ], + "_MomTakenMoneyText": [ + 111, + 23166 + ], + "_MonNameBidsFarewellText": [ + 111, + 18201 + ], + "_MonNameSentToText": [ + 111, + 18177 + ], + "_MonNumberText": [ + 112, + 20445 + ], + "_MonWasSentToText": [ + 111, + 18153 + ], + "_MoveAskForgetText": [ + 113, + 22136 + ], + "_MoveBoulderText": [ + 112, + 18312 + ], + "_MoveCantForgetHMText": [ + 113, + 22386 + ], + "_MoveForgotText": [ + 113, + 22350 + ], + "_MoveKnowsOneText": [ + 113, + 24250 + ], + "_MoveMonWOMailSaveText": [ + 113, + 18015 + ], + "_MoveNameText": [ + 112, + 19868 + ], + "_MysteryGiftCanceledText": [ + 112, + 17462 + ], + "_MysteryGiftCommErrorText": [ + 112, + 17492 + ], + "_MysteryGiftFiveADayText": [ + 112, + 17575 + ], + "_MysteryGiftOneADayText": [ + 112, + 17606 + ], + "_MysteryGiftSentHomeText": [ + 112, + 17658 + ], + "_MysteryGiftSentText": [ + 112, + 17641 + ], + "_NPCTradeAfterText1": [ + 111, + 21748 + ], + "_NPCTradeAfterText2": [ + 111, + 22004 + ], + "_NPCTradeAfterText3": [ + 111, + 22261 + ], + "_NPCTradeAfterText4": [ + 111, + 22341 + ], + "_NPCTradeCableText": [ + 111, + 21511 + ], + "_NPCTradeCancelText1": [ + 111, + 21644 + ], + "_NPCTradeCancelText2": [ + 111, + 21861 + ], + "_NPCTradeCancelText3": [ + 111, + 22131 + ], + "_NPCTradeCompleteText1": [ + 111, + 21714 + ], + "_NPCTradeCompleteText2": [ + 111, + 21964 + ], + "_NPCTradeCompleteText3": [ + 111, + 22221 + ], + "_NPCTradeCompleteText4": [ + 111, + 22321 + ], + "_NPCTradeFanfareText": [ + 111, + 21573 + ], + "_NPCTradeIntroText1": [ + 111, + 21577 + ], + "_NPCTradeIntroText2": [ + 111, + 21778 + ], + "_NPCTradeIntroText3": [ + 111, + 22049 + ], + "_NPCTradeWrongText1": [ + 111, + 21674 + ], + "_NPCTradeWrongText2": [ + 111, + 21921 + ], + "_NPCTradeWrongText3": [ + 111, + 22166 + ], + "_NameCardLinkCancelledText": [ + 112, + 17779 + ], + "_NameCardLinkCommErrorText": [ + 112, + 17809 + ], + "_NameCardListedCardText": [ + 112, + 17713 + ], + "_NameCardNotRegisteredCardText": [ + 112, + 17749 + ], + "_NameCardReceivedCardText": [ + 112, + 17690 + ], + "_NameRaterBetterNameText": [ + 112, + 16589 + ], + "_NameRaterComeAgainText": [ + 112, + 16798 + ], + "_NameRaterEggText": [ + 112, + 16904 + ], + "_NameRaterFinishedText": [ + 112, + 16753 + ], + "_NameRaterHelloText": [ + 112, + 16451 + ], + "_NameRaterNamedText": [ + 112, + 17010 + ], + "_NameRaterPerfectNameText": [ + 112, + 16830 + ], + "_NameRaterSameNameText": [ + 112, + 16930 + ], + "_NameRaterWhatNameText": [ + 112, + 16706 + ], + "_NameRaterWhichMonText": [ + 112, + 16544 + ], + "_NeedAtLeastThreeMonText": [ + 113, + 22915 + ], + "_NewCardArrivedText": [ + 111, + 20890 + ], + "_NewDexDataText": [ + 113, + 23379 + ], + "_NewPokedexEntry": [ + 16, + 23167 + ], + "_NoCoinCaseText": [ + 111, + 21483 + ], + "_NoCoinsText": [ + 111, + 21463 + ], + "_NoCyclingText": [ + 113, + 24065 + ], + "_NoPhotoText": [ + 112, + 16384 + ], + "_NoPromptText": [ + 112, + 20408 + ], + "_NoRoomForEggText": [ + 111, + 24562 + ], + "_NoRoomTMHMText": [ + 112, + 17402 + ], + "_NotEnoughMoneyText": [ + 111, + 24252 + ], + "_NotYetText": [ + 111, + 24310 + ], + "_NothingHereText": [ + 111, + 16469 + ], + "_NothingToChooseText": [ + 111, + 17521 + ], + "_NothingToPutAwayText": [ + 111, + 17644 + ], + "_NothingToSellText": [ + 113, + 20242 + ], + "_OKComeBackText": [ + 112, + 17216 + ], + "_OPT_AddictivelyText": [ + 111, + 18766 + ], + "_OPT_AlmostPoisonouslyText": [ + 111, + 18686 + ], + "_OPT_AptlyNamedText": [ + 111, + 18607 + ], + "_OPT_BoldSortOfText": [ + 111, + 18909 + ], + "_OPT_CuteText": [ + 111, + 18880 + ], + "_OPT_EvolutionMustBeText": [ + 111, + 18805 + ], + "_OPT_ExcitingText": [ + 111, + 18973 + ], + "_OPT_FlippedOutText": [ + 111, + 18841 + ], + "_OPT_FriendlyText": [ + 111, + 19008 + ], + "_OPT_FrighteningText": [ + 111, + 18926 + ], + "_OPT_GroovyText": [ + 111, + 18985 + ], + "_OPT_GuardedText": [ + 111, + 19052 + ], + "_OPT_HeartMeltinglyText": [ + 111, + 18862 + ], + "_OPT_HotHotHotText": [ + 111, + 19020 + ], + "_OPT_InspiringText": [ + 111, + 18995 + ], + "_OPT_IntroText1": [ + 111, + 18458 + ], + "_OPT_IntroText2": [ + 111, + 18477 + ], + "_OPT_IntroText3": [ + 111, + 18490 + ], + "_OPT_LooksInWaterText": [ + 111, + 18785 + ], + "_OPT_LovelyText": [ + 111, + 19063 + ], + "_OPT_MaryText1": [ + 111, + 18550 + ], + "_OPT_MischievouslyText": [ + 111, + 18727 + ], + "_OPT_OakText1": [ + 111, + 18507 + ], + "_OPT_OakText2": [ + 111, + 18520 + ], + "_OPT_OakText3": [ + 111, + 18541 + ], + "_OPT_PleasantText": [ + 111, + 18897 + ], + "_OPT_PokemonChannelText": [ + 111, + 19083 + ], + "_OPT_PowerfulText": [ + 111, + 18961 + ], + "_OPT_ProvocativelyText": [ + 111, + 18825 + ], + "_OPT_SensuallyText": [ + 111, + 18707 + ], + "_OPT_SpeedyText": [ + 111, + 19073 + ], + "_OPT_StimulatingText": [ + 111, + 19037 + ], + "_OPT_SuaveDebonairText": [ + 111, + 18941 + ], + "_OPT_SweetAdorablyText": [ + 111, + 18565 + ], + "_OPT_TopicallyText": [ + 111, + 18746 + ], + "_OPT_UnbearablyText": [ + 111, + 18646 + ], + "_OPT_UndeniablyKindOfText": [ + 111, + 18625 + ], + "_OPT_WeirdText": [ + 111, + 18888 + ], + "_OPT_WigglySlicklyText": [ + 111, + 18586 + ], + "_OPT_WowImpressivelyText": [ + 111, + 18666 + ], + "_OTSendsText": [ + 111, + 18252 + ], + "_OakPCText1": [ + 112, + 21781 + ], + "_OakPCText2": [ + 112, + 21811 + ], + "_OakPCText3": [ + 112, + 21843 + ], + "_OakPCText4": [ + 112, + 22954 + ], + "_OakRating01": [ + 112, + 21893 + ], + "_OakRating02": [ + 112, + 21925 + ], + "_OakRating03": [ + 112, + 21973 + ], + "_OakRating04": [ + 112, + 22033 + ], + "_OakRating05": [ + 112, + 22095 + ], + "_OakRating06": [ + 112, + 22156 + ], + "_OakRating07": [ + 112, + 22218 + ], + "_OakRating08": [ + 112, + 22281 + ], + "_OakRating09": [ + 112, + 22331 + ], + "_OakRating10": [ + 112, + 22387 + ], + "_OakRating11": [ + 112, + 22436 + ], + "_OakRating12": [ + 112, + 22496 + ], + "_OakRating13": [ + 112, + 22546 + ], + "_OakRating14": [ + 112, + 22611 + ], + "_OakRating15": [ + 112, + 22667 + ], + "_OakRating16": [ + 112, + 22716 + ], + "_OakRating17": [ + 112, + 22775 + ], + "_OakRating18": [ + 112, + 22834 + ], + "_OakRating19": [ + 112, + 22891 + ], + "_OakText1": [ + 112, + 23861 + ], + "_OakText2": [ + 112, + 23972 + ], + "_OakText3": [ + 112, + 24034 + ], + "_OakText4": [ + 112, + 24037 + ], + "_OakText5": [ + 112, + 24145 + ], + "_OakText6": [ + 113, + 16384 + ], + "_OakText7": [ + 113, + 16422 + ], + "_OakThisIsntTheTimeText": [ + 112, + 19438 + ], + "_OakTimeHoursQuestionMarkText": [ + 111, + 17157 + ], + "_OakTimeHowManyMinutesText": [ + 111, + 17160 + ], + "_OakTimeIsItText": [ + 111, + 17274 + ], + "_OakTimeMinutesQuestionMarkText": [ + 111, + 17187 + ], + "_OakTimeOversleptText": [ + 111, + 17190 + ], + "_OakTimeSoDarkText": [ + 111, + 17231 + ], + "_OakTimeWhatDayIsItText": [ + 111, + 17257 + ], + "_OakTimeWhatHoursText": [ + 111, + 17149 + ], + "_OakTimeWhatTimeIsItText": [ + 111, + 17131 + ], + "_OakTimeWhoaMinutesText": [ + 111, + 17179 + ], + "_OakTimeWokeUpText": [ + 111, + 17052 + ], + "_OakTimeYikesText": [ + 111, + 17206 + ], + "_ObjectEventText": [ + 113, + 18158 + ], + "_ObtainedFruitText": [ + 111, + 16429 + ], + "_ObtainedTheVoltorbBadgeText": [ + 111, + 21152 + ], + "_OhFineThenText": [ + 111, + 24281 + ], + "_OnlyOneMonText": [ + 111, + 23703 + ], + "_OnlyThreeMonMayBeEnteredText": [ + 113, + 22979 + ], + "_PCCantDepositLastMonText": [ + 112, + 20608 + ], + "_PCCantTakeText": [ + 112, + 20642 + ], + "_PCGottaHavePokemonText": [ + 112, + 20486 + ], + "_PCMonHoldingMailText": [ + 112, + 20523 + ], + "_PCNoSingleMonText": [ + 112, + 20578 + ], + "_PCWhatText": [ + 112, + 20516 + ], + "_PPIsMaxedOutText": [ + 113, + 23750 + ], + "_PPRestoredText": [ + 113, + 23793 + ], + "_PPsIncreasedText": [ + 113, + 23773 + ], + "_PackEmptyText": [ + 112, + 19587 + ], + "_PackNoItemText": [ + 112, + 19354 + ], + "_PasscodeSetText": [ + 111, + 16875 + ], + "_PasscodesNotSameText": [ + 111, + 16847 + ], + "_PasswordAskEnterText": [ + 113, + 22062 + ], + "_PasswordAskResetClockText": [ + 113, + 22044 + ], + "_PasswordAskResetText": [ + 113, + 21979 + ], + "_PasswordWrongText": [ + 113, + 22027 + ], + "_PerfectHeresYourMonText": [ + 111, + 24068 + ], + "_PharmacyComeAgainText": [ + 113, + 20214 + ], + "_PharmacyFinalPriceText": [ + 113, + 20105 + ], + "_PharmacyHowManyText": [ + 113, + 20094 + ], + "_PharmacyIntroText": [ + 113, + 20063 + ], + "_PharmacyNoMoneyText": [ + 113, + 20184 + ], + "_PharmacyPackFullText": [ + 113, + 20153 + ], + "_PharmacyThanksText": [ + 113, + 20139 + ], + "_PhoneClickText": [ + 113, + 21888 + ], + "_PhoneEllipseText": [ + 113, + 21896 + ], + "_PhoneJustTalkToThemText": [ + 113, + 21932 + ], + "_PhoneOutOfAreaText": [ + 113, + 21899 + ], + "_PhoneThankYouText": [ + 113, + 21962 + ], + "_PhoneWrongNumberText": [ + 113, + 21861 + ], + "_PickErrorPacketText": [ + 111, + 21094 + ], + "_PickThreeMonForMobileBattleText": [ + 113, + 17672 + ], + "_PlayedFluteText": [ + 113, + 23545 + ], + "_PlayerFoundItemText": [ + 111, + 21281 + ], + "_PlayersPCAskWhatDoText": [ + 112, + 21352 + ], + "_PlayersPCDepositItemsText": [ + 112, + 21521 + ], + "_PlayersPCHowManyDepositText": [ + 112, + 21487 + ], + "_PlayersPCHowManyWithdrawText": [ + 112, + 21377 + ], + "_PlayersPCNoItemsText": [ + 112, + 21471 + ], + "_PlayersPCNoRoomDepositText": [ + 112, + 21549 + ], + "_PlayersPCNoRoomWithdrawText": [ + 112, + 21439 + ], + "_PlayersPCTurnOnText": [ + 112, + 21331 + ], + "_PlayersPCWithdrewItemsText": [ + 112, + 21412 + ], + "_PleaseTryAgainTomorrowText": [ + 113, + 17498 + ], + "_PnP_BoldText": [ + 111, + 19668 + ], + "_PnP_CoolText": [ + 111, + 19778 + ], + "_PnP_CuteText": [ + 111, + 19584 + ], + "_PnP_GreatText": [ + 111, + 19738 + ], + "_PnP_HappyText": [ + 111, + 19614 + ], + "_PnP_InspiringText": [ + 111, + 19796 + ], + "_PnP_LazyText": [ + 111, + 19595 + ], + "_PnP_MyTypeText": [ + 111, + 19759 + ], + "_PnP_NoisyText": [ + 111, + 19633 + ], + "_PnP_OddText": [ + 111, + 19851 + ], + "_PnP_PickyText": [ + 111, + 19688 + ], + "_PnP_PrecociousText": [ + 111, + 19651 + ], + "_PnP_RightForMeText": [ + 111, + 19832 + ], + "_PnP_SoSoText": [ + 111, + 19721 + ], + "_PnP_SortOfOKText": [ + 111, + 19704 + ], + "_PnP_Text1": [ + 111, + 19514 + ], + "_PnP_Text2": [ + 111, + 19535 + ], + "_PnP_Text3": [ + 111, + 19555 + ], + "_PnP_Text4": [ + 111, + 19570 + ], + "_PnP_Text5": [ + 111, + 19872 + ], + "_PnP_WeirdText": [ + 111, + 19812 + ], + "_PocketIsFullText": [ + 113, + 18251 + ], + "_PoisonFaintText": [ + 112, + 19148 + ], + "_PoisonWhiteoutText": [ + 112, + 19162 + ], + "_PokecenterBillsPCText": [ + 112, + 21620 + ], + "_PokecenterOaksPCText": [ + 112, + 21714 + ], + "_PokecenterPCCantUseText": [ + 112, + 21288 + ], + "_PokecenterPCOaksClosedText": [ + 112, + 21765 + ], + "_PokecenterPCTurnOnText": [ + 112, + 21581 + ], + "_PokecenterPCWhoseText": [ + 112, + 21602 + ], + "_PokecenterPlayersPCText": [ + 112, + 21668 + ], + "_PokedexShowText": [ + 111, + 19089 + ], + "_PokegearAskDeleteText": [ + 113, + 22653 + ], + "_PokegearAskWhoCallText": [ + 113, + 22599 + ], + "_PokegearPressButtonText": [ + 113, + 22626 + ], + "_PokemonAskSwapItemText": [ + 112, + 23516 + ], + "_PokemonHoldItemText": [ + 112, + 23383 + ], + "_PokemonNotEnoughHPText": [ + 112, + 23779 + ], + "_PokemonNotHoldingText": [ + 112, + 23438 + ], + "_PokemonRemoveMailText": [ + 112, + 23407 + ], + "_PokemonSwapItemText": [ + 112, + 23340 + ], + "_PokemonTookItemText": [ + 112, + 23492 + ], + "_PrepMonFrontpic": [ + 0, + 14219 + ], + "_PrestoAllDoneText": [ + 111, + 24682 + ], + "_PutAwayAndSetUpText": [ + 111, + 17692 + ], + "_PutAwayTheDecoText": [ + 111, + 17623 + ], + "_PutCardInCardFolderText": [ + 111, + 20922 + ], + "_PutItemInPocketText": [ + 113, + 18220 + ], + "_RaiseThePPOfWhichMoveText": [ + 113, + 23690 + ], + "_ReceiveItemText": [ + 111, + 21438 + ], + "_ReceivedEggText": [ + 111, + 24485 + ], + "_ReceivedItemText": [ + 113, + 18201 + ], + "_ReceivedTMHMText": [ + 112, + 17441 + ], + "_RecoveredSomeHPText": [ + 111, + 16546 + ], + "_RegisteredItemText": [ + 112, + 19502 + ], + "_RemainingTimeText": [ + 111, + 20521 + ], + "_RemoveMailText": [ + 111, + 23770 + ], + "_RepelUsedEarlierIsStillInEffectText": [ + 113, + 23501 + ], + "_RepelWoreOffText": [ + 111, + 21256 + ], + "_RestoreThePPOfWhichMoveText": [ + 113, + 23719 + ], + "_RetrieveMysteryGiftText": [ + 112, + 17514 + ], + "_RevitalizedText": [ + 111, + 16698 + ], + "_RidOfParalysisText": [ + 111, + 16594 + ], + "_RocketRadioText1": [ + 111, + 19880 + ], + "_RocketRadioText10": [ + 111, + 20060 + ], + "_RocketRadioText2": [ + 111, + 19898 + ], + "_RocketRadioText3": [ + 111, + 19913 + ], + "_RocketRadioText4": [ + 111, + 19933 + ], + "_RocketRadioText5": [ + 111, + 19954 + ], + "_RocketRadioText6": [ + 111, + 19973 + ], + "_RocketRadioText7": [ + 111, + 19991 + ], + "_RocketRadioText8": [ + 111, + 20014 + ], + "_RocketRadioText9": [ + 111, + 20036 + ], + "_RodBiteText": [ + 112, + 18776 + ], + "_RodNothingText": [ + 112, + 18789 + ], + "_SaveFileCorruptedText": [ + 113, + 17933 + ], + "_SavedTheGameText": [ + 113, + 17808 + ], + "_SavingDontTurnOffThePowerText": [ + 113, + 17773 + ], + "_SavingRecordText": [ + 111, + 21406 + ], + "_SeerCantTellAThingText": [ + 113, + 18327 + ], + "_SeerDoNothingText": [ + 113, + 18773 + ], + "_SeerEggText": [ + 113, + 18717 + ], + "_SeerImpressedText": [ + 113, + 19173 + ], + "_SeerMightyText": [ + 113, + 19035 + ], + "_SeerMoreCareText": [ + 113, + 18810 + ], + "_SeerMoreConfidentText": [ + 113, + 18886 + ], + "_SeerMuchStrengthText": [ + 113, + 18977 + ], + "_SeerNameLocationText": [ + 113, + 18388 + ], + "_SeerNoLocationText": [ + 113, + 18559 + ], + "_SeerSeeAllText": [ + 113, + 18271 + ], + "_SeerTimeLevelText": [ + 113, + 18426 + ], + "_SeerTradeText": [ + 113, + 18487 + ], + "_SentTrophyHomeText": [ + 113, + 23811 + ], + "_SetUpTheDecoText": [ + 111, + 17673 + ], + "_SlotsBetHowManyCoinsText": [ + 113, + 20553 + ], + "_SlotsDarnText": [ + 113, + 20667 + ], + "_SlotsLinedUpText": [ + 113, + 20639 + ], + "_SlotsNotEnoughCoinsText": [ + 113, + 20582 + ], + "_SlotsPlayAgainText": [ + 113, + 20626 + ], + "_SlotsRanOutOfCoinsText": [ + 113, + 20601 + ], + "_SlotsStartText": [ + 113, + 20574 + ], + "_SpaceSpaceColonText": [ + 113, + 21974 + ], + "_SquirtbottleNothingText": [ + 112, + 19259 + ], + "_StartMenuContestEndText": [ + 112, + 23148 + ], + "_StartText": [ + 111, + 20805 + ], + "_StartingLinkText": [ + 111, + 20993 + ], + "_StopLearningMoveText": [ + 113, + 22169 + ], + "_StoppedEvolvingText": [ + 113, + 19397 + ], + "_SweetScentNothingText": [ + 112, + 19226 + ], + "_TMHMNotCompatibleText": [ + 112, + 17346 + ], + "_TakeGoodCareOfEggText": [ + 111, + 24506 + ], + "_TakeGoodCareOfMonText": [ + 111, + 18207 + ], + "_TeleportReturnText": [ + 112, + 18202 + ], + "_TestEventText": [ + 111, + 20785 + ], + "_ThatCantBeUsedRightNowText": [ + 111, + 20423 + ], + "_ThatItemCantBePutInThePackText": [ + 111, + 20453 + ], + "_ThatsEnoughComeBackText": [ + 112, + 17188 + ], + "_TheBoxIsFullText": [ + 111, + 20872 + ], + "_TheItemWasPutInThePackText": [ + 111, + 20489 + ], + "_TheMonMustAllBeDifferentKindsText": [ + 113, + 23013 + ], + "_TheMonMustNotHoldTheSameItemsText": [ + 113, + 23059 + ], + "_ThePasswordIsText": [ + 111, + 17452 + ], + "_ThereIsNoEggText": [ + 111, + 20745 + ], + "_ThereIsNothingConnectedText": [ + 111, + 17284 + ], + "_ThrewAwayText": [ + 112, + 19416 + ], + "_TimeAskOkayText": [ + 113, + 24561 + ], + "_TimeLimitWasClearedText": [ + 111, + 21065 + ], + "_TimesetAskAdjustDSTText": [ + 113, + 24725 + ], + "_TimesetAskDSTText": [ + 113, + 24576 + ], + "_TimesetAskNotDSTText": [ + 113, + 24662 + ], + "_TimesetDSTText": [ + 113, + 24624 + ], + "_TimesetNotDSTText": [ + 113, + 24693 + ], + "_TradingMonForOTMonText": [ + 111, + 21126 + ], + "_TrainerRankingExplanationText": [ + 112, + 22989 + ], + "_TrainerRankingNoDataText": [ + 112, + 23074 + ], + "_TryAgainUsingSameSettingsText": [ + 113, + 17564 + ], + "_UnusedNothingHereText": [ + 112, + 18809 + ], + "_UseCutText": [ + 112, + 17885 + ], + "_UseDigText": [ + 112, + 18142 + ], + "_UseEscapeRopeText": [ + 112, + 18157 + ], + "_UseHeadbuttText": [ + 112, + 18583 + ], + "_UseRockSmashText": [ + 112, + 18672 + ], + "_UseSacredAshText": [ + 112, + 19301 + ], + "_UseStrengthText": [ + 112, + 18292 + ], + "_UseSweetScentText": [ + 112, + 19203 + ], + "_UseWaterfallText": [ + 112, + 18062 + ], + "_UseWhirlpoolText": [ + 112, + 18454 + ], + "_UsedInsteadText": [ + 112, + 19856 + ], + "_UsedMove1Text": [ + 112, + 19838 + ], + "_UsedMove2Text": [ + 112, + 19847 + ], + "_UsedSurfText": [ + 112, + 17967 + ], + "_WaitButtonText": [ + 113, + 23349 + ], + "_WantAQuickMobileBattleText": [ + 113, + 17372 + ], + "_WantToRushThroughAMobileBattleText": [ + 113, + 17433 + ], + "_WarpingText": [ + 111, + 20564 + ], + "_WasDefrostedText": [ + 111, + 16641 + ], + "_WasSentToBillsPCText": [ + 112, + 20459 + ], + "_WhatShouldIRaiseText": [ + 111, + 23673 + ], + "_WhichApricornText": [ + 111, + 16491 + ], + "_WhichMonPhotoText": [ + 111, + 24612 + ], + "_WhichSidePutAwayText": [ + 111, + 17586 + ], + "_WhichSidePutOnText": [ + 111, + 17548 + ], + "_WhitedOutText": [ + 112, + 19022 + ], + "_WillTradeText": [ + 111, + 18270 + ], + "_WillYouPlayWithMonText": [ + 111, + 20607 + ], + "_WindowAreaExceededErrorText": [ + 113, + 18067 + ], + "_WindowPoppingErrorText": [ + 113, + 18103 + ], + "_WokeUpText": [ + 111, + 16661 + ], + "_WouldYouLikeToMobileBattleText": [ + 113, + 17308 + ], + "_WouldYouLikeToSaveTheGameText": [ + 113, + 17739 + ], + "_YesPromptText": [ + 112, + 20403 + ], + "_YouCantTakeAnEggText": [ + 113, + 23106 + ], + "_YouCantUseItInABattleText": [ + 112, + 19589 + ], + "_YouDontHaveAMonText": [ + 112, + 19479 + ], + "_YouNeedTwoMonForBreedingText": [ + 111, + 20634 + ], + "_YourFoesWeakGetmMonText": [ + 112, + 17150 + ], + "_YourFriendIsNotReadyText": [ + 112, + 17550 + ], + "_YourMonHasGrownText": [ + 111, + 23958 + ], + "_YourMonsHPWasHealedText": [ + 111, + 20537 + ], + "wBaseUnusedBackpic": [ + 1, + 53834 + ], + "wBaseUnusedFrontpic": [ + 1, + 53832 + ], + "wDecompressEnemyFrontpic": [ + 6, + 55296 + ] + }, + "text": { + "labels": [ + "AlreadyAsleepText", + "AlreadyConfusedText", + "AlreadyParalyzedText", + "AlreadyPoisonedText", + "AttackMissed2Text", + "AttackMissedText", + "BadlyPoisonedText", + "BattleText_AnEGGCantBattle", + "BattleText_CantEscape", + "BattleText_CantEscape2", + "BattleText_EnemyFled", + "BattleText_EnemyIsAboutToUseWillPlayerChangeMon", + "BattleText_EnemyMonFainted", + "BattleText_EnemySentOut", + "BattleText_EnemyWasDefeated", + "BattleText_GotAwaySafely", + "BattleText_ItemHealedConfusion", + "BattleText_ItemsCantBeUsedHere", + "BattleText_LinkErrorBattleCanceled", + "BattleText_MonCantBeRecalled", + "BattleText_MonFainted", + "BattleText_MonHasNoMovesLeft", + "BattleText_MonIsAlreadyOut", + "BattleText_MonsLightScreenFell", + "BattleText_MonsReflectFaded", + "BattleText_NoTimeLeftToday", + "BattleText_PlayerPickedUpPayDayMoney", + "BattleText_RainContinuesToFall", + "BattleText_SafeguardFaded", + "BattleText_StringBuffer1GrewToLevel", + "BattleText_TargetRecoveredWithItem", + "BattleText_TargetWasHitByFutureSight", + "BattleText_TargetsEncoreEnded", + "BattleText_TheMoveIsDisabled", + "BattleText_TheRainStopped", + "BattleText_TheSandstormRages", + "BattleText_TheSandstormSubsided", + "BattleText_TheSunlightFaded", + "BattleText_TheSunlightIsStrong", + "BattleText_TheresNoEscapeFromTrainerBattle", + "BattleText_TheresNoPPLeftForThisMove", + "BattleText_TheresNoWillToBattle", + "BattleText_UseNextMon", + "BattleText_UserFledUsingAStringBuffer1", + "BattleText_UserHurtBySpikes", + "BattleText_UserRecoveredPPUsing", + "BattleText_UserWasReleasedFromStringBuffer1", + "BattleText_UsersHurtByStringBuffer1", + "BattleText_UsersStringBuffer1Activated", + "BattleText_WildFled", + "BattleText_WildMonIsAngry", + "BattleText_WildMonIsEating", + "BeatUpAttackText", + "BecameConfusedText", + "BeganToNapText", + "BellChimedText", + "BellyDrumText", + "BlewSpikesText", + "BlownAwayText", + "BracedItselfText", + "BugContestPrizeNoRoomText", + "ButItFailedText", + "CantEscapeNowText", + "ClampedByText", + "CoinVendor_Buy500CoinsText", + "CoinVendor_Buy50CoinsText", + "CoinVendor_CancelText", + "CoinVendor_CoinCaseFullText", + "CoinVendor_IntroText", + "CoinVendor_NoCoinCaseText", + "CoinVendor_NotEnoughMoneyText", + "CoinVendor_WelcomeText", + "CoinsScatteredText", + "ConfusedNoMoreText", + "ContestResults_ConsolationPrizeText", + "ContestResults_DidNotWinText", + "ContestResults_JoinUsNextTimeText", + "ContestResults_PartyFullText", + "ContestResults_PlayerWonAPrizeText", + "ContestResults_ReadyToJudgeText", + "ContestResults_ReturnPartyText", + "CopiedStatsText", + "CoveredByVeilText", + "CrashedText", + "CriticalHitText", + "DefrostedOpponentText", + "DestinyBondEffectText", + "DidntAffect1Text", + "DidntAffect2Text", + "DifficultBookshelfText", + "DisabledMoveText", + "DisabledNoMoreText", + "DoesntAffectText", + "DownpourText", + "DraggedOutText", + "DreamEatenText", + "EliminatedStatsText", + "EnduredText", + "EnemyHitTimesText", + "EvadedText", + "FastAsleepText", + "FellAsleepText", + "FellInLoveText", + "FireSpinTrapText", + "FledFromBattleText", + "FledInFearText", + "FlinchedText", + "ForesawAttackText", + "FrozenSolidText", + "FullyParalyzedText", + "GettingPumpedText", + "GotAnEncoreText", + "GotMoneyForWinningText", + "GymStatue_CityGymText", + "GymStatue_WinningTrainersText", + "HPIsFullText", + "HappinessText1", + "HappinessText2", + "HappinessText3", + "HasANightmareText", + "HasNoPPLeftText", + "HasSubstituteText", + "HomepageText", + "HookedPokemonAttackedText", + "HungOnText", + "HurtByBurnText", + "HurtByCurseText", + "HurtByPoisonText", + "HurtItselfText", + "IdentifiedText", + "IgnoredOrders2Text", + "IgnoredOrdersText", + "IgnoredSleepingText", + "InLoveWithText", + "IncenseBurnerText", + "InfatuationText", + "IsConfusedText", + "ItFailedText", + "LeechSeedSapsText", + "LightScreenEffectText", + "LoafingAroundText", + "LookTownMapText", + "LostAgainstText", + "MadeSubstituteText", + "MagazineBookshelfText", + "MagnitudeText", + "MartSignText", + "MerchandiseShelfText", + "MimicLearnedMoveText", + "MirrorMoveFailedText", + "MistText", + "MustRechargeText", + "NoPPLeftText", + "NotVeryEffectiveText", + "NothingHappenedText", + "NurseAskHealText", + "NurseDayText", + "NurseGoodbyeText", + "NurseMornText", + "NurseNiteText", + "NursePokerusText", + "NurseReturnPokemonText", + "NurseTakePokemonText", + "OneHitKOText", + "ParalyzedText", + "PerishCountText", + "PictureBookshelfText", + "PlayerHitTimesText", + "PokeComNurseDayText", + "PokeComNurseMornText", + "PokeComNurseNiteText", + "PokeComNursePokerusText", + "PokecenterSignText", + "PokemonFellFromTreeText", + "PresentFailedText", + "ProtectedByMistText", + "ProtectedByText", + "ProtectedItselfText", + "ProtectingItselfText", + "PutACurseText", + "RageBuildingText", + "ReceivedItemText", + "RecoilText", + "RecoveredUsingText", + "ReflectEffectText", + "RegainedHealthText", + "RegisteredNumber1Text", + "RegisteredNumber2Text", + "ReleasedByText", + "RestedText", + "SafeguardProtectText", + "SandstormBrewedText", + "SandstormHitsText", + "SentAllToMomText", + "SentHalfToMomText", + "SentSomeToMomText", + "SharedPainText", + "ShedLeechSeedText", + "SketchedText", + "SpikesText", + "SpiteEffectText", + "StartPerishText", + "StartedNightmareText", + "StoleText", + "StoringEnergyText", + "SubFadedText", + "SubTookDamageText", + "SuckedHealthText", + "SunGotBrightText", + "SuperEffectiveText", + "TVText", + "TeamRocketOathText", + "Text_BallCaught", + "Text_BattleEffectActivate", + "Text_BattleFoeEffectActivate", + "Text_BattleUser", + "Text_BreedHuh", + "Text_Gained", + "Text_MoveForgetCount", + "Text_NPCTraded", + "Text_PlayedPokeFlute", + "TiedAgainstText", + "TooWeakSubText", + "TookAimText", + "TookDownWithItText", + "TransformedText", + "TransformedTypeText", + "TrashCanText", + "TurnedAwayText", + "UnaffectedText", + "UnleashedEnergyText", + "UnusedRivalLossText", + "UnusedRivalWinText", + "UsedBindText", + "WantsToBattleText", + "WasBurnedText", + "WasDefrostedText", + "WasDisabledText", + "WasFrozenText", + "WasPoisonedText", + "WasSeededText", + "WentToSleepText", + "WhirlpoolTrapText", + "WildCelebiAppearedText", + "WildPokemonAppearedText", + "WindowText", + "WokeUpText", + "WontDropAnymoreText", + "WontObeyText", + "WontRiseAnymoreText", + "WrappedByText", + "_ActorNameText", + "_AlreadyASaveFileText", + "_AlreadySetUpText", + "_AlreadySurfingText", + "_AlreadyUsingStrengthText", + "_AnEggCantHoldAnItemText", + "_AnimationTypeText", + "_AnotherSaveFileText", + "_AreWeGeniusesText", + "_AreYouABoyOrAreYouAGirlText", + "_AskCutText", + "_AskDeleteMoveText", + "_AskFloorElevatorText", + "_AskForgetMoveText", + "_AskGiveNicknameText", + "_AskHeadbuttText", + "_AskItemMoveText", + "_AskQuantityThrowAwayText", + "_AskRockSmashText", + "_AskStrengthText", + "_AskSurfText", + "_AskThrowAwayText", + "_AskWaterfallText", + "_AskWhirlpoolText", + "_BGEventText", + "_BTExcuseMeText", + "_BackAlreadyText", + "_BadgeRequiredText", + "_BallAlmostHadItText", + "_BallAppearedCaughtText", + "_BallBlockedText", + "_BallBoxFullText", + "_BallBrokeFreeText", + "_BallDodgedText", + "_BallDontBeAThiefText", + "_BallMissedText", + "_BallSentToPCText", + "_BallSoCloseText", + "_BargainShopComeAgainText", + "_BargainShopFinalPriceText", + "_BargainShopIntroText", + "_BargainShopNoFundsText", + "_BargainShopPackFullText", + "_BargainShopSoldOutText", + "_BargainShopThanksText", + "_BattleDugText", + "_BattleFlewText", + "_BattleGlowingText", + "_BattleLoweredHeadText", + "_BattleMadeWhirlwindText", + "_BattleMonNickCommaText", + "_BattleMonNicknameText", + "_BattleStatFellText", + "_BattleStatSharplyFellText", + "_BattleStatWentUpText", + "_BattleStatWentWayUpText", + "_BattleTookSunlightText", + "_BattleTowerReturnWhenReadyText", + "_BenFernText1", + "_BenFernText2A", + "_BenFernText2B", + "_BenFernText3A", + "_BenFernText3B", + "_BenIntroText1", + "_BenIntroText2", + "_BenIntroText3", + "_BidsFarewellToMonText", + "_BlindingFlashText", + "_BlueCardBalanceText", + "_BoostedExpPointsText", + "_BootedHMText", + "_BootedTMText", + "_BouldersMayMoveText", + "_BouldersMoveText", + "_BreedAppearsToCareForText", + "_BreedAskNicknameText", + "_BreedBrimmingWithEnergyText", + "_BreedClearboxText", + "_BreedEggHatchText", + "_BreedFriendlyText", + "_BreedNoInterestText", + "_BreedShowsInterestText", + "_BreedingIsNotPossibleText", + "_BuenaAskWhichPrizeText", + "_BuenaComeAgainText", + "_BuenaHereYouGoText", + "_BuenaIsThatRightText", + "_BuenaNoRoomText", + "_BuenaNotEnoughPointsText", + "_BuenaOffTheAirText", + "_BuenaRadioMidnightText1", + "_BuenaRadioMidnightText10", + "_BuenaRadioMidnightText2", + "_BuenaRadioMidnightText3", + "_BuenaRadioMidnightText4", + "_BuenaRadioMidnightText5", + "_BuenaRadioMidnightText6", + "_BuenaRadioMidnightText7", + "_BuenaRadioMidnightText8", + "_BuenaRadioMidnightText9", + "_BuenaRadioText1", + "_BuenaRadioText2", + "_BuenaRadioText3", + "_BuenaRadioText4", + "_BuenaRadioText5", + "_BuenaRadioText6", + "_BuenaRadioText7", + "_BugCatchingContestIsOverText", + "_BugCatchingContestTimeUpText", + "_BurnWasHealedText", + "_ButNoSpaceText", + "_CameToItsSensesText", + "_CanCutText", + "_CantAcceptEggText", + "_CantCarryItemText", + "_CantGetOffBikeText", + "_CantRegisterText", + "_CantSurfText", + "_CantUseDigText", + "_CantUseItemText", + "_CantUseTeleportText", + "_CardFlipChooseACardText", + "_CardFlipDarnText", + "_CardFlipNotEnoughCoinsText", + "_CardFlipPlaceYourBetText", + "_CardFlipPlayAgainText", + "_CardFlipPlayWithThreeCoinsText", + "_CardFlipShuffledText", + "_CardFlipYeahText", + "_CardFolderOpenText", + "_CardWasListedText", + "_CaughtAskNicknameText", + "_ChangeBoxSaveText", + "_ChangeWhichNumberText", + "_CheckCDMAAdapterText", + "_CheckCellPhoneAdapterText", + "_CheckDDIPHSAdapterText", + "_CheckDOCOMOPHSAdapterText", + "_CheckMobileAdapterText", + "_ClearAllSaveDataText", + "_ClearTimeLimitText", + "_ClockHasResetText", + "_ClockIsThisOKText", + "_ClockSetWithControlPadText", + "_ClockTimeMayBeWrongText", + "_ClosingLinkText", + "_CoinCaseCountText", + "_ComeAgainText", + "_ComeBackLaterText", + "_ComeBackText", + "_CompatibilityShouldTheyBreedText", + "_ConfirmPasscodeText", + "_CongratulationsYourPokemonText", + "_ContainedMoveText", + "_ContestAlreadyCaughtText", + "_ContestAskSwitchText", + "_ContestCaughtMonText", + "_ContestJudging_FirstPlaceScoreText", + "_ContestJudging_FirstPlaceText", + "_ContestJudging_SecondPlaceScoreText", + "_ContestJudging_SecondPlaceText", + "_ContestJudging_ThirdPlaceScoreText", + "_ContestJudging_ThirdPlaceText", + "_CoordinatesEventText", + "_CorruptedEventText", + "_CuredOfPoisonText", + "_CutNothingText", + "_DSTIsThatOKText", + "_DayCareLadyIntroEggText", + "_DayCareLadyIntroText", + "_DayCareManIntroEggText", + "_DayCareManIntroText", + "_DaycareDummyText", + "_DeleteSavedLoginPasswordText", + "_DeletedTheLoginPasswordText", + "_DeleterAskWhichMonText", + "_DeleterAskWhichMoveText", + "_DeleterEggText", + "_DeleterForgotMoveText", + "_DeleterIntroText", + "_DeleterNoComeAgainText", + "_DidNotLearnMoveText", + "_DoItMonText", + "_DoesntConcernABoyText", + "_EggDoesNotQualifyText", + "_EggPhotoText", + "_EmptyMailboxText", + "_EndText", + "_EndUsedMove1Text", + "_EndUsedMove2Text", + "_EndUsedMove3Text", + "_EndUsedMove4Text", + "_EndUsedMove5Text", + "_EnemyUsedOnText", + "_EnemyWithdrewText", + "_EnterNewPasscodeText", + "_EnterPasscodeText", + "_EnterTheAmountText", + "_EnterTheIDNoText", + "_EvolvedIntoText", + "_EvolvingText", + "_ExcuseMeYoureNotReadyText", + "_ExpPointsText", + "_FernIntroText1", + "_FernIntroText2", + "_FluteWakeUpText", + "_ForABoyText", + "_ForAGirlText", + "_ForYourMonSendsText", + "_ForYourMonWillTradeText", + "_FoundAnEggText", + "_FoundItemText", + "_FourZerosInvalidText", + "_FruitBearingTreeText", + "_FruitPackIsFullText", + "_GearEllipseText", + "_GearOutOfServiceText", + "_GearTodayText", + "_GoForItMonText", + "_GoMonText", + "_GoodComeBackText", + "_GotBackMonText", + "_GotOffBikeText", + "_GotOnBikeText", + "_GrewToLevelText", + "_HaveNoRoomText", + "_HeadbuttNothingText", + "_HealthReturnedText", + "_HerbShopLadyIntroText", + "_HerbalLadyComeAgainText", + "_HerbalLadyFinalPriceText", + "_HerbalLadyHowManyText", + "_HerbalLadyNoMoneyText", + "_HerbalLadyPackFullText", + "_HerbalLadyThanksText", + "_HeyItsFruitText", + "_HoldStillText", + "_HowManyShouldIMakeText", + "_HugeWaterfallText", + "_IllKeepItThanksText", + "_IllRaiseYourMonText", + "_IncorrectPasscodeText", + "_IsThisOKText", + "_ItemBelongsToSomeoneElseText", + "_ItemCantGetOnText", + "_ItemCantHeldText", + "_ItemCantUseOnEggText", + "_ItemCantUseOnMonText", + "_ItemGotOffText", + "_ItemGotOnText", + "_ItemLooksBitterText", + "_ItemOakWarningText", + "_ItemStatRoseText", + "_ItemStorageFullText", + "_ItemUsedText", + "_ItemWontHaveEffectText", + "_ItemfinderItemNearbyText", + "_ItemfinderNopeText", + "_ItemsDiscardedText", + "_ItemsOakWarningText", + "_ItemsThrowAwayText", + "_ItemsTooImportantText", + "_ItemsTossOutHowManyText", + "_ItsGoingToHatchText", + "_JustSawSomeRareMonText", + "_KarpGuruRecordText", + "_KnowsMoveText", + "_LC_DragText1", + "_LC_DragText2", + "_LC_Text1", + "_LC_Text10", + "_LC_Text11", + "_LC_Text2", + "_LC_Text3", + "_LC_Text4", + "_LC_Text5", + "_LC_Text6", + "_LC_Text7", + "_LC_Text8", + "_LC_Text9", + "_LastHealthyMonText", + "_LearnedMoveText", + "_LeftWithDayCareLadyText", + "_LeftWithDayCareManText", + "_LinkAbnormalMonText", + "_LinkAskTradeForText", + "_LinkTerminatedText", + "_LinkTimeoutText", + "_LinkTradeCantBattleText", + "_LookAdorableDecoText", + "_LookClefairyPosterText", + "_LookGiantDecoText", + "_LookJigglypuffPosterText", + "_LookPikachuPosterText", + "_LookTownMapText", + "_LuckyNumberMatchPCText", + "_LuckyNumberMatchPartyText", + "_MagikarpGuruMeasureText", + "_MailAlreadyHoldingItemText", + "_MailAskSendToPCText", + "_MailClearedPutAwayText", + "_MailDetachedText", + "_MailEggText", + "_MailLoseMessageText", + "_MailMessageLostText", + "_MailMovedFromBoxText", + "_MailNoSpaceText", + "_MailPackFullText", + "_MailSentToPCText", + "_MailboxFullText", + "_MainMenuTimeUnknownText", + "_MartAskMoreText", + "_MartBoughtText", + "_MartCantBuyText", + "_MartComeAgainText", + "_MartFinalPriceText", + "_MartHowManyText", + "_MartNoMoneyText", + "_MartPackFullText", + "_MartSellHowManyText", + "_MartSellPriceText", + "_MartThanksText", + "_MartWelcomeText", + "_MayPassWhirlpoolText", + "_MayRegisterItemText", + "_MaySmashText", + "_MemoryGameDarnText", + "_MemoryGameYeahText", + "_MobileBattleLessThanOneMinuteLeftText", + "_MobileBattleMoreInfoText", + "_MobileBattleMustPickThreeMonText", + "_MobileBattleNoTimeLeftForLinkingText", + "_MobileBattleRemainingTimeText", + "_MobileBattleRulesText", + "_MobileCardFolderAskDeleteOldText", + "_MobileCardFolderAskDeleteText", + "_MobileCardFolderAskOpenOldText", + "_MobileCardFolderDeleteAreYouSureText", + "_MobileCardFolderDeletedText", + "_MobileCardFolderFinishRegisteringCardsText", + "_MobileCardFolderIntro1Text", + "_MobileCardFolderIntro2Text", + "_MobileCardFolderIntro3Text", + "_MobileCardFolderIntro4Text", + "_MobileForPartnersMonText", + "_MobileOnlyThreeMonMayEnterText", + "_MobilePickThreeMonForBattleText", + "_MobilePlayerWillTradeMonText", + "_MobilePlayersMonTrade2Text", + "_MobilePlayersMonTradeText", + "_MobileStadiumEntryText", + "_MobileStadiumSuccessText", + "_MobileTakeGoodCareOfMonText", + "_MobileTakeGoodCareOfText", + "_MobileTradeCameBackText", + "_MobileUseTheseThreeMonText", + "_MomBankWhatDoYouWantToDoText", + "_MomBoughtWithYourMoneyText", + "_MomFoundADollText", + "_MomFoundAnItemText", + "_MomHaventSavedThatMuchText", + "_MomHiHowAreYouText", + "_MomInsufficientFundsInWalletText", + "_MomIsThisAboutYourMoneyText", + "_MomItsInPCText", + "_MomItsInYourRoomText", + "_MomJustDoWhatYouCanText", + "_MomLeavingText1", + "_MomLeavingText2", + "_MomLeavingText3", + "_MomLostGearBookletText", + "_MomNotEnoughRoomInBankText", + "_MomNotEnoughRoomInWalletText", + "_MomSaveMoneyText", + "_MomStartSavingMoneyText", + "_MomStoreMoneyText", + "_MomStoredMoneyText", + "_MomTakeMoneyText", + "_MomTakenMoneyText", + "_MonNameBidsFarewellText", + "_MonNameSentToText", + "_MonNumberText", + "_MonWasSentToText", + "_MoveAskForgetText", + "_MoveBoulderText", + "_MoveCantForgetHMText", + "_MoveForgotText", + "_MoveKnowsOneText", + "_MoveMonWOMailSaveText", + "_MoveNameText", + "_MysteryGiftCanceledText", + "_MysteryGiftCommErrorText", + "_MysteryGiftFiveADayText", + "_MysteryGiftOneADayText", + "_MysteryGiftSentHomeText", + "_MysteryGiftSentText", + "_NPCTradeAfterText1", + "_NPCTradeAfterText2", + "_NPCTradeAfterText3", + "_NPCTradeAfterText4", + "_NPCTradeCableText", + "_NPCTradeCancelText1", + "_NPCTradeCancelText2", + "_NPCTradeCancelText3", + "_NPCTradeCompleteText1", + "_NPCTradeCompleteText2", + "_NPCTradeCompleteText3", + "_NPCTradeCompleteText4", + "_NPCTradeFanfareText", + "_NPCTradeIntroText1", + "_NPCTradeIntroText2", + "_NPCTradeIntroText3", + "_NPCTradeWrongText1", + "_NPCTradeWrongText2", + "_NPCTradeWrongText3", + "_NameCardLinkCancelledText", + "_NameCardLinkCommErrorText", + "_NameCardListedCardText", + "_NameCardNotRegisteredCardText", + "_NameCardReceivedCardText", + "_NameRaterBetterNameText", + "_NameRaterComeAgainText", + "_NameRaterEggText", + "_NameRaterFinishedText", + "_NameRaterHelloText", + "_NameRaterNamedText", + "_NameRaterPerfectNameText", + "_NameRaterSameNameText", + "_NameRaterWhatNameText", + "_NameRaterWhichMonText", + "_NeedAtLeastThreeMonText", + "_NewCardArrivedText", + "_NewDexDataText", + "_NoCoinCaseText", + "_NoCoinsText", + "_NoCyclingText", + "_NoPhotoText", + "_NoPromptText", + "_NoRoomForEggText", + "_NoRoomTMHMText", + "_NotEnoughMoneyText", + "_NotYetText", + "_NothingHereText", + "_NothingToChooseText", + "_NothingToPutAwayText", + "_NothingToSellText", + "_OKComeBackText", + "_OPT_AddictivelyText", + "_OPT_AlmostPoisonouslyText", + "_OPT_AptlyNamedText", + "_OPT_BoldSortOfText", + "_OPT_CuteText", + "_OPT_EvolutionMustBeText", + "_OPT_ExcitingText", + "_OPT_FlippedOutText", + "_OPT_FriendlyText", + "_OPT_FrighteningText", + "_OPT_GroovyText", + "_OPT_GuardedText", + "_OPT_HeartMeltinglyText", + "_OPT_HotHotHotText", + "_OPT_InspiringText", + "_OPT_IntroText1", + "_OPT_IntroText2", + "_OPT_IntroText3", + "_OPT_LooksInWaterText", + "_OPT_LovelyText", + "_OPT_MaryText1", + "_OPT_MischievouslyText", + "_OPT_OakText1", + "_OPT_OakText2", + "_OPT_OakText3", + "_OPT_PleasantText", + "_OPT_PokemonChannelText", + "_OPT_PowerfulText", + "_OPT_ProvocativelyText", + "_OPT_SensuallyText", + "_OPT_SpeedyText", + "_OPT_StimulatingText", + "_OPT_SuaveDebonairText", + "_OPT_SweetAdorablyText", + "_OPT_TopicallyText", + "_OPT_UnbearablyText", + "_OPT_UndeniablyKindOfText", + "_OPT_WeirdText", + "_OPT_WigglySlicklyText", + "_OPT_WowImpressivelyText", + "_OTSendsText", + "_OakPCText1", + "_OakPCText2", + "_OakPCText3", + "_OakPCText4", + "_OakRating01", + "_OakRating02", + "_OakRating03", + "_OakRating04", + "_OakRating05", + "_OakRating06", + "_OakRating07", + "_OakRating08", + "_OakRating09", + "_OakRating10", + "_OakRating11", + "_OakRating12", + "_OakRating13", + "_OakRating14", + "_OakRating15", + "_OakRating16", + "_OakRating17", + "_OakRating18", + "_OakRating19", + "_OakText1", + "_OakText2", + "_OakText3", + "_OakText4", + "_OakText5", + "_OakText6", + "_OakText7", + "_OakThisIsntTheTimeText", + "_OakTimeHoursQuestionMarkText", + "_OakTimeHowManyMinutesText", + "_OakTimeIsItText", + "_OakTimeMinutesQuestionMarkText", + "_OakTimeOversleptText", + "_OakTimeSoDarkText", + "_OakTimeWhatDayIsItText", + "_OakTimeWhatHoursText", + "_OakTimeWhatTimeIsItText", + "_OakTimeWhoaMinutesText", + "_OakTimeWokeUpText", + "_OakTimeYikesText", + "_ObjectEventText", + "_ObtainedFruitText", + "_ObtainedTheVoltorbBadgeText", + "_OhFineThenText", + "_OnlyOneMonText", + "_OnlyThreeMonMayBeEnteredText", + "_PCCantDepositLastMonText", + "_PCCantTakeText", + "_PCGottaHavePokemonText", + "_PCMonHoldingMailText", + "_PCNoSingleMonText", + "_PCWhatText", + "_PPIsMaxedOutText", + "_PPRestoredText", + "_PPsIncreasedText", + "_PackEmptyText", + "_PackNoItemText", + "_PasscodeSetText", + "_PasscodesNotSameText", + "_PasswordAskEnterText", + "_PasswordAskResetClockText", + "_PasswordAskResetText", + "_PasswordWrongText", + "_PerfectHeresYourMonText", + "_PharmacyComeAgainText", + "_PharmacyFinalPriceText", + "_PharmacyHowManyText", + "_PharmacyIntroText", + "_PharmacyNoMoneyText", + "_PharmacyPackFullText", + "_PharmacyThanksText", + "_PhoneClickText", + "_PhoneEllipseText", + "_PhoneJustTalkToThemText", + "_PhoneOutOfAreaText", + "_PhoneThankYouText", + "_PhoneWrongNumberText", + "_PickErrorPacketText", + "_PickThreeMonForMobileBattleText", + "_PlayedFluteText", + "_PlayerFoundItemText", + "_PlayersPCAskWhatDoText", + "_PlayersPCDepositItemsText", + "_PlayersPCHowManyDepositText", + "_PlayersPCHowManyWithdrawText", + "_PlayersPCNoItemsText", + "_PlayersPCNoRoomDepositText", + "_PlayersPCNoRoomWithdrawText", + "_PlayersPCTurnOnText", + "_PlayersPCWithdrewItemsText", + "_PleaseTryAgainTomorrowText", + "_PnP_BoldText", + "_PnP_CoolText", + "_PnP_CuteText", + "_PnP_GreatText", + "_PnP_HappyText", + "_PnP_InspiringText", + "_PnP_LazyText", + "_PnP_MyTypeText", + "_PnP_NoisyText", + "_PnP_OddText", + "_PnP_PickyText", + "_PnP_PrecociousText", + "_PnP_RightForMeText", + "_PnP_SoSoText", + "_PnP_SortOfOKText", + "_PnP_Text1", + "_PnP_Text2", + "_PnP_Text3", + "_PnP_Text4", + "_PnP_Text5", + "_PnP_WeirdText", + "_PocketIsFullText", + "_PoisonFaintText", + "_PoisonWhiteoutText", + "_PokecenterBillsPCText", + "_PokecenterOaksPCText", + "_PokecenterPCCantUseText", + "_PokecenterPCOaksClosedText", + "_PokecenterPCTurnOnText", + "_PokecenterPCWhoseText", + "_PokecenterPlayersPCText", + "_PokedexShowText", + "_PokegearAskDeleteText", + "_PokegearAskWhoCallText", + "_PokegearPressButtonText", + "_PokemonAskSwapItemText", + "_PokemonHoldItemText", + "_PokemonNotEnoughHPText", + "_PokemonNotHoldingText", + "_PokemonRemoveMailText", + "_PokemonSwapItemText", + "_PokemonTookItemText", + "_PrestoAllDoneText", + "_PutAwayAndSetUpText", + "_PutAwayTheDecoText", + "_PutCardInCardFolderText", + "_PutItemInPocketText", + "_RaiseThePPOfWhichMoveText", + "_ReceiveItemText", + "_ReceivedEggText", + "_ReceivedItemText", + "_ReceivedTMHMText", + "_RecoveredSomeHPText", + "_RegisteredItemText", + "_RemainingTimeText", + "_RemoveMailText", + "_RepelUsedEarlierIsStillInEffectText", + "_RepelWoreOffText", + "_RestoreThePPOfWhichMoveText", + "_RetrieveMysteryGiftText", + "_RevitalizedText", + "_RidOfParalysisText", + "_RocketRadioText1", + "_RocketRadioText10", + "_RocketRadioText2", + "_RocketRadioText3", + "_RocketRadioText4", + "_RocketRadioText5", + "_RocketRadioText6", + "_RocketRadioText7", + "_RocketRadioText8", + "_RocketRadioText9", + "_RodBiteText", + "_RodNothingText", + "_SaveFileCorruptedText", + "_SavedTheGameText", + "_SavingDontTurnOffThePowerText", + "_SavingRecordText", + "_SeerCantTellAThingText", + "_SeerDoNothingText", + "_SeerEggText", + "_SeerImpressedText", + "_SeerMightyText", + "_SeerMoreCareText", + "_SeerMoreConfidentText", + "_SeerMuchStrengthText", + "_SeerNameLocationText", + "_SeerNoLocationText", + "_SeerSeeAllText", + "_SeerTimeLevelText", + "_SeerTradeText", + "_SentTrophyHomeText", + "_SetUpTheDecoText", + "_SlotsBetHowManyCoinsText", + "_SlotsDarnText", + "_SlotsLinedUpText", + "_SlotsNotEnoughCoinsText", + "_SlotsPlayAgainText", + "_SlotsRanOutOfCoinsText", + "_SlotsStartText", + "_SpaceSpaceColonText", + "_SquirtbottleNothingText", + "_StartMenuContestEndText", + "_StartText", + "_StartingLinkText", + "_StopLearningMoveText", + "_StoppedEvolvingText", + "_SweetScentNothingText", + "_TMHMNotCompatibleText", + "_TakeGoodCareOfEggText", + "_TakeGoodCareOfMonText", + "_TeleportReturnText", + "_TestEventText", + "_ThatCantBeUsedRightNowText", + "_ThatItemCantBePutInThePackText", + "_ThatsEnoughComeBackText", + "_TheBoxIsFullText", + "_TheItemWasPutInThePackText", + "_TheMonMustAllBeDifferentKindsText", + "_TheMonMustNotHoldTheSameItemsText", + "_ThePasswordIsText", + "_ThereIsNoEggText", + "_ThereIsNothingConnectedText", + "_ThrewAwayText", + "_TimeAskOkayText", + "_TimeLimitWasClearedText", + "_TimesetAskAdjustDSTText", + "_TimesetAskDSTText", + "_TimesetAskNotDSTText", + "_TimesetDSTText", + "_TimesetNotDSTText", + "_TradingMonForOTMonText", + "_TrainerRankingExplanationText", + "_TrainerRankingNoDataText", + "_TryAgainUsingSameSettingsText", + "_UnusedNothingHereText", + "_UseCutText", + "_UseDigText", + "_UseEscapeRopeText", + "_UseHeadbuttText", + "_UseRockSmashText", + "_UseSacredAshText", + "_UseStrengthText", + "_UseSweetScentText", + "_UseWaterfallText", + "_UseWhirlpoolText", + "_UsedInsteadText", + "_UsedMove1Text", + "_UsedMove2Text", + "_UsedSurfText", + "_WaitButtonText", + "_WantAQuickMobileBattleText", + "_WantToRushThroughAMobileBattleText", + "_WarpingText", + "_WasDefrostedText", + "_WasSentToBillsPCText", + "_WhatShouldIRaiseText", + "_WhichApricornText", + "_WhichMonPhotoText", + "_WhichSidePutAwayText", + "_WhichSidePutOnText", + "_WhitedOutText", + "_WillTradeText", + "_WillYouPlayWithMonText", + "_WindowAreaExceededErrorText", + "_WindowPoppingErrorText", + "_WokeUpText", + "_WouldYouLikeToMobileBattleText", + "_WouldYouLikeToSaveTheGameText", + "_YesPromptText", + "_YouCantTakeAnEggText", + "_YouCantUseItInABattleText", + "_YouDontHaveAMonText", + "_YouNeedTwoMonForBreedingText", + "_YourFoesWeakGetmMonText", + "_YourFriendIsNotReadyText", + "_YourMonHasGrownText", + "_YourMonsHPWasHealedText" + ] + }, + "tilesets": { + "TILESET_AERODACTYL_WORD_ROOM": {}, + "TILESET_BATTLE_TOWER_INSIDE": {}, + "TILESET_BATTLE_TOWER_OUTSIDE": {}, + "TILESET_BETA_WORD_ROOM": {}, + "TILESET_CAVE": {}, + "TILESET_CHAMPIONS_ROOM": {}, + "TILESET_DARK_CAVE": {}, + "TILESET_ELITE_FOUR_ROOM": {}, + "TILESET_FACILITY": {}, + "TILESET_FOREST": {}, + "TILESET_GAME_CORNER": {}, + "TILESET_GATE": {}, + "TILESET_HOUSE": {}, + "TILESET_HO_OH_WORD_ROOM": {}, + "TILESET_ICE_PATH": {}, + "TILESET_JOHTO": {}, + "TILESET_JOHTO_MODERN": {}, + "TILESET_KABUTO_WORD_ROOM": {}, + "TILESET_KANTO": {}, + "TILESET_LAB": {}, + "TILESET_LIGHTHOUSE": {}, + "TILESET_MANSION": {}, + "TILESET_MART": {}, + "TILESET_OMANYTE_WORD_ROOM": {}, + "TILESET_PARK": {}, + "TILESET_PLAYERS_HOUSE": {}, + "TILESET_PLAYERS_ROOM": {}, + "TILESET_POKECENTER": {}, + "TILESET_POKECOM_CENTER": {}, + "TILESET_PORT": {}, + "TILESET_RADIO_TOWER": {}, + "TILESET_RUINS_OF_ALPH": {}, + "TILESET_TOWER": {}, + "TILESET_TRADITIONAL_HOUSE": {}, + "TILESET_TRAIN_STATION": {}, + "TILESET_UNDERGROUND": {} + }, + "unownCharmap": { + "0": "A", + "10": "F", + "100": "-", + "12": "G", + "14": "H", + "2": "B", + "255": "@", + "32": "I", + "34": "J", + "36": "K", + "38": "L", + "4": "C", + "40": "M", + "42": "N", + "44": "O", + "46": "P", + "6": "D", + "64": "Q", + "66": "R", + "68": "S", + "70": "T", + "72": "U", + "74": "V", + "76": "W", + "78": "X", + "8": "E", + "96": "Y", + "98": "Z" + } +} diff --git a/tools/rom_manifest_gold.json b/tools/rom_manifest_gold.json index 333445da..e42afb81 100644 --- a/tools/rom_manifest_gold.json +++ b/tools/rom_manifest_gold.json @@ -14072,6 +14072,10 @@ 36, 27127 ], + "FishingGFX": [ + 20, + 17792 + ], "FlaaffyBackpic": [ 25, 23889 diff --git a/tools/rom_manifest_silver.json b/tools/rom_manifest_silver.json index 0b869a25..789fb014 100644 --- a/tools/rom_manifest_silver.json +++ b/tools/rom_manifest_silver.json @@ -14072,6 +14072,10 @@ 36, 27127 ], + "FishingGFX": [ + 20, + 17792 + ], "FlaaffyBackpic": [ 25, 24656 diff --git a/tools/save-editor/App.lua b/tools/save-editor/App.lua index 5e45e56f..b64816f4 100644 --- a/tools/save-editor/App.lua +++ b/tools/save-editor/App.lua @@ -103,6 +103,9 @@ local function applyLoaded(path, statusVerb) S.loadError = false S.allowSave = true end + if Gen.of(S.save, S.version) == 2 then + S.events = Catalog.gen2EventList(Gen.engineOf(S.save, S.version), S.modRoots) + end local mapId = Gen.playerMap(S.save) S.mapId = mapId S.dirty = false @@ -169,16 +172,14 @@ function App.load(pathOverride, opts) for _, mod in ipairs(S.mods:status().loaded) do modRoots[#modRoots + 1] = mod.path end + S.modRoots = modRoots if Gen.of(nil, opts.version) == 2 or require("src.core.GameVersion").generation() == 2 then - S.events = Catalog.goldEventList(modRoots) + S.events = Catalog.gen2EventList(Gen.engineOf(nil, opts.version), modRoots) else S.events = Catalog.scrapeEvents("data/scripts", "data/generated/trainer_headers.lua", nil, modRoots) end applyLoaded(pathOverride or SaveIO.defaultPath(), "Loaded") - if Gen.of(S.save, S.version) == 2 then - S.events = Catalog.goldEventList(modRoots) - end end -- Switch to another save file (Open button, drag-drop, or --save arg). diff --git a/tools/save-editor/Catalog.lua b/tools/save-editor/Catalog.lua index 6280ea25..2bd27988 100644 --- a/tools/save-editor/Catalog.lua +++ b/tools/save-editor/Catalog.lua @@ -125,15 +125,8 @@ function Catalog.scrapeEvents(scriptDir, headerPath, listFiles, extraDirs) return sortedKeys(found) end -function Catalog.goldEventList(extraDirs) - local names = {} - local ok, flags = pcall(require, "src.core.gen2.FlagNames") - if ok and flags and flags.events then - for name in pairs(flags.events) do - names[#names + 1] = name - end - end - table.sort(names) +function Catalog.gen2EventList(engine, extraDirs) + local names = require("Gen2Flags").names(engine) local modFlags = Catalog.scrapeEvents(nil, nil, nil, extraDirs) local seen = {} for _, name in ipairs(names) do seen[name] = true end @@ -146,4 +139,8 @@ function Catalog.goldEventList(extraDirs) return names end +function Catalog.goldEventList(extraDirs) + return Catalog.gen2EventList("gs", extraDirs) +end + return Catalog diff --git a/tools/save-editor/Gen.lua b/tools/save-editor/Gen.lua index 56258a85..3b978f55 100644 --- a/tools/save-editor/Gen.lua +++ b/tools/save-editor/Gen.lua @@ -24,6 +24,37 @@ function Gen.of(save, version) return GameVersion.generation() end +local function versionOf(save, version) + if type(save) == "table" and GameVersion.VERSIONS[save.version] then + return save.version + end + if type(version) == "string" and GameVersion.VERSIONS[version] then + return version + end + return GameVersion.get() +end +Gen.versionOf = versionOf + +function Gen.engineOf(save, version) + return GameVersion.engine(versionOf(save, version)) +end + +function Gen.editionLabel(save, version) + local info = GameVersion.info(versionOf(save, version)) + return tostring((info and info.label) or "GEN 2"):upper() +end + +function Gen.hasCaughtData(save, version) + if Gen.of(save, version) ~= 2 then return false end + return require("src.battle.gen2.Mon").hasCaughtData(versionOf(save, version)) +end + +-- engine/menus/init_gender.asm:23-38 +function Gen.hasPlayerGender(save, version) + if Gen.of(save, version) ~= 2 then return false end + return Gen.engineOf(save, version) == "crystal" +end + function Gen.ofState(S) if not S then return GameVersion.generation() end return Gen.of(S.save, S.version) @@ -110,10 +141,17 @@ function Gen.bindGoldData(data) end function Gen.newGame(version) - if (versionGeneration(version) or GameVersion.generation(version)) == 2 then - return require("src.core.gen2.Save").newGame() + local id = type(version) == "string" and GameVersion.VERSIONS[version] and version + or nil + if (versionGeneration(id) or GameVersion.generation()) == 2 then + local Save2 = require("src.core.gen2.Save") + local save = Save2.newGame({ + playerName = id and Save2.defaultPlayerName(id) or nil, + }) + if id then save.version = id end + return save end - return require("src.core.SaveData").newGame() + return require("src.core.SaveData").newGame({ version = id }) end function Gen.validate(save, data) @@ -223,6 +261,34 @@ function Gen.setCoins(save, amount) end end +-- constants/ram_constants.asm:176-177 +function Gen.playerGender(save) + local stored = save and save.player and save.player.gender + return stored == "female" and "female" or "male" +end + +function Gen.setPlayerGender(save, gender) + save.player = save.player or {} + save.player.gender = (gender == "female") and "female" or "male" + return save.player.gender +end + +-- constants/landmark_constants.asm:3, :111-113 +function Gen.landmarkName(data, index) + index = math.floor(tonumber(index) or 0) + local Mon = require("src.battle.gen2.Mon") + if index == Mon.LANDMARK_EVENT then return "EVENT" end + if index == Mon.LANDMARK_GIFT then return "GIFT" end + if index == 0 then return "UNKNOWN" end + local rows = data and data.gen2Landmarks and data.gen2Landmarks.landmarks + for _, rec in pairs(rows or {}) do + if type(rec) == "table" and rec.index == index then + return (tostring(rec.name or rec.id):gsub("\n", " ")) + end + end + return ("#%d"):format(index) +end + function Gen.dexOwnedKey(save) if Gen.of(save) == 2 then return "caught" end return "owned" @@ -317,14 +383,13 @@ function Gen.toggleBadge(save, id) return not on end -local function goldFlagId(name) - local flags = require("src.core.gen2.FlagNames") - return flags.events and flags.events[name] +local function gen2FlagId(save, name) + return require("Gen2Flags").byName(Gen.engineOf(save))[name] end function Gen.getFlag(save, name) if Gen.of(save) == 2 then - local id = goldFlagId(name) + local id = gen2FlagId(save, name) if id then local Events2 = require("src.world.gen2.Events") local ev = Events2.new() @@ -338,7 +403,7 @@ end function Gen.setFlag(save, name, on) if Gen.of(save) == 2 then - local id = goldFlagId(name) + local id = gen2FlagId(save, name) if id then local Events2 = require("src.world.gen2.Events") local ev = Events2.new() diff --git a/tools/save-editor/Gen2Flags.lua b/tools/save-editor/Gen2Flags.lua new file mode 100644 index 00000000..9de49a9d --- /dev/null +++ b/tools/save-editor/Gen2Flags.lua @@ -0,0 +1,275 @@ +-- ../pokecrystal/constants/event_flags.asm:1 against +-- ../pokegold/constants/event_flags.asm:1 + +local Gen2Flags = {} + +Gen2Flags.CRYSTAL_ADDED = { + EVENT_ALAN_GAVE_FIRE_STONE = 257, + EVENT_ANSWERED_DRAGON_MASTER_QUIZ_WRONG = 193, + EVENT_AZALEA_TOWN_KURT = 1956, + EVENT_BATTLE_TOWER_BATTLE_ROOM_YOUNGSTER = 1937, + EVENT_BATTLE_TOWER_OPEN_CIVILIANS = 1999, + EVENT_BEAT_BEAUTY_OLIVIA = 1473, + EVENT_BEAT_BUG_CATCHER_WAYNE = 1472, + EVENT_BEAT_CAMPER_QUENTIN = 1475, + EVENT_BEAT_COOLTRAINERF_CARA = 1470, + EVENT_BEAT_COOLTRAINERM_DARIN = 1469, + EVENT_BEAT_FISHER_TULLY = 1108, + EVENT_BEAT_FISHER_TULLY2 = 1119, + EVENT_BEAT_FISHER_TULLY3 = 1120, + EVENT_BEAT_POKEFANF_JAIME = 1474, + EVENT_BEAT_POKEFANM_ALLAN = 1480, + EVENT_BEAT_POKEFANM_REX = 1479, + EVENT_BEAT_POKEMANIAC_MILLER = 1476, + EVENT_BEAT_SAGE_GAKU = 1481, + EVENT_BEAT_SAGE_KOJI = 1483, + EVENT_BEAT_SAGE_MASA = 1482, + EVENT_BEAT_SUPER_NERD_HUGH = 1477, + EVENT_BEAT_SUPER_NERD_MARKUS = 1478, + EVENT_BEAT_SUPER_NERD_STAN = 1409, + EVENT_BEAT_TWINS_LEA_AND_PIA = 1471, + EVENT_BUENA_OFFERED_HER_PHONE_NUMBER = 828, + EVENT_BUENA_OFFERED_HER_PHONE_NUMBER_NO_BLUE_CARD = 670, + EVENT_BUGGING_KURT_TOO_MUCH = 187, + EVENT_BURNED_TOWER_1F_EUSINE = 1893, + EVENT_BURNED_TOWER_1F_HIDDEN_ULTRA_BALL = 255, + EVENT_BURNED_TOWER_1F_HP_UP = 1622, + EVENT_BURNED_TOWER_MORTY = 1892, + EVENT_CAN_GIVE_GS_BALL_TO_KURT = 190, + EVENT_CIANWOOD_CITY_EUSINE = 1965, + EVENT_COPYCAT_1 = 1774, + EVENT_COPYCAT_2 = 1775, + EVENT_DANA_GAVE_THUNDERSTONE = 258, + EVENT_DARK_CAVE_VIOLET_ENTRANCE_DIRE_HIT = 1998, + EVENT_DRAGONS_DEN_B1F_CALCIUM = 1983, + EVENT_DRAGONS_DEN_B1F_MAX_ELIXER = 1984, + EVENT_DRAGON_SHRINE_CLAIR = 1936, + EVENT_ECRUTEAK_CITY_GRAMPS = 1961, + EVENT_ECRUTEAK_GYM_GRAMPS = 1960, + EVENT_ECRUTEAK_TIN_TOWER_ENTRANCE_WANDERING_SAGE = 1969, + EVENT_ERIN_CALCIUM = 827, + EVENT_EUSINE_IN_BURNED_TOWER = 1962, + EVENT_FLORIA_AT_FLOWER_SHOP = 1896, + EVENT_FLORIA_AT_SUDOWOODO = 1897, + EVENT_FOREST_IS_RESTLESS = 192, + EVENT_FOUGHT_EUSINE = 819, + EVENT_FOUGHT_SUICUNE = 821, + EVENT_GAVE_GS_BALL_TO_KURT = 191, + EVENT_GINA_GAVE_LEAF_STONE = 256, + EVENT_GOLDENROD_CITY_MOVE_TUTOR = 1898, + EVENT_GOLDENROD_GAME_CORNER_MOVE_TUTOR = 1899, + EVENT_GOLDENROD_SALE_OFF = 1776, + EVENT_GOLDENROD_SALE_ON = 1777, + EVENT_GOLDENROD_UNDERGROUND_WAREHOUSE_ULTRA_BALL = 1621, + EVENT_GOT_CLEAR_BELL = 120, + EVENT_GOT_DRATINI = 189, + EVENT_GOT_GS_BALL_FROM_GOLDENROD_POKEMON_CENTER = 832, + EVENT_GOT_ODD_EGG = 830, + EVENT_GOT_RAINBOW_WING = 822, + EVENT_HOLE_IN_BURNED_TOWER = 818, + EVENT_HUEY_PROTEIN = 823, + EVENT_ICE_PATH_1F_PROTEIN = 1982, + EVENT_ILEX_FOREST_ANTIDOTE = 1978, + EVENT_ILEX_FOREST_ETHER = 1979, + EVENT_ILEX_FOREST_FARFETCHD = 1769, + EVENT_ILEX_FOREST_KURT = 1957, + EVENT_ILEX_FOREST_LASS = 1773, + EVENT_ILEX_FOREST_X_ATTACK = 1977, + EVENT_JOEY_HP_UP = 824, + EVENT_KOJI_ALLOWS_YOU_PASSAGE_TO_TIN_TOWER = 820, + EVENT_KURTS_HOUSE_GRANDDAUGHTER_1 = 1932, + EVENT_KURTS_HOUSE_GRANDDAUGHTER_2 = 1933, + EVENT_LAKE_OF_RAGE_ELIXER = 1605, + EVENT_LAKE_OF_RAGE_ELIXIR_ON_STANDBY = 58, + EVENT_MET_BUENA = 829, + EVENT_MET_FLORIA = 185, + EVENT_MOUNT_MORTAR_1F_INSIDE_IRON = 1992, + EVENT_MOUNT_MORTAR_1F_INSIDE_MAX_POTION = 1958, + EVENT_MOUNT_MORTAR_1F_INSIDE_NUGGET = 1959, + EVENT_MOUNT_MORTAR_1F_INSIDE_ULTRA_BALL = 1993, + EVENT_MOUNT_MORTAR_B1F_CARBOS = 1671, + EVENT_MOUNT_MORTAR_B1F_FULL_RESTORE = 1994, + EVENT_MOUNT_MORTAR_B1F_MAX_ETHER = 1995, + EVENT_MOUNT_MORTAR_B1F_PP_UP = 1996, + EVENT_OLIVINE_LIGHTHOUSE_5F_SUPER_REPEL = 1638, + EVENT_PARRY_IRON = 826, + EVENT_PICKED_UP_BERRY_FROM_KABUTO_ITEM_ROOM = 1944, + EVENT_PICKED_UP_CHARCOAL_FROM_HO_OH_ITEM_ROOM = 1943, + EVENT_PICKED_UP_ENERGYPOWDER_FROM_KABUTO_ITEM_ROOM = 1947, + EVENT_PICKED_UP_ENERGY_ROOT_FROM_AERODACTYL_ITEM_ROOM = 1955, + EVENT_PICKED_UP_GOLD_BERRY_FROM_AERODACTYL_ITEM_ROOM = 1952, + EVENT_PICKED_UP_GOLD_BERRY_FROM_HO_OH_ITEM_ROOM = 1940, + EVENT_PICKED_UP_HEAL_POWDER_FROM_AERODACTYL_ITEM_ROOM = 1954, + EVENT_PICKED_UP_HEAL_POWDER_FROM_KABUTO_ITEM_ROOM = 1946, + EVENT_PICKED_UP_MOON_STONE_FROM_AERODACTYL_ITEM_ROOM = 1953, + EVENT_PICKED_UP_MYSTERYBERRY_FROM_HO_OH_ITEM_ROOM = 1941, + EVENT_PICKED_UP_MYSTERYBERRY_FROM_OMANYTE_ITEM_ROOM = 1948, + EVENT_PICKED_UP_MYSTIC_WATER_FROM_OMANYTE_ITEM_ROOM = 1949, + EVENT_PICKED_UP_PSNCUREBERRY_FROM_KABUTO_ITEM_ROOM = 1945, + EVENT_PICKED_UP_REVIVAL_HERB_FROM_HO_OH_ITEM_ROOM = 1942, + EVENT_PICKED_UP_STARDUST_FROM_OMANYTE_ITEM_ROOM = 1950, + EVENT_PICKED_UP_STAR_PIECE_FROM_OMANYTE_ITEM_ROOM = 1951, + EVENT_PLAYERS_HOUSE_1F_NEIGHBOR = 1938, + EVENT_PLAYERS_NEIGHBORS_HOUSE_NEIGHBOR = 1939, + EVENT_RADIO_TOWER_5F_ULTRA_BALL = 1997, + EVENT_RANG_CLEAR_BELL_1 = 1894, + EVENT_RANG_CLEAR_BELL_2 = 1895, + EVENT_ROUTE_30_ANTIDOTE = 1976, + EVENT_ROUTE_31_POTION = 1710, + EVENT_ROUTE_32_REPEL = 1713, + EVENT_ROUTE_34_ILEX_FOREST_GATE_LASS = 1771, + EVENT_ROUTE_34_ILEX_FOREST_GATE_TEACHER_BEHIND_COUNTER = 1770, + EVENT_ROUTE_34_ILEX_FOREST_GATE_TEACHER_IN_WALKWAY = 1772, + EVENT_ROUTE_34_NUGGET = 1980, + EVENT_ROUTE_44_MAX_REPEL = 1981, + EVENT_ROUTE_45_NUGGET = 1720, + EVENT_ROUTE_46_X_SPEED = 1724, + EVENT_RUINS_OF_ALPH_OUTSIDE_TOURIST_FISHER = 1934, + EVENT_RUINS_OF_ALPH_OUTSIDE_TOURIST_YOUNGSTERS = 1935, + EVENT_SAW_SUICUNE_AT_CIANWOOD_CITY = 1966, + EVENT_SAW_SUICUNE_ON_ROUTE_36 = 1968, + EVENT_SAW_SUICUNE_ON_ROUTE_42 = 1967, + EVENT_SET_WHEN_FOUGHT_HO_OH = 1975, + EVENT_SILVER_CAVE_ROOM_1_PROTEIN = 1690, + EVENT_SILVER_CAVE_ROOM_1_ULTRA_BALL = 1985, + EVENT_SILVER_CAVE_ROOM_2_CALCIUM = 1986, + EVENT_SILVER_CAVE_ROOM_2_PP_UP = 1988, + EVENT_SILVER_CAVE_ROOM_2_ULTRA_BALL = 1987, + EVENT_SPROUT_TOWER_2F_X_ACCURACY = 1608, + EVENT_TALKED_TO_FLORIA_AT_FLOWER_SHOP = 186, + EVENT_TALKED_TO_RUINS_COWARD = 188, + EVENT_TEAM_ROCKET_BASE_B1F_GUARD_SPEC = 1643, + EVENT_TEAM_ROCKET_BASE_B3F_FULL_HEAL = 1647, + EVENT_TEAM_ROCKET_BASE_B3F_PROTEIN = 1645, + EVENT_TEAM_ROCKET_BASE_B3F_ULTRA_BALL = 1620, + EVENT_TEAM_ROCKET_BASE_B3F_X_SPECIAL = 1646, + EVENT_TIFFANY_GAVE_PINK_BOW = 260, + EVENT_TIN_TOWER_1F_ENTEI = 1971, + EVENT_TIN_TOWER_1F_EUSINE = 1973, + EVENT_TIN_TOWER_1F_RAIKOU = 1972, + EVENT_TIN_TOWER_1F_SUICUNE = 1970, + EVENT_TIN_TOWER_1F_WISE_TRIO_1 = 1974, + EVENT_TIN_TOWER_1F_WISE_TRIO_2 = 1989, + EVENT_TIN_TOWER_4F_PP_UP = 1613, + EVENT_TIN_TOWER_6F_MAX_POTION = 1990, + EVENT_TIN_TOWER_9F_HP_UP = 1991, + EVENT_TULLY_ASKED_FOR_PHONE_NUMBER = 655, + EVENT_TULLY_GAVE_WATER_STONE = 259, + EVENT_UNION_CAVE_1F_POTION = 1628, + EVENT_UNION_CAVE_1F_X_ATTACK = 1627, + EVENT_VANCE_CARBOS = 825, + EVENT_VICTORY_ROAD_HP_UP = 1703, + EVENT_WADE_HAS_BERRY = 811, + EVENT_WADE_HAS_BITTER_BERRY = 814, + EVENT_WADE_HAS_PRZCUREBERRY = 813, + EVENT_WADE_HAS_PSNCUREBERRY = 812, + EVENT_WALL_OPENED_IN_AERODACTYL_CHAMBER = 809, + EVENT_WALL_OPENED_IN_HO_OH_CHAMBER = 806, + EVENT_WALL_OPENED_IN_KABUTO_CHAMBER = 807, + EVENT_WALL_OPENED_IN_OMANYTE_CHAMBER = 808, + EVENT_WELCOMED_TO_POKECOM_CENTER = 810, + EVENT_WHIRL_ISLAND_SW_ULTRA_BALL = 1680, + EVENT_WILTON_HAS_GREAT_BALL = 816, + EVENT_WILTON_HAS_POKE_BALL = 817, + EVENT_WILTON_HAS_ULTRA_BALL = 815, + EVENT_WISE_TRIOS_ROOM_WISE_TRIO_1 = 1963, + EVENT_WISE_TRIOS_ROOM_WISE_TRIO_2 = 1964, +} + +Gen2Flags.CRYSTAL_REMOVED = { + EVENT_ALAN_READY_FOR_REMATCH = true, + EVENT_ANTHONY_READY_FOR_REMATCH = true, + EVENT_ARNIE_READY_FOR_REMATCH = true, + EVENT_BEAT_FISHER_CHRIS = true, + EVENT_BEAT_FISHER_CHRIS2 = true, + EVENT_BEAT_FISHER_CHRIS3 = true, + EVENT_BEAT_SUPER_NERD_ERIC_UNUSED = true, + EVENT_BETH_READY_FOR_REMATCH = true, + EVENT_BEVERLY_READY_FOR_REMATCH = true, + EVENT_BRENT_READY_FOR_REMATCH = true, + EVENT_BURNED_TOWER_1F_BURN_HEAL = true, + EVENT_BURNED_TOWER_1F_X_SPEED = true, + EVENT_BURNED_TOWER_B1F_HIDDEN_BURN_HEAL = true, + EVENT_BURNED_TOWER_B1F_HIDDEN_NUGGET = true, + EVENT_BURNED_TOWER_B1F_HIDDEN_ULTRA_BALL = true, + EVENT_BURNED_TOWER_B1F_HP_UP = true, + EVENT_BURNED_TOWER_FIREBREATHER_DICK_ASHES = true, + EVENT_BURNED_TOWER_FIREBREATHER_DICK_NORMAL = true, + EVENT_CHAD_READY_FOR_REMATCH = true, + EVENT_CHRIS_ASKED_FOR_PHONE_NUMBER = true, + EVENT_CHRIS_READY_FOR_REMATCH = true, + EVENT_DANA_READY_FOR_REMATCH = true, + EVENT_DEREK_READY_FOR_REMATCH = true, + EVENT_ECRUTEAK_TIN_TOWER_ENTRANCE_SAGE_LEFT = true, + EVENT_ECRUTEAK_TIN_TOWER_ENTRANCE_SAGE_RIGHT = true, + EVENT_ERIN_READY_FOR_REMATCH = true, + EVENT_GAVEN_READY_FOR_REMATCH = true, + EVENT_GINA_READY_FOR_REMATCH = true, + EVENT_HUEY_READY_FOR_REMATCH = true, + EVENT_ILEX_FOREST_FARFETCHD_1 = true, + EVENT_ILEX_FOREST_FARFETCHD_10 = true, + EVENT_ILEX_FOREST_FARFETCHD_2 = true, + EVENT_ILEX_FOREST_FARFETCHD_3 = true, + EVENT_ILEX_FOREST_FARFETCHD_4 = true, + EVENT_ILEX_FOREST_FARFETCHD_5 = true, + EVENT_ILEX_FOREST_FARFETCHD_6 = true, + EVENT_ILEX_FOREST_FARFETCHD_7 = true, + EVENT_ILEX_FOREST_FARFETCHD_8 = true, + EVENT_ILEX_FOREST_FARFETCHD_9 = true, + EVENT_IRWIN_READY_FOR_REMATCH = true, + EVENT_JACK_READY_FOR_REMATCH = true, + EVENT_JOEY_READY_FOR_REMATCH = true, + EVENT_JOSE_READY_FOR_REMATCH = true, + EVENT_KENJI_READY_FOR_REMATCH = true, + EVENT_LAKE_OF_RAGE_ETHER_ON_STANDBY = true, + EVENT_LAKE_OF_RAGE_MAX_ETHER = true, + EVENT_LIZ_READY_FOR_REMATCH = true, + EVENT_MOUNT_MORTAR_B1F_FULL_HEAL = true, + EVENT_OLIVINE_LIGHTHOUSE_5F_GREAT_BALL = true, + EVENT_PARRY_READY_FOR_REMATCH = true, + EVENT_RALPH_READY_FOR_REMATCH = true, + EVENT_REENA_READY_FOR_REMATCH = true, + EVENT_ROUTE_31_ANTIDOTE = true, + EVENT_ROUTE_32_POTION = true, + EVENT_ROUTE_45_X_SPECIAL = true, + EVENT_ROUTE_46_DIRE_HIT = true, + EVENT_SILVER_CAVE_ROOM_1_X_ACCURACY = true, + EVENT_SPROUT_TOWER_2F_X_DEFEND = true, + EVENT_TEAM_ROCKET_BASE_B1F_X_ACCURACY = true, + EVENT_TEAM_ROCKET_BASE_B3F_DIRE_HIT = true, + EVENT_TIFFANY_READY_FOR_REMATCH = true, + EVENT_TIN_TOWER_4F_SUPER_POTION = true, + EVENT_TODD_READY_FOR_REMATCH = true, + EVENT_VANCE_READY_FOR_REMATCH = true, + EVENT_VICTORY_ROAD_X_SPECIAL = true, + EVENT_WADE_READY_FOR_REMATCH = true, + EVENT_WHIRL_ISLAND_SW_GUARD_SPEC = true, + EVENT_WILTON_READY_FOR_REMATCH = true, +} + +local cache = {} + +function Gen2Flags.byName(engine) + engine = (engine == "crystal") and "crystal" or "gs" + if cache[engine] then return cache[engine] end + local ids = {} + local ok, flags = pcall(require, "src.core.gen2.FlagNames") + if ok and type(flags) == "table" and type(flags.events) == "table" then + for name, id in pairs(flags.events) do ids[name] = id end + end + if engine == "crystal" then + for name in pairs(Gen2Flags.CRYSTAL_REMOVED) do ids[name] = nil end + for name, id in pairs(Gen2Flags.CRYSTAL_ADDED) do ids[name] = id end + end + cache[engine] = ids + return ids +end + +function Gen2Flags.names(engine) + local names = {} + for name in pairs(Gen2Flags.byName(engine)) do names[#names + 1] = name end + table.sort(names) + return names +end + +return Gen2Flags diff --git a/tools/save-editor/Ops.lua b/tools/save-editor/Ops.lua index 86276d76..c5e2a33d 100644 --- a/tools/save-editor/Ops.lua +++ b/tools/save-editor/Ops.lua @@ -1141,4 +1141,74 @@ function Ops.setPokerus(S, mon, value) return Ops.mark(S, ("%s pokerus byte %d"):format(mon.species, want)) end +-- engine/pokemon/caught_data.asm:169-172 +Ops.CAUGHT_TIMES = { "UNKNOWN", "MORN", "DAY", "NITE" } + +local function caughtGuard(S, mon) + if not mon then return false end + if not Gen.hasCaughtData(S.save, S.version) then + return Ops.say(S, "This game has no caught data") + end + return true +end + +function Ops.setCaughtTime(S, mon, value) + if not caughtGuard(S, mon) then return false end + local want = clamp(math.floor(tonumber(value) or 0), 0, 3) + if want == (mon.caughtTime or 0) then + return Ops.say(S, ("Caught time is already %s"):format(Ops.CAUGHT_TIMES[want + 1])) + end + mon.caughtTime = want + return Ops.mark(S, ("%s caught time %s") + :format(mon.species, Ops.CAUGHT_TIMES[want + 1])) +end + +-- constants/pokemon_data_constants.asm:120-121 +function Ops.setCaughtLevel(S, mon, value) + if not caughtGuard(S, mon) then return false end + local Mon = require("src.battle.gen2.Mon") + local want = clamp(math.floor(tonumber(value) or 0), 0, Mon.CAUGHT_LEVEL_MASK) + if want == (mon.caughtLevel or 0) then + return Ops.say(S, ("Caught level is already %d"):format(want)) + end + mon.caughtLevel = want + return Ops.mark(S, ("%s caught level %d"):format(mon.species, want)) +end + +-- constants/landmark_constants.asm:111-113 +function Ops.setCaughtLocation(S, mon, value) + if not caughtGuard(S, mon) then return false end + local Mon = require("src.battle.gen2.Mon") + local span = Mon.CAUGHT_LOCATION_MASK + 1 + local want = math.floor(tonumber(value) or 0) % span + if want == (mon.caughtLocation or 0) then + return Ops.say(S, ("Caught location is already %s") + :format(Gen.landmarkName(S.data, want))) + end + mon.caughtLocation = want + return Ops.mark(S, ("%s caught at %s") + :format(mon.species, Gen.landmarkName(S.data, want))) +end + +function Ops.setCaughtByGender(S, mon, gender) + if not caughtGuard(S, mon) then return false end + local Mon = require("src.battle.gen2.Mon") + local want = Mon.caughtGenderOf(gender) + if want == mon.caughtByGender then + return Ops.say(S, ("Caught by is already %s"):format(tostring(want or "none"))) + end + mon.caughtByGender = want + return Ops.mark(S, ("%s caught by %s"):format(mon.species, tostring(want or "none"))) +end + +function Ops.setPlayerGender(S, gender) + if not Gen.hasPlayerGender(S.save, S.version) then + return Ops.say(S, "This game has no player gender") + end + if Gen.playerGender(S.save) == gender then + return Ops.say(S, ("Player is already %s"):format(tostring(gender))) + end + return Ops.mark(S, ("Player gender %s"):format(Gen.setPlayerGender(S.save, gender))) +end + return Ops diff --git a/tools/save-editor/State.lua b/tools/save-editor/State.lua index 258e32d2..534d5411 100644 --- a/tools/save-editor/State.lua +++ b/tools/save-editor/State.lua @@ -26,6 +26,7 @@ function State.new() -- App.load's opts; nil in a bare `love . --editor` run. version = nil, slotId = nil, + modRoots = nil, -- Hosted inside the launcher process (Edit on a save row) rather than a -- standalone `--editor` window: Close returns to the launcher instead of -- quitting, and App calls onClose() to do it. diff --git a/tools/save-editor/panels/Items.lua b/tools/save-editor/panels/Items.lua index e43a8282..94db78bb 100644 --- a/tools/save-editor/panels/Items.lua +++ b/tools/save-editor/panels/Items.lua @@ -101,6 +101,31 @@ local function drawMoney(S, Kit, x, y, w, h) end end +-- engine/menus/init_gender.asm:55-56 +local GENDERS = { { "BOY", "male" }, { "GIRL", "female" } } + +local function trainerHeight(Kit, s, pad) + return pad * 2 + Kit.textHeight("caption") + 10 * s + 28 * s +end + +local function drawTrainer(S, Kit, x, y, w, h) + local s = Kit.scale + local pad = 16 * s + Kit.card(x, y, w, h) + Kit.caption(x + pad, y + pad, "TRAINER") + Kit.textRight("mono", tostring((S.save.player and S.save.player.name) or "?"), + x + w - pad, y + pad, PAL.caption) + local cy = y + pad + Kit.textHeight("caption") + 10 * s + local chipW = (w - 2 * pad - 8 * s) / 2 + local gender = Gen.playerGender(S.save) + for i, pair in ipairs(GENDERS) do + if Kit.chip(x + pad + (i - 1) * (chipW + 8 * s), cy, chipW, 28 * s, + pair[1], gender == pair[2], PAL.green, PAL.steel) then + Ops.setPlayerGender(S, pair[2]) + end + end +end + local BADGE_COLS = 4 local function badgeHeight(S, Kit, s, pad) @@ -273,6 +298,10 @@ function M.draw(S, Kit, x, y, w, h) local listH = 300 * s Kit.pushClip(x, y, w, h) local cy = y - off + if Gen.hasPlayerGender(S.save, S.version) then + local trainerH = trainerHeight(Kit, s, pad) + drawTrainer(S, Kit, x, cy, w, trainerH); cy = cy + trainerH + gap + end drawMoney(S, Kit, x, cy, w, moneyH); cy = cy + moneyH + gap drawPicker(S, Kit, x, cy, w, pickH); cy = cy + pickH + gap drawBadges(S, Kit, x, cy, w, badgeH); cy = cy + badgeH + gap @@ -294,8 +323,14 @@ function M.draw(S, Kit, x, y, w, h) -- made the old panel unusable. local moneyH = moneyHeight(Kit, s, pad, S) local badgeH = badgeHeight(S, Kit, s, pad) - drawMoney(S, Kit, x, y, leftW, moneyH) - drawPicker(S, Kit, x, y + moneyH + gap, leftW, h - moneyH - badgeH - 2 * gap) + local topH = 0 + if Gen.hasPlayerGender(S.save, S.version) then + topH = trainerHeight(Kit, s, pad) + gap + drawTrainer(S, Kit, x, y, leftW, topH - gap) + end + drawMoney(S, Kit, x, y + topH, leftW, moneyH) + drawPicker(S, Kit, x, y + topH + moneyH + gap, leftW, + h - topH - moneyH - badgeH - 2 * gap) drawBadges(S, Kit, x, y + h - badgeH, leftW, badgeH) drawBag(S, Kit, bagX, y, listW, h) drawPc(S, Kit, pcX, y, listW, h) diff --git a/tools/save-editor/panels/MonEditor.lua b/tools/save-editor/panels/MonEditor.lua index b9286048..35757324 100644 --- a/tools/save-editor/panels/MonEditor.lua +++ b/tools/save-editor/panels/MonEditor.lua @@ -147,6 +147,66 @@ local function drawDvRows(S, Kit, mon, cx, rowY, colW, rowH, rowGap) end end +-- engine/pokemon/caught_data.asm:168-199 +local CAUGHT_BY = { { "-", "none" }, { "BOY", "boy" }, { "GIRL", "girl" } } + +local function drawCaughtRows(S, Kit, mon, cx, y, inner, row) + local s = Kit.scale + local chipH = 22 * s + local gap = 6 * s + local tinyH = Kit.textHeight("tiny") + + Kit.text("tiny", "CAUGHT", cx, y + (row - tinyH) / 2, PAL.caption) + local timeX = cx + 62 * s + local timeW = math.max(34 * s, (cx + inner - timeX - 3 * gap) / 4) + for i, label in ipairs(Ops.CAUGHT_TIMES) do + if Kit.chip(timeX + (i - 1) * (timeW + gap), y + (row - chipH) / 2, + timeW, chipH, label, (mon.caughtTime or 0) == i - 1, PAL.blue, PAL.steel) then + Ops.setCaughtTime(S, mon, i - 1) + end + end + y = y + row + gap + + local btn = 24 * s + local lvX = cx + inner - 2 * btn - gap + if Kit.stepper(lvX, y + (row - btn) / 2, btn, btn, "-", { font = "small" }) then + Ops.setCaughtLevel(S, mon, (mon.caughtLevel or 0) - 1) + end + if Kit.stepper(lvX + btn + gap, y + (row - btn) / 2, btn, btn, "+", + { font = "small" }) then + Ops.setCaughtLevel(S, mon, (mon.caughtLevel or 0) + 1) + end + Kit.textRight("tiny", ("MET LV %d"):format(mon.caughtLevel or 0), lvX - 10 * s, + y + (row - tinyH) / 2, PAL.text) + Kit.text("tiny", "OT", cx, y + (row - tinyH) / 2, PAL.caption) + local otX = cx + 26 * s + local otW = math.max(30 * s, (inner * 0.42 - 26 * s - 2 * gap) / 3) + for i, pair in ipairs(CAUGHT_BY) do + if Kit.chip(otX + (i - 1) * (otW + gap), y + (row - chipH) / 2, otW, chipH, + pair[1], (mon.caughtByGender or "none") == pair[2], PAL.blue, PAL.steel) then + Ops.setCaughtByGender(S, mon, pair[2]) + end + end + y = y + row + gap + + local whereX = cx + inner - 3 * btn - 2 * gap + if Kit.stepper(whereX, y + (row - btn) / 2, btn, btn, "-", { font = "small" }) then + Ops.setCaughtLocation(S, mon, (mon.caughtLocation or 0) - 1) + end + if Kit.stepper(whereX + btn + gap, y + (row - btn) / 2, btn, btn, "+", + { font = "small" }) then + Ops.setCaughtLocation(S, mon, (mon.caughtLocation or 0) + 1) + end + if Kit.button(whereX + 2 * (btn + gap), y + (row - btn) / 2, btn, btn, "0", + { kind = "danger", font = "micro", radius = 6 * s }) then + Ops.setCaughtLocation(S, mon, 0) + end + local where = ("WHERE %s"):format(Gen.landmarkName(S.data, mon.caughtLocation or 0)) + Kit.text("tiny", Kit.ellipsize("tiny", where, whereX - 10 * s - cx), cx, + y + (row - tinyH) / 2, PAL.text) + return y + row + gap +end + local function drawMoveRows(S, Kit, mon, rightX, rowY, colW, rowH, rowGap) local s = Kit.scale for slot = 1, 4 do @@ -231,6 +291,7 @@ function MonEditor.draw(S, Kit, x, y, w, h) -- the field + Set row local extraH = 0 if Gen.ofState(S) == 2 then extraH = 88 * s end + if Gen.hasCaughtData(S.save, S.version) then extraH = extraH + 102 * s end local nickFieldH = 30 * s local contentH = pad + headerH + 18 * s + capH + 10 * s + nickFieldH + 18 * s @@ -341,7 +402,7 @@ function MonEditor.draw(S, Kit, x, y, w, h) local colY = statsY + cellH + 18 * s if Gen.ofState(S) == 2 then local extraY = colY - Kit.caption(cx, extraY, "GOLD") + Kit.caption(cx, extraY, Gen.editionLabel(S.save, S.version)) extraY = extraY + capH + 8 * s local row = 28 * s Kit.text("tiny", "HELD " .. tostring(mon.item or "none"), cx, extraY, PAL.text) @@ -371,7 +432,11 @@ function MonEditor.draw(S, Kit, x, y, w, h) if mon.unownLetter then bits[#bits + 1] = "Unown " .. tostring(mon.unownLetter) end Kit.text("tiny", table.concat(bits, " ") ~= "" and table.concat(bits, " ") or "gender/shiny follow DVs", cx, extraY, PAL.caption) - colY = extraY + 22 * s + extraY = extraY + 22 * s + if Gen.hasCaughtData(S.save, S.version) then + extraY = drawCaughtRows(S, Kit, mon, cx, extraY, inner, row) + end + colY = extraY end if narrow then -- stacked: DVs first, then moves, then the two actions side by side at diff --git a/tools/shaderfx-bridge/.gitignore b/tools/shaderfx-bridge/.gitignore new file mode 100644 index 00000000..2f7896d1 --- /dev/null +++ b/tools/shaderfx-bridge/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/tools/shaderfx-bridge/Cargo.lock b/tools/shaderfx-bridge/Cargo.lock new file mode 100644 index 00000000..3f3652bc --- /dev/null +++ b/tools/shaderfx-bridge/Cargo.lock @@ -0,0 +1,955 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bit-set" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +dependencies = [ + "serde_core", +] + +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "cc" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "codespan-reporting" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "glslang" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0db73ac0e8bd6f9c253b70205f2bb1c63845b6e263665ac8063316ce16f408c8" +dependencies = [ + "bitflags", + "glslang-sys", + "rustc-hash 2.1.3", + "smartstring", + "thiserror 2.0.20", +] + +[[package]] +name = "glslang-sys" +version = "0.8.1+275822a" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec80baca8831e8414db40c562fd76ca6a32a792c1db990451d4e7d3cb246e1ed" +dependencies = [ + "cc", + "glob", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "num-traits", + "serde", + "zerocopy", +] + +[[package]] +name = "halfbrown" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c7ed2f2edad8a14c8186b847909a41fbb9c3eafa44f88bd891114ed5019da09" +dependencies = [ + "hashbrown 0.16.1", + "serde", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash 0.2.0", + "serde", + "serde_core", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom", + "libc", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "librashader-common" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75fcb7b016f592e08241eb627ab94d8b2e435769b99f2705a2ba11c063a1b5fc" +dependencies = [ + "bitflags", + "halfbrown", + "num-traits", + "rustc-hash 2.1.3", + "serde", + "strumbra", +] + +[[package]] +name = "librashader-pack" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e1405d8ae8996621cacbc5d1f9220486eda4cafaf229da02c02eecfbbe2cf" +dependencies = [ + "image", + "librashader-common", + "librashader-preprocess", + "librashader-presets", + "rayon", +] + +[[package]] +name = "librashader-preprocess" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62507ee532a6f3320acac509e3d3b32e8828c8bd1161a74bc576351729c820e2" +dependencies = [ + "encoding_rs", + "librashader-common", + "nom", + "serde", + "thiserror 2.0.20", +] + +[[package]] +name = "librashader-presets" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d70306212493fbb4be617cdb98b7048cb896b16182951aeb17856bee3d14aeff" +dependencies = [ + "librashader-common", + "librashader-preprocess", + "nom", + "nom_locate", + "num-traits", + "once_cell", + "regex", + "serde", + "thiserror 2.0.20", + "vec_extract_if_polyfill", +] + +[[package]] +name = "librashader-reflect" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "923a7ea3724e2fca281a08598217fe2daa5b6a245a278448251c851f19c0b290" +dependencies = [ + "bitflags", + "bytemuck", + "glslang", + "librashader-common", + "librashader-pack", + "librashader-preprocess", + "librashader-presets", + "naga", + "rspirv", + "rustc-hash 2.1.3", + "spirv", + "spirv-cross2", + "thiserror 2.0.20", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "naga" +version = "30.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23bf0a141a9ab6f07dbb492db53245e464bc9db42f407772d9ae03d83a2c1033" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags", + "cfg-if", + "cfg_aliases", + "codespan-reporting", + "half", + "hashbrown 0.17.1", + "indexmap", + "libm", + "log", + "naga-types", + "num-traits", + "once_cell", + "petgraph", + "rustc-hash 1.1.0", + "serde", + "spirv", + "thiserror 2.0.20", + "unicode-ident", +] + +[[package]] +name = "naga-types" +version = "30.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "658200ddc25c6c7b860747516d132d1b284c0fafb7a380233acee9a72fb30e11" +dependencies = [ + "hashbrown 0.17.1", + "indexmap", + "rustc-hash 1.1.0", + "serde", + "thiserror 2.0.20", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "nom_locate" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b577e2d69827c4740cba2b52efaad1c4cc7c73042860b199710b3575c68438d" +dependencies = [ + "bytecount", + "memchr", + "nom", +] + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rspirv" +version = "0.13.0+sdk-1.4.341.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "091dca2e1d6fd3098417b5ec88e77e80d1ba5945750943419dc976858082c296" +dependencies = [ + "rustc-hash 1.1.0", + "spirv", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "smartstring" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fb72c633efbaa2dd666986505016c32c3044395ceaf881518399d2f4127ee29" +dependencies = [ + "autocfg", + "static_assertions", + "version_check", +] + +[[package]] +name = "spike" +version = "0.1.0" +dependencies = [ + "librashader-common", + "librashader-preprocess", + "librashader-presets", + "librashader-reflect", + "serde", + "serde_json", +] + +[[package]] +name = "spirv" +version = "0.4.0+sdk-1.4.341.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f" +dependencies = [ + "bitflags", +] + +[[package]] +name = "spirv-cross-sys" +version = "0.7.2+38681a3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9291b270f65ee1e61842d0a4dadc707d3b1669d8001e0900433e341c897d5d1b" +dependencies = [ + "bytemuck", + "cc", + "num-derive", + "num-traits", +] + +[[package]] +name = "spirv-cross2" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e01f93f6f1637a1a72b43d2ca50ebbcd94c3f4c4e94eda8e27144834671f008" +dependencies = [ + "bitflags", + "bytemuck", + "memchr", + "spirv", + "spirv-cross-sys", + "spirv-cross2-derive", + "thiserror 1.0.69", +] + +[[package]] +name = "spirv-cross2-derive" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18017a288e6ce64dd5d56510166baeabb01849483555c031f573c091b6934a64" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strumbra" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b498044485e2789b38e047fdb4c7ac827bd72d66407af9e5d15626f9c45364e" +dependencies = [ + "serde", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "vec_extract_if_polyfill" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c9cb5fb67c2692310b6eb3fce7dd4b6e4c9a75be4f2f46b27f0b2b7799759c" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/tools/shaderfx-bridge/Cargo.toml b/tools/shaderfx-bridge/Cargo.toml new file mode 100644 index 00000000..d9671c35 --- /dev/null +++ b/tools/shaderfx-bridge/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "spike" +version = "0.1.0" +edition = "2024" + +[lib] +name = "librashader_bridge" +crate-type = ["cdylib", "rlib"] + +[dependencies] +librashader-preprocess = "0.12.0" +librashader-presets = "0.12.0" +librashader-reflect = "0.12.0" +librashader-common = "0.12.0" +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/tools/shaderfx-bridge/src/lib.rs b/tools/shaderfx-bridge/src/lib.rs new file mode 100644 index 00000000..2cd175f2 --- /dev/null +++ b/tools/shaderfx-bridge/src/lib.rs @@ -0,0 +1,1009 @@ +// Plain FFI surface: call a function, get strings/a manifest back. No +// Librashader runtime backend (GL/Vulkan/D3D/Metal) is linked in and no +// live graphics context is ever touched here -- this is +// librashader-presets + librashader-reflect only, the translation step, +// exactly what spike/src/main.rs already proved works as a standalone +// binary. This just gives that same logic a stable C ABI so a LuaJIT +// `ffi.load`/`ffi.cdef` caller can reach it. + +use std::collections::HashMap; +use std::ffi::{CStr, CString}; +use std::os::raw::c_char; +use std::path::Path; + +use librashader_common::{FilterMode, WrapMode}; +use librashader_presets::{Scaling, ScaleFactor, ScaleType, ShaderFeatures, ShaderPreset}; +use librashader_preprocess::ShaderSource; +use librashader_reflect::back::glsl::GlslVersion; +use librashader_reflect::back::targets::GLSL; +use librashader_reflect::back::{CompileShader, FromCompilation}; +use librashader_reflect::front::{Glslang, ShaderInputCompiler}; +use librashader_reflect::reflect::semantics::{ + Semantic, ShaderSemantics, TextureSemanticMap, TextureSemantics, UniformSemantic, + UniqueSemanticMap, +}; +use librashader_reflect::reflect::ReflectShader; +use serde::Serialize; + +fn filter_str(f: FilterMode) -> &'static str { + match f { + FilterMode::Linear => "linear", + FilterMode::Nearest => "nearest", + } +} + +fn wrap_str(w: WrapMode) -> &'static str { + match w { + WrapMode::ClampToBorder => "clamp_to_border", + WrapMode::ClampToEdge => "clamp_to_edge", + WrapMode::Repeat => "repeat", + WrapMode::MirroredRepeat => "mirrored_repeat", + } +} + +fn texture_semantics_str(t: TextureSemantics) -> &'static str { + match t { + TextureSemantics::Original => "Original", + TextureSemantics::Source => "Source", + TextureSemantics::OriginalHistory => "OriginalHistory", + TextureSemantics::PassOutput => "PassOutput", + TextureSemantics::PassFeedback => "PassFeedback", + TextureSemantics::User => "User", + } +} + +/// Every `LIBRA_TEXTURE_` sampler2D uniform this pass's *already +/// compiled* GLSL declares -- scraped from the same compiler output +/// `love-test/main.lua`'s old `findSamplers` regexed directly, just done +/// once here instead of by every Lua consumer. +fn extract_texture_uniform_names(glsl: &str) -> Vec { + const PREFIX: &str = "LIBRA_TEXTURE_"; + let mut names = Vec::new(); + for line in glsl.lines() { + let t = line.trim(); + if !t.starts_with("uniform ") || !t.contains("sampler2D") { + continue; + } + let Some(pos) = t.find(PREFIX) else { continue }; + let rest = &t[pos + PREFIX.len()..]; + let end = rest + .find(|c: char| !(c.is_alphanumeric() || c == '_')) + .unwrap_or(rest.len()); + let name = &rest[..end]; + if !name.is_empty() && !names.iter().any(|n: &String| n == name) { + names.push(name.to_string()); + } + } + names +} + +/// Classifies every declared `LIBRA_TEXTURE_` sampler using the same +/// `librashader_reflect::reflect::semantics` resolution librashader itself +/// uses at runtime (`ShaderSemantics::create_pass_semantics` + the +/// `TextureSemanticMap` name-lookup fallback: explicit alias/LUT-name +/// entries first, then the built-in `Source`/`Original`/`OriginalHistoryN`/ +/// `PassOutputN`/`PassFeedbackN` literal-name conventions) -- replaces the +/// Lua-side `passOutputAlias`/`originalHistory`/hardcoded +/// `"COLOR_PALETTE"`/`"BACKGROUND"` name matching: a pass reachable only +/// by its real `.slangp` alias (not a +/// literal `PassOutputN` name), or a preset with differently-named LUTs, +/// could never be resolved by that pattern matching but resolves correctly +/// here because this is the actual API meant for it, not a reimplication +/// of its naming convention. +/// `ShaderSemantics::create_pass_semantics` (the per-pass convenience API +/// this file uses instead of upstream's real `compile_preset_passes`) +/// only registers the alias of the pass being +/// compiled, not every other pass's alias -- upstream's real preset-wide +/// compile builds one shared semantics map covering every pass's alias +/// before compiling any of them (`librashader_reflect::reflect::presets:: +/// compile_preset_passes`'s own `insert_pass_semantics` loop over ALL +/// passes). That gap is invisible for a preset where every cross-pass +/// texture reference uses the literal `PassOutputN` name (matched by +/// `TextureSemanticMap`'s own prefix-strip fallback regardless of this +/// map), which is all the earlier per-pass approach was ever validated +/// against -- but a real +/// preset can and does reference another pass's output purely by its own +/// `aliasN` (`ds-hybrid-scalefx.slangp`'s `scalefx-pass2.slang` samples +/// `scalefx_pass0`, pass 1's alias, with no `PassOutput1`-shaped name +/// anywhere), which the per-pass map can never resolve. Mirrors upstream's +/// own `insert_pass_semantics` (alias -> PassOutput, `Size` -> +/// PassOutput's size semantic, `Feedback`/`FeedbackSize` -> +/// PassFeedback) since that function itself isn't exported publicly. +fn insert_extra_pass_semantics(semantics: &mut ShaderSemantics, alias: &str, index: usize) { + if alias.trim().is_empty() { + return; + } + semantics.texture_semantics.insert( + alias.into(), + Semantic { + semantics: TextureSemantics::PassOutput, + index, + }, + ); + semantics.uniform_semantics.insert( + format!("{alias}Size").into(), + UniformSemantic::Texture(Semantic { + semantics: TextureSemantics::PassOutput, + index, + }), + ); + semantics.texture_semantics.insert( + format!("{alias}Feedback").into(), + Semantic { + semantics: TextureSemantics::PassFeedback, + index, + }, + ); + semantics.uniform_semantics.insert( + format!("{alias}FeedbackSize").into(), + UniformSemantic::Texture(Semantic { + semantics: TextureSemantics::PassFeedback, + index, + }), + ); +} + +fn classify_samplers( + preset: &ShaderPreset, + semantics: &ShaderSemantics, + fragment: &str, +) -> Vec { + let names = extract_texture_uniform_names(fragment); + names + .into_iter() + .map(|name| match semantics.texture_semantics.texture_semantic(&name) { + Some(sem) => { + let user_name = if matches!(sem.semantics, TextureSemantics::User) { + preset.textures.get(sem.index).map(|t| t.meta.name.to_string()) + } else { + None + }; + SamplerOut { + name, + semantic: texture_semantics_str(sem.semantics).to_string(), + index: sem.index, + user_name, + } + } + None => SamplerOut { + name, + semantic: "Unknown".to_string(), + index: 0, + user_name: None, + }, + }) + .collect() +} + +/// Every member name a given struct (named `struct_name`) declares in this +/// pass's *already compiled* GLSL -- scraped the same way +/// `ShaderFixup.lua`'s `flattenStruct` does (`struct { +/// ; ... };`), before that struct gets flattened away into packed +/// uniforms. Includes every member of whichever struct is named: `*Size` +/// uniforms (`OutputSize`, `SourceSize`, `OriginalHistorySize1`, ...), +/// ordinary `#pragma parameter`s (`response_time`, `color_toggle`, ...), and +/// (when scanning a `LIBRA_UBO_*` struct) `MVP`/motion uniforms +/// (`Gyroscope`, `Accelerometer`, `AccelerometerRest`) alike -- +/// `classify_size_uniforms` below is what tells them apart. +fn extract_struct_member_names(glsl: &str, struct_name: &str) -> Vec { + let marker = format!("struct {struct_name}"); + let Some(start) = glsl.find(marker.as_str()) else { + return Vec::new(); + }; + let Some(open) = glsl[start..].find('{') else { + return Vec::new(); + }; + let body_start = start + open + 1; + let Some(close) = glsl[body_start..].find('}') else { + return Vec::new(); + }; + let body = &glsl[body_start..body_start + close]; + body.split(';') + .filter_map(|member| { + let member = member.trim(); + if member.is_empty() { + return None; + } + member.split_whitespace().last().map(|s| s.to_string()) + }) + .collect() +} + +/// Classifies every `LIBRA_PUSH_FRAGMENT` struct member using the same +/// `librashader_reflect::reflect::semantics` name-resolution +/// `classify_samplers` above uses for texture samplers, applied to the +/// pass's own `uniform_semantics` map instead of `texture_semantics`: +/// `unique_semantic` resolves the fixed built-in names (`OutputSize`, +/// `FinalViewportSize`, `MVP`, `FrameCount`, ...), `texture_semantic` +/// resolves the `*Size` convention `TextureSemantics::size_uniform_name` +/// defines (`SourceSize`, `OriginalSize`, `OriginalHistorySizeN`, +/// `PassOutputSizeN`, `PassFeedbackSizeN`, `UserSizeN`). This replaces +/// `love-test/main.lua`'s `sizeTable()`'s own `OriginalHistorySize(%d+)`/ +/// `PassOutputSize(%d+)` Lua regex -- the same class of gap the sampler- +/// wiring fix closed: a member that isn't one of those two literal name +/// shapes (a `PassFeedbackSizeN`/`UserSizeN`, which no preset under +/// `references/` happens to use but which `size_uniform_name`'s convention +/// covers just as well) could never be classified correctly by pattern +/// matching alone. Confirmed empirically (throwaway `main.rs` print block, +/// added/inspected/reverted, against the real preset): an ordinary +/// `#pragma parameter` (`response_time`, `color_toggle`, ...) is NOT an +/// unresolved name here -- `ShaderSemantics::create_pass_semantics` itself +/// registers every one of a pass's own declared parameters into +/// `uniform_semantics` as `UniqueSemantics::FloatParameter` +/// (`librashader_reflect::reflect::presets::create_pass_semantics`), so +/// `unique_semantic` resolves them too, as `kind = "unique", semantic = +/// "FloatParameter"` -- real classification, not a guess. `kind = +/// "parameter"` (semantic/index left empty) is the fallback for a name +/// that resolves to neither map, which no known preset's push struct ever +/// contains -- callers already have a separate, working mechanism for +/// actual parameter defaults (`ALL_DEFAULTS`), so this fallback only exists +/// to fail informatively rather than silently misclassify something odd as +/// a size uniform. +/// Structs classified, paired with whether to scan them from the vertex +/// (true) or fragment (false) GLSL text. `LIBRA_PUSH_FRAGMENT`/ +/// `LIBRA_PUSH_VERTEX` mirror each other (same #pragma parameters, per +/// ShaderFixup.lua's own documented convention) so scanning both is +/// redundant but harmless -- names are deduped below. +/// `LIBRA_UBO_FRAGMENT`/`LIBRA_UBO_VERTEX` are new here: previously only +/// ever carried a special-cased `MVP` (substituted to `transform_projection` +/// in ShaderFixup.lua, never needing a real classified value), but a real +/// preset (pixel_transparency.slang) also declares `Gyroscope`/ +/// `Accelerometer`/`AccelerometerRest` there once +/// `ShaderFeatures::SENSOR_UNIFORMS` is set -- those need real +/// classification too, the same as any other built-in. +const CLASSIFIED_STRUCTS: [(&str, bool); 4] = [ + ("LIBRA_PUSH_FRAGMENT", false), + ("LIBRA_PUSH_VERTEX", true), + ("LIBRA_UBO_FRAGMENT", false), + ("LIBRA_UBO_VERTEX", true), +]; + +fn classify_size_uniforms( + semantics: &ShaderSemantics, + vertex: &str, + fragment: &str, +) -> Vec { + let mut seen = std::collections::HashSet::new(); + let mut out = Vec::new(); + for (struct_name, is_vertex) in CLASSIFIED_STRUCTS { + let glsl = if is_vertex { vertex } else { fragment }; + for name in extract_struct_member_names(glsl, struct_name) { + if !seen.insert(name.clone()) { + continue; // already classified from an earlier (mirrored) struct + } + out.push(if let Some(sem) = semantics.uniform_semantics.unique_semantic(&name) { + SizeUniformOut { + name, + kind: "unique".to_string(), + semantic: sem.semantics.as_str().to_string(), + index: 0, + } + } else if let Some(sem) = semantics.uniform_semantics.texture_semantic(&name) { + SizeUniformOut { + name, + kind: "texture".to_string(), + semantic: texture_semantics_str(sem.semantics).to_string(), + index: sem.index, + } + } else { + SizeUniformOut { + name, + kind: "parameter".to_string(), + semantic: String::new(), + index: 0, + } + }); + } + } + out +} + +fn scale_type_str(t: ScaleType) -> &'static str { + match t { + ScaleType::Input => "source", + ScaleType::Absolute => "absolute", + ScaleType::Viewport => "viewport", + ScaleType::Original => "original", + } +} + +#[derive(Serialize)] +pub struct ScalingOut { + pub scale_type: String, + /// "float": `factor` is a multiplier on the reference size (the normal + /// case). "absolute": `factor` is itself the size in pixels, verbatim + /// (only meaningful when `scale_type == "absolute"`). + pub factor_kind: String, + pub factor: f32, +} + +fn scaling_out(s: &Scaling) -> ScalingOut { + let (factor_kind, factor) = match s.factor { + ScaleFactor::Float(f) => ("float", f), + ScaleFactor::Absolute(i) => ("absolute", i as f32), + }; + ScalingOut { + scale_type: scale_type_str(s.scale_type).to_string(), + factor_kind: factor_kind.to_string(), + factor, + } +} + +/// A single `#pragma parameter` as declared in a pass's own shader source: +/// name, human-readable label, and the slider's default/min/max/step. This +/// is the real source of truth this project's Lua harnesses previously hand +/// -copied into a shared `ALL_DEFAULTS` table from reading `gb-params.inc` +/// by eye. +#[derive(Serialize)] +pub struct ParameterOut { + pub id: String, + pub description: String, + pub initial: f32, + pub minimum: f32, + pub maximum: f32, + pub step: f32, +} + +/// A preset-level parameter override (the `.slangp` file's own `param = value` +/// lines, e.g. `gameboy-color-dot-matrix.slangp`'s `color_toggle = "1.000000"`) +/// -- applied on top of each pass's own `ParameterOut.initial` default. +#[derive(Serialize)] +pub struct ParameterOverrideOut { + pub name: String, + pub value: f32, +} + +/// One of the preset's lookup textures (e.g. `COLOR_PALETTE`/`BACKGROUND` for +/// `gameboy-color-dot-matrix.slangp`) -- name to bind it as, the real path to +/// load, and its sampling settings. Previously hardcoded per-preset in Lua as +/// `paletteImg`/`backgroundImg`. +#[derive(Serialize)] +pub struct TextureOut { + pub name: String, + pub path: String, + pub wrap_mode: String, + pub filter_mode: String, + pub mipmap: bool, +} + +/// One `LIBRA_TEXTURE_` sampler this pass's compiled GLSL declares, +/// classified via real `librashader_reflect` semantics (see +/// `classify_samplers`) instead of Lua-side name-pattern matching. +/// `semantic` is one of `"Original"`/`"Source"`/`"OriginalHistory"`/ +/// `"PassOutput"`/`"PassFeedback"`/`"User"`/`"Unknown"` (the last only if +/// this pass declares a sampler no semantic could resolve at all -- a +/// genuine translation bug, not an expected case). `index` is meaningful +/// for every semantic except `Original`/`Source`/`Unknown` (always 0 there). +/// `user_name` is only set for `"User"`: the real preset texture name +/// (matches one of `TranslateResult.textures[].name`) to bind, resolved +/// from the same LUT list, not a hardcoded per-preset name. +#[derive(Serialize)] +pub struct SamplerOut { + pub name: String, + pub semantic: String, + pub index: usize, + pub user_name: Option, +} + +/// One `LIBRA_PUSH_FRAGMENT` struct member, classified via real +/// `librashader_reflect` semantics (see `classify_size_uniforms`) instead +/// of Lua-side `*Size` name-pattern matching. `kind` is `"unique"` (a fixed +/// built-in like `OutputSize`/`MVP`/`FrameCount` -- `semantic` names which +/// one, `index` always 0), `"texture"` (a `*Size` uniform tied to a texture +/// semantic -- `semantic` is one of `Original`/`Source`/`OriginalHistory`/ +/// `PassOutput`/`PassFeedback`/`User`, `index` meaningful for every +/// semantic except `Original`/`Source`), or `"parameter"` (an ordinary +/// `#pragma parameter`, not a size uniform at all -- `semantic` empty, +/// `index` 0). +#[derive(Serialize)] +pub struct SizeUniformOut { + pub name: String, + pub kind: String, + pub semantic: String, + pub index: usize, +} + +#[derive(Serialize)] +pub struct PassOutput { + pub id: String, + pub alias: Option, + pub vertex: String, + pub fragment: String, + pub filter: String, + pub wrap_mode: String, + pub frame_count_mod: u32, + pub srgb_framebuffer: bool, + pub float_framebuffer: bool, + pub mipmap_input: bool, + pub scale_x: ScalingOut, + pub scale_y: ScalingOut, + /// This pass's own `#pragma parameter` declarations (id/description/ + /// initial/min/max/step) -- every pass in the preset contributes to one + /// flat namespace (matching how `#pragma parameter` works upstream: + /// shared uniform names across passes are the same parameter), so a + /// consumer merges these across all passes, then layers the preset-level + /// `TranslateResult.parameter_overrides` on top by name. + pub parameters: Vec, + /// Every texture sampler this pass's fragment shader declares, real- + /// semantics-classified. See `SamplerOut`. + pub samplers: Vec, + /// Every `LIBRA_PUSH_FRAGMENT` struct member this pass declares + /// (`*Size` uniforms and `#pragma parameter`s alike), real-semantics- + /// classified. See `SizeUniformOut`. + pub size_uniforms: Vec, +} + +#[derive(Serialize)] +pub struct TranslateResult { + pub pass_count: usize, + pub passes: Vec, + pub textures: Vec, + pub parameter_overrides: Vec, + pub error: Option, +} + +/// The convert-time ESSL 100 gap: `textureSize`/`texelFetchOffset`/ +/// `textureOffset` are all GLSL ES 3.00+-only -- spirv-cross refuses to +/// *emit* them for an ES 1.00 target and the whole pass fails to compile +/// (`UnsupportedSpirv("textureSize is not supported in ESSL 100.")` etc, +/// captured for real across 14 real buildbot presets by `shaderfix-repro/`). +/// Unlike every other ES-1.00 fixup this +/// project does (`ShaderFixup.lua`, all post-compile GLSL-text patches), +/// this one has to happen on the RAW `.slang` source text *before* SPIRV +/// compilation -- spirv-cross's error means no GLSL text is ever produced +/// for the pass to patch afterward. Only ever called for the ES 1.00 +/// target; desktop GLSL 1.20 supports all three calls natively and is left +/// untouched. Rewrites, textually, into ES-1.00-legal equivalents: +/// - `textureSize(, )` -> a literal `ivec2(w, h)`, for a `` +/// that's one of this preset's own declared static textures (a LUT/ +/// border PNG with fixed, load-time-known dimensions -- `lut_sizes`, +/// read directly from each PNG's IHDR chunk). `` must be a +/// non-negative integer literal (true of every real occurrence found so +/// far -- always `0`); a `` not in `lut_sizes`, or a non-literal/ +/// negative ``, is left unrewritten so it fails exactly as loudly +/// as before instead of silently guessing wrong. +/// - `texelFetchOffset(, , , )` / +/// `textureOffset(, , )` -> `texture(, ...)` (NOT +/// `texture2D` -- the source here is still `#version 450` desktop GLSL +/// at this stage, before spirv-cross's own ES 1.00 backend does its +/// usual automatic `texture()` -> `texture2D()` downgrade during +/// emission, same as it already does for every other ordinary `texture()` +/// call in these shaders) sampling at the equivalent texel-center UV, +/// using ``'s own `Size` uniform's `.zw` (reciprocal size -- +/// every pass-graph texture Source/PassOutputN/OriginalHistoryN/etc +/// already declares one, per the slang spec's own convention, confirmed +/// directly against `lcd-grid-v2.slang`'s existing `texelSize = +/// global.SourceSize.zw` line). The uniform's containing block instance +/// name (`params`/`global`/etc) is discovered by scanning the source's +/// own push-constant/UBO block bodies for a member named `Size`, +/// never assumed to be one fixed block. Honest limit, not fixable +/// without a much bigger rewrite: `texture()` sampling honors the +/// sampler's configured wrap mode at out-of-range coordinates, which is +/// not necessarily identical to `texelFetch`'s implementation-defined +/// out-of-bounds behavior -- a possible edge-of-image discrepancy for +/// passes whose fetch offsets can go out of range, not measured, flagged +/// here for whoever verifies these presets' rendered output next. +fn rewrite_essl100_gaps(text: &mut String, lut_sizes: &HashMap) { + rewrite_texture_size_calls(text, lut_sizes); + rewrite_offset_fetch_calls(text, "texelFetchOffset", true); + rewrite_offset_fetch_calls(text, "textureOffset", false); +} + +fn rewrite_texture_size_calls(text: &mut String, lut_sizes: &HashMap) { + for (start, end, args) in find_calls(text, "textureSize").into_iter().rev() { + let (Some(tex), Some(lod)) = (args.first(), args.get(1)) else { + continue; + }; + let Some(&(w, h)) = lut_sizes.get(tex.trim()) else { + continue; + }; + let Ok(lod) = lod.trim().parse::() else { + continue; + }; + text.replace_range(start..end, &format!("ivec2({}, {})", w >> lod, h >> lod)); + } +} + +fn rewrite_offset_fetch_calls(text: &mut String, fn_name: &str, has_lod: bool) { + let expected_args = if has_lod { 4 } else { 3 }; + + // A pass can sample another pass's PassOutput/alias purely through + // texelFetchOffset/textureOffset with no textureSize() call of its own + // (`ds-hybrid-scalefx.slangp`'s scalefx-pass2.slang samples its + // `scalefx_pass0` alias this way) -- the raw shader source then never + // declares that texture's own `Size` uniform at all, since the + // author never needed it directly. Ensure one exists, injected into an + // existing uniform block, before computing any call's byte offsets + // below (injecting text shifts every later offset, so this must fully + // finish first, not interleave with the rewrite loop). + let mut tex_names: Vec = find_calls(text, fn_name) + .into_iter() + .filter(|(_, _, args)| args.len() == expected_args) + .map(|(_, _, args)| args[0].trim().to_string()) + .collect(); + tex_names.sort(); + tex_names.dedup(); + for tex in &tex_names { + ensure_size_uniform_declared(text, tex); + } + + for (start, end, args) in find_calls(text, fn_name).into_iter().rev() { + if args.len() != expected_args { + continue; + } + let tex = args[0].trim(); + let Some(prefix) = find_uniform_block_instance(text, &format!("{tex}Size")) else { + continue; + }; + let replacement = if has_lod { + format!( + "texture({tex}, (vec2(({coord}) + ({off})) + vec2(0.5)) * {prefix}.{tex}Size.zw)", + tex = tex, + coord = args[1].trim(), + off = args[3].trim(), + prefix = prefix + ) + } else { + format!( + "texture({tex}, ({uv}) + vec2({off}) * {prefix}.{tex}Size.zw)", + tex = tex, + uv = args[1].trim(), + off = args[2].trim(), + prefix = prefix + ) + }; + text.replace_range(start..end, &replacement); + } +} + +/// Finds every top-level call of `name(...)` in `src` (not preceded by an +/// identifier char, so a longer name that happens to end in `name` can't +/// false-match), returning `(start_byte, end_byte_exclusive, args)` per +/// call -- `args` are the call's comma-separated argument texts, split +/// respecting nested parens/brackets so an argument like `(coord)` or +/// `ivec2(0, 0)` doesn't get sliced apart. A plain regex can't do this +/// safely since GLSL call arguments nest arbitrarily. +fn find_calls(src: &str, name: &str) -> Vec<(usize, usize, Vec)> { + let bytes = src.as_bytes(); + let pat = format!("{name}("); + let mut out = Vec::new(); + let mut search_from = 0usize; + while let Some(rel) = src[search_from..].find(pat.as_str()) { + let start = search_from + rel; + let open = start + pat.len() - 1; + let preceded_by_ident = start > 0 && { + let c = bytes[start - 1] as char; + c.is_alphanumeric() || c == '_' + }; + if preceded_by_ident { + search_from = open + 1; + continue; + } + let mut depth = 0i32; + let mut i = open; + let mut close = None; + while i < bytes.len() { + match bytes[i] { + b'(' => depth += 1, + b')' => { + depth -= 1; + if depth == 0 { + close = Some(i); + break; + } + } + _ => {} + } + i += 1; + } + let Some(close) = close else { break }; + let args = split_top_level_args(&src[open + 1..close]); + out.push((start, close + 1, args)); + search_from = close + 1; + } + out +} + +fn split_top_level_args(s: &str) -> Vec { + let mut args = Vec::new(); + let mut depth = 0i32; + let mut cur = String::new(); + for c in s.chars() { + match c { + '(' | '[' => { + depth += 1; + cur.push(c); + } + ')' | ']' => { + depth -= 1; + cur.push(c); + } + ',' if depth == 0 => { + args.push(cur.trim().to_string()); + cur = String::new(); + } + _ => cur.push(c), + } + } + if !cur.trim().is_empty() || !args.is_empty() { + args.push(cur.trim().to_string()); + } + args +} + +/// One `[layout(...)] uniform { ... } ;` declaration +/// found by `find_uniform_blocks` -- `body_start`/`body_end` bound the +/// member-declaration text between (not including) the braces. +struct UniformBlockSpan { + instance: String, + body_start: usize, + body_end: usize, +} + +/// Scans `src` for every `[layout(...)] uniform { ... } ;` +/// declaration (push-constant or UBO alike, `params`/`global`/etc -- never +/// one fixed instance name, confirmed by both shapes existing in the real +/// corpus: `lcd-grid-v2.slang`'s `global.SourceSize` vs. +/// `scalefx-pass0.slang`'s `params.SourceSize`). Skips plain non-block +/// uniform declarations (e.g. `uniform sampler2D Source;`) by requiring a +/// `{` immediately (past whitespace) after the block-name identifier. +fn find_uniform_blocks(src: &str) -> Vec { + let bytes = src.as_bytes(); + let mut out = Vec::new(); + let mut search_from = 0usize; + while let Some(rel) = src[search_from..].find("uniform ") { + let kw_start = search_from + rel; + let after_kw = kw_start + "uniform ".len(); + let rest = &src[after_kw..]; + let ident_end = rest + .find(|c: char| !(c.is_alphanumeric() || c == '_')) + .unwrap_or(rest.len()); + let after_ident = rest[ident_end..].trim_start(); + if !after_ident.starts_with('{') { + search_from = after_kw; + continue; + } + let open = after_kw + (rest.len() - after_ident.len()); + let mut depth = 0i32; + let mut i = open; + let mut close = None; + while i < bytes.len() { + match bytes[i] { + b'{' => depth += 1, + b'}' => { + depth -= 1; + if depth == 0 { + close = Some(i); + break; + } + } + _ => {} + } + i += 1; + } + let Some(close) = close else { break }; + let after = src[close + 1..].trim_start(); + let instance = after + .split(|c: char| c == ';' || c.is_whitespace()) + .next() + .unwrap_or(""); + if !instance.is_empty() { + out.push(UniformBlockSpan { + instance: instance.to_string(), + body_start: open + 1, + body_end: close, + }); + } + search_from = close + 1; + } + out +} + +/// The accessor `` for the block (of `find_uniform_blocks`) whose +/// body declares a member named exactly `member` -- used to correctly +/// qualify a `Size` reference once it's known to exist somewhere. +fn find_uniform_block_instance(src: &str, member: &str) -> Option { + find_uniform_blocks(src) + .into_iter() + .find(|b| member_declared(&src[b.body_start..b.body_end], member)) + .map(|b| b.instance) +} + +/// Guarantees `Size` is declared in some uniform block of `text`, +/// injecting `vec4 Size;` into one if it isn't already there, and +/// returns that block's accessor either way. Needed for +/// `rewrite_offset_fetch_calls`: a pass can reference another pass's +/// PassOutput/alias purely through texelFetchOffset/textureOffset with no +/// textureSize() call of its own (`ds-hybrid-scalefx.slangp`'s +/// scalefx-pass2.slang samples its `scalefx_pass0` alias exactly this way), +/// so the raw shader source never declares that texture's own `*Size` +/// uniform at all -- the author never needed it directly, but our rewrite +/// does. Prefers a block that already declares some other `*Size` member +/// (matching the real convention every corpus shader already follows of +/// keeping every `*Size` uniform in one block alongside `SourceSize`/ +/// `OutputSize`), falling back to the first uniform block found at all; +/// `None` only if the shader declares no uniform block whatsoever, which no +/// known real corpus shader does. +fn ensure_size_uniform_declared(text: &mut String, tex: &str) -> Option { + let member = format!("{tex}Size"); + if let Some(instance) = find_uniform_block_instance(text, &member) { + return Some(instance); + } + let blocks = find_uniform_blocks(text); + let target = blocks + .iter() + .find(|b| { + text[b.body_start..b.body_end] + .split(';') + .any(|stmt| stmt.trim_end().ends_with("Size")) + }) + .or_else(|| blocks.first())?; + let instance = target.instance.clone(); + let insert_at = target.body_end; + text.insert_str(insert_at, &format!("\tvec4 {member};\n")); + Some(instance) +} + +fn member_declared(body: &str, member: &str) -> bool { + body.split(';').any(|stmt| { + stmt.split_whitespace() + .last() + .map(|last| last == member) + .unwrap_or(false) + }) +} + +/// Reads a PNG's real pixel width/height straight from its IHDR chunk +/// (bytes 16..20 = width, 20..24 = height, big-endian u32, always the +/// first chunk right after the 8-byte signature per the PNG spec) -- no +/// image-decoding crate needed for just the header, consistent with this +/// project's preference for minimal dependencies. `None` on any read/parse +/// failure; `rewrite_texture_size_calls` treats a missing entry as "leave +/// unrewritten," so this fails the same loud, pre-existing way rather than +/// silently. +fn png_dimensions(path: &Path) -> Option<(u32, u32)> { + let bytes = std::fs::read(path).ok()?; + if bytes.len() < 24 || &bytes[0..8] != b"\x89PNG\r\n\x1a\n" || &bytes[12..16] != b"IHDR" { + return None; + } + let w = u32::from_be_bytes(bytes[16..20].try_into().ok()?); + let h = u32::from_be_bytes(bytes[20..24].try_into().ok()?); + Some((w, h)) +} + +pub fn compile_pass_glsl( + source: &ShaderSource, + version: GlslVersion, +) -> Result<(String, String), Box> { + let compiled = Glslang::compile(source)?; + let mut reflect = GLSL::from_compilation(compiled)?; + let semantics = ShaderSemantics { + uniform_semantics: Default::default(), + texture_semantics: Default::default(), + }; + // Best-effort, matching main.rs: don't fail the whole pass if reflect() + // can't resolve semantics we never populated. + let _ = reflect.reflect(0, &semantics); + let output = reflect.compile(version)?; + Ok((output.vertex, output.fragment)) +} + +/// Parses a `.slangp` preset and compiles every pass to GLSL text at the +/// requested dialect. No fixup (precision header, struct flattening, +/// main()->effect() rewrite) happens here -- that stays a Lua-side stage, +/// run against the returned GLSL text. +pub fn translate_preset(preset_path: &Path, version: GlslVersion) -> TranslateResult { + // SENSOR_UNIFORMS only defines `_HAS_SENSOR_UNIFORMS` in preprocessing so + // a preset's own `#ifdef` guard (e.g. pixel_transparency.slang's + // Gyroscope/Accelerometer/AccelerometerRest block) compiles in -- the + // resulting uniforms are then already classified generically by + // classify_size_uniforms below via the real `unique_semantic` lookup, + // same as every other built-in (OutputSize, MVP, FrameCount, ...), no + // further special-casing needed here. + let preset = match ShaderPreset::try_parse(preset_path, ShaderFeatures::SENSOR_UNIFORMS) { + Ok(p) => p, + Err(e) => { + return TranslateResult { + pass_count: 0, + passes: Vec::new(), + textures: Vec::new(), + parameter_overrides: Vec::new(), + error: Some(format!("preset parse failed: {e}")), + }; + } + }; + + let textures = preset + .textures + .iter() + .map(|t| TextureOut { + name: t.meta.name.to_string(), + path: t.path.to_string_lossy().into_owned(), + wrap_mode: wrap_str(t.meta.wrap_mode).to_string(), + filter_mode: filter_str(t.meta.filter_mode).to_string(), + mipmap: t.meta.mipmap, + }) + .collect(); + + let parameter_overrides = preset + .parameters + .iter() + .map(|p| ParameterOverrideOut { + name: p.name.to_string(), + value: p.value, + }) + .collect(); + + // Only needed for `rewrite_essl100_gaps`'s `textureSize()` case -- + // real load-time dimensions for this preset's own declared static + // textures (LUTs, borders), read once, reused for every pass. + let lut_sizes: HashMap = preset + .textures + .iter() + .filter_map(|t| png_dimensions(&t.path).map(|dim| (t.meta.name.to_string(), dim))) + .collect(); + + let mut passes = Vec::new(); + for (pass_index, pass) in preset.passes.iter().enumerate() { + let mut source = match ShaderSource::load(&pass.path, preset.features) { + Ok(s) => s, + Err(e) => { + return TranslateResult { + pass_count: preset.pass_count as usize, + passes, + textures, + parameter_overrides, + error: Some(format!( + "pass {} source load failed: {e}", + pass.meta.id + )), + }; + } + }; + // ES 1.00 can't emit textureSize/texelFetchOffset/ + // textureOffset at all (spirv-cross errors before any GLSL text + // exists to patch post-compile) -- rewrite the raw source first. + // Desktop GLSL 1.20 supports all three natively; left untouched. + if matches!(version, GlslVersion::Glsl100Es) { + rewrite_essl100_gaps(&mut source.vertex, &lut_sizes); + rewrite_essl100_gaps(&mut source.fragment, &lut_sizes); + } + let mut parameters: Vec = source + .parameters + .values() + .map(|p| ParameterOut { + id: p.id.to_string(), + description: p.description.clone(), + initial: p.initial, + minimum: p.minimum, + maximum: p.maximum, + step: p.step, + }) + .collect(); + // FastHashMap iteration order isn't stable -- sort so the JSON (and + // anything downstream that diffs it) doesn't churn run to run. + parameters.sort_by(|a, b| a.id.cmp(&b.id)); + + match compile_pass_glsl(&source, version) { + Ok((vertex, fragment)) => { + let mut semantics = match ShaderSemantics::create_pass_semantics::< + Box, + >(&preset, pass_index) + { + Ok(s) => s, + Err(e) => { + return TranslateResult { + pass_count: preset.pass_count as usize, + passes, + textures, + parameter_overrides, + error: Some(format!( + "pass {} semantics failed: {e}", + pass.meta.id + )), + }; + } + }; + // See insert_extra_pass_semantics: create_pass_semantics only + // knows this pass's own alias; a later pass can still sample + // an earlier pass's output purely by that earlier pass's own + // alias, which needs registering here too. + for (other_index, other_pass) in preset.passes.iter().enumerate() { + if other_index == pass_index { + continue; + } + if let Some(alias) = other_pass.meta.alias.as_ref() { + insert_extra_pass_semantics(&mut semantics, alias, other_index); + } + } + let samplers = classify_samplers(&preset, &semantics, &fragment); + let size_uniforms = classify_size_uniforms(&semantics, &vertex, &fragment); + passes.push(PassOutput { + id: pass.meta.id.to_string(), + alias: pass.meta.alias.as_ref().map(|a| a.to_string()), + vertex, + fragment, + filter: filter_str(pass.meta.filter).to_string(), + wrap_mode: wrap_str(pass.meta.wrap_mode).to_string(), + frame_count_mod: pass.meta.frame_count_mod, + srgb_framebuffer: pass.meta.srgb_framebuffer, + float_framebuffer: pass.meta.float_framebuffer, + mipmap_input: pass.meta.mipmap_input, + scale_x: scaling_out(&pass.meta.scaling.x), + scale_y: scaling_out(&pass.meta.scaling.y), + parameters, + samplers, + size_uniforms, + }) + } + Err(e) => { + return TranslateResult { + pass_count: preset.pass_count as usize, + passes, + textures, + parameter_overrides, + error: Some(format!("pass {} compile failed: {e}", pass.meta.id)), + }; + } + } + } + + TranslateResult { + pass_count: preset.pass_count as usize, + passes, + textures, + parameter_overrides, + error: None, + } +} + +/// `es != 0` targets GLSL ES 1.00 (mobile/LÖVE's ES dialect); `es == 0` +/// targets GLSL 1.20 (LÖVE's desktop dialect). Returns a JSON-encoded +/// `TranslateResult`, always -- even the error case is valid JSON with an +/// `error` string and empty `passes`, so a caller never has to distinguish +/// "call failed" from "call succeeded but returned an error" at the ABI +/// boundary, only by reading the JSON. +/// +/// Ownership: the returned pointer is heap-allocated by Rust and must be +/// freed with `librashader_free_string`, never with the caller's own +/// allocator. +#[unsafe(no_mangle)] +pub extern "C" fn librashader_translate_preset(preset_path: *const c_char, es: i32) -> *mut c_char { + let path_str = unsafe { + if preset_path.is_null() { + return CString::new("{\"error\":\"null preset_path\",\"pass_count\":0,\"passes\":[],\"textures\":[],\"parameter_overrides\":[]}") + .unwrap() + .into_raw(); + } + CStr::from_ptr(preset_path).to_string_lossy().into_owned() + }; + let version = if es != 0 { + GlslVersion::Glsl100Es + } else { + GlslVersion::Glsl120 + }; + let result = translate_preset(Path::new(&path_str), version); + let json = serde_json::to_string(&result).unwrap_or_else(|e| { + format!("{{\"error\":\"serialize failed: {e}\",\"pass_count\":0,\"passes\":[],\"textures\":[],\"parameter_overrides\":[]}}") + }); + CString::new(json) + .unwrap_or_else(|_| { + CString::new("{\"error\":\"nul byte in translated output\",\"pass_count\":0,\"passes\":[],\"textures\":[],\"parameter_overrides\":[]}") + .unwrap() + }) + .into_raw() +} + +/// Frees a string returned by `librashader_translate_preset`. Must be +/// called exactly once per returned pointer, from the same allocator +/// (this library) that produced it -- never `free()` the pointer directly +/// from the caller's side. +#[unsafe(no_mangle)] +pub extern "C" fn librashader_free_string(s: *mut c_char) { + if s.is_null() { + return; + } + unsafe { + drop(CString::from_raw(s)); + } +} diff --git a/tools/shaderfx-bridge/src/main.rs b/tools/shaderfx-bridge/src/main.rs new file mode 100644 index 00000000..8ef0eec7 --- /dev/null +++ b/tools/shaderfx-bridge/src/main.rs @@ -0,0 +1,123 @@ +use std::path::PathBuf; + +use librashader_presets::{ShaderFeatures, ShaderPreset}; +use librashader_preprocess::ShaderSource; +use librashader_reflect::back::glsl::GlslVersion; +use librashader_bridge::compile_pass_glsl; + +fn count_uniform_vectors(glsl: &str) -> (usize, Vec) { + let mut total = 0usize; + let mut lines = Vec::new(); + for line in glsl.lines() { + let t = line.trim(); + if !t.starts_with("uniform ") { + continue; + } + // crude: skip sampler uniforms, they don't cost a fragment uniform *vector* slot + if t.contains("sampler") { + lines.push(format!("(sampler, not counted) {t}")); + continue; + } + let cost = if t.contains("mat4") { + 4 + } else if t.contains("mat3") { + 3 + } else if t.contains("mat2") { + 2 + } else { + 1 // scalar, vec2, vec3, vec4 all cost one vec4 slot each + }; + // arrays: uniform float foo[8]; costs 8 slots + let mut n = cost; + if let Some(br) = t.find('[') { + if let Some(close) = t[br..].find(']') { + if let Ok(count) = t[br + 1..br + close].trim().parse::() { + n = cost * count; + } + } + } + total += n; + lines.push(format!("({n} slot{}) {t}", if n == 1 { "" } else { "s" })); + } + (total, lines) +} + +fn main() -> Result<(), Box> { + let preset_path = PathBuf::from( + "../references/slang-shaders-gameboy/handheld/gameboy-color-dot-matrix.slangp", + ); + println!("=== parsing preset: {} ===", preset_path.display()); + let preset = ShaderPreset::try_parse(&preset_path, ShaderFeatures::empty())?; + println!("pass_count = {}", preset.pass_count); + for pass in &preset.passes { + println!( + " pass {} alias={:?} -> {}", + pass.meta.id, + pass.meta.alias, + pass.path.display() + ); + } + for tex in &preset.textures { + println!( + " texture {} -> {} (linear={:?})", + tex.meta.name, + tex.path.display(), + tex.meta.filter_mode + ); + } + println!(); + + std::fs::create_dir_all("out")?; + + for pass in &preset.passes { + let label = format!( + "pass{}{}", + pass.meta.id, + pass.meta + .alias + .as_ref() + .map(|a| format!("_{a}")) + .unwrap_or_default() + ); + println!("=== {label}: {} ===", pass.path.display()); + + let source = match ShaderSource::load(&pass.path, preset.features) { + Ok(s) => s, + Err(e) => { + println!(" !! ShaderSource::load failed: {e}"); + continue; + } + }; + println!(" parameters: {}", source.parameters.len()); + for (name, p) in &source.parameters { + println!( + " {name} = {} (min {} max {} step {})", + p.initial, p.minimum, p.maximum, p.step + ); + } + + for (tag, version) in [("100es", GlslVersion::Glsl100Es), ("120", GlslVersion::Glsl120)] { + match compile_pass_glsl(&source, version) { + Ok((vert, frag)) => { + let (slots, lines) = count_uniform_vectors(&frag); + println!(" -- GLSL {tag}: fragment uniform-vector cost = {slots}"); + for l in &lines { + println!(" {l}"); + } + let out_path = format!("out/{label}_{tag}.frag.glsl"); + std::fs::write(&out_path, &frag)?; + println!(" written to {out_path}"); + let vert_path = format!("out/{label}_{tag}.vert.glsl"); + std::fs::write(&vert_path, &vert)?; + println!(" written to {vert_path}"); + } + Err(e) => { + println!(" !! GLSL {tag} compile failed: {e}"); + } + } + } + println!(); + } + + Ok(()) +}