diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 623e7c96..7495d123 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -214,8 +214,21 @@ jobs: apk="$(find dist/android/debug -name '*.apk' | head -1)" [ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/debug"; exit 1; } cp "$apk" "$outdir/gen1recomp-${v}-android.apk" + + # Platform-independent update payload, built alongside the desktop + # apps above (same game.love that gets fused into each of them). + love_file=".bazinga/work/game.love" + [ -f "$love_file" ] || { echo "::error::$love_file not found (expected from scripts/build.sh)"; exit 1; } + cp "$love_file" "$outdir/gen1recomp-${v}.love" + ls -lh "$outdir" + # Checksums for every staged release asset (sums file itself is + # written after this and named outside the gen1recomp-* glob, so it + # never lists itself). + (cd "$outdir" && shasum -a 256 gen1recomp-* > sha256sums.txt) + cat "$outdir/sha256sums.txt" + - name: Publish GitHub Release env: GH_TOKEN: ${{ github.token }} @@ -266,7 +279,9 @@ jobs: "dist/release/gen1recomp-${v}-macos.zip" \ "dist/release/gen1recomp-${v}-windows.zip" \ "dist/release/gen1recomp-${v}-linux.zip" \ - "dist/release/gen1recomp-${v}-android.apk" + "dist/release/gen1recomp-${v}-android.apk" \ + "dist/release/gen1recomp-${v}.love" \ + "dist/release/sha256sums.txt" echo "Published release $tag" diff --git a/data/scripts/flavor/copycats_house_2f.lua b/data/scripts/flavor/copycats_house_2f.lua index ac167980..d40976e3 100644 --- a/data/scripts/flavor/copycats_house_2f.lua +++ b/data/scripts/flavor/copycats_house_2f.lua @@ -1,9 +1,8 @@ -- Flavor talk scripts for CopycatsHouse2F (registry id COPYCATS_HOUSE_2F). -- Source: pokered/scripts/CopycatsHouse2F.asm, pokered/text/CopycatsHouse2F.asm -- --- TEXT_COPYCATSHOUSE2F_COPYCAT is already ported (with the POKE DOLL / --- TM31 MIMIC trade) in data/scripts/story4.lua, so it is intentionally --- omitted here. +-- TEXT_COPYCATSHOUSE2F_COPYCAT (POKE DOLL -> TM31 MIMIC) lives in +-- data/scripts/story4.lua. return { COPYCATS_HOUSE_2F = { diff --git a/data/scripts/oaks_lab.lua b/data/scripts/oaks_lab.lua index c092467f..dbb01673 100644 --- a/data/scripts/oaks_lab.lua +++ b/data/scripts/oaks_lab.lua @@ -59,52 +59,97 @@ end return { talk = { - -- Oak: accepts the parcel and hands over the Pokédex, then (once the - -- player has beaten the Route 22 rival) hands over the real POKé - -- BALLs (scripts/OaksLab.asm OaksLabOak1Text, simplified: no rival - -- recall / dex-rating / "pokemon can fight" / "around the world" - -- branches -- see docs/known-differences.md) + -- Oak: OaksLabOak1Text. Parcel delivery kicks SCRIPT_OAKSLAB_RIVAL_ + -- ARRIVES_AT_OAKS_REQUEST + OaksLabOakGivesPokedexScript (rival walk- + -- in, full Pokédex speech, rival exit, Route 22 arm). Dex-rating + -- (DisplayDexRating) is still skipped. TEXT_OAKSLAB_OAK1 = { - { "face_player" }, -- 1 - { "check_flag", "EVENT_GOT_OAKS_PARCEL" }, -- 2 - { "jump_if_false", 16 }, -- 3 - { "check_flag", "EVENT_OAK_GOT_PARCEL" }, -- 4 - { "jump_if_true", 16 }, -- 5 - { "show_text", "_OaksLabOak1DeliverParcelText" }, -- 6 - { "take_item", "OAKS_PARCEL", 1 }, -- 7 - { "set_flag", "EVENT_OAK_GOT_PARCEL" }, -- 8 - { "show_text", "_OaksLabOak1PokemonAroundTheWorldText" }, -- 9 - { "set_flag", "EVENT_GOT_POKEDEX" }, -- 10 - -- OaksLab.asm OakGivesPokedex: HideObject TOGGLE_POKEDEX_1/2 - -- so the table sprites leave with the gift (#106). - { "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX1" }, -- 11 - { "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX2" }, -- 12 - -- the Pokédex swaps Viridian's two old men (OaksLab.asm:602-606: - -- HideObject TOGGLE_LYING_OLD_MAN / ShowObject TOGGLE_OLD_MAN). - -- Until this ran, the walking man at (17,5) -- who owns the coffee - -- ask and the catch tutorial -- stayed OFF for the whole game - -- (toggleable_objects.asm seeds him OFF, the sleeper ON). - { "hide_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY" }, -- 13 - { "show_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN" }, -- 14 - { "jump", "end" }, -- 15 - { "check_flag", "EVENT_GOT_STARTER" }, -- 16 - { "jump_if_false", 31 }, -- 17 - { "check_item", "POKE_BALL" }, -- 18 - { "jump_if_true", 29 }, -- 19 - { "check_flag", "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE" }, -- 20 - { "jump_if_false", 33 }, -- 21 - { "check_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, -- 22 - { "jump_if_true", 29 }, -- 23 - { "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, -- 24 - { "give_item", "POKE_BALL", 5, false }, -- 25 - { "show_text", "_OaksLabOak1ReceivedPokeballsText" }, -- 26 - { "show_text", "_OaksLabGivePokeballsExplanationText" }, -- 27 - { "jump", "end" }, -- 28 - { "show_text", "_OaksLabOak1ComeSeeMeSometimesText" }, -- 29 - { "jump", "end" }, -- 30 - { "show_text", "_OaksLabOak1WhichPokemonDoYouWantText" }, -- 31 - { "jump", "end" }, -- 32 - { "show_text", "_OaksLabOak1RaiseYourYoungPokemonText" }, -- 33 + { "face_player" }, + { "check_item", "POKE_BALL" }, + { "jump_if_true", "come_see" }, + { "check_flag", "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE" }, + { "jump_if_true", "give_balls" }, + { "check_flag", "EVENT_GOT_POKEDEX" }, + { "jump_if_true", "around_world" }, + { "check_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" }, + { "jump_if_false", "pre_lab_battle" }, + { "check_item", "OAKS_PARCEL" }, + { "jump_if_false", "raise_young" }, + -- OaksLabOak1Text.got_parcel → RivalArrives + OakGivesPokedex + { "show_text", "_OaksLabOak1DeliverParcelText" }, + { "play_sound", "Get_Key_Item" }, + { "show_text", "_OaksLabOak1ParcelThanksText" }, + { "take_item", "OAKS_PARCEL", 1 }, + { "stop_music" }, + { "play_music", "Music_MeetRival" }, + { "show_text", "_OaksLabRivalGrampsText" }, + { "show_object", "OAKS_LAB", "OAKSLAB_RIVAL" }, + -- OaksLabCalcRivalMovementScript: sprite map (8,11) → cell (4,7) + -- when the player stands below Oak (the usual desk talk). + { "place_npc", 1, 4, 7, "up" }, + { "move_npc_to", 1, 4, 3 }, + { "play_music", "Music_OaksLab" }, + { "face_object", 1, "up" }, + { "face_object", 5, "down" }, + { "show_text", "_OaksLabRivalWhatDidYouCallMeForText" }, + { "face_object", 1, "up" }, + { "face_object", 5, "down" }, + { "show_text", "_OaksLabOakIHaveARequestText" }, + { "face_object", 1, "up" }, + { "face_object", 5, "down" }, + { "show_text", "_OaksLabOakMyInventionPokedexText" }, + { "show_text", "_OaksLabOakGotPokedexText" }, + { "play_sound", "Get_Key_Item" }, + { "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX1" }, + { "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX2" }, + { "face_object", 1, "up" }, + { "face_object", 5, "down" }, + { "show_text", "_OaksLabOakThatWasMyDreamText" }, + { "face_object", 1, "right" }, + { "show_text", "_OaksLabRivalLeaveItAllToMeText" }, + { "set_flag", "EVENT_GOT_POKEDEX" }, + { "set_flag", "EVENT_OAK_GOT_PARCEL" }, + { "hide_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY" }, + { "show_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN" }, + { "stop_music" }, + { "play_music", "Music_MeetRival" }, + { "move_npc_to", 1, 4, 7 }, + { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" }, + { "play_music", "Music_OaksLab" }, + { "set_flag", "EVENT_1ST_ROUTE22_RIVAL_BATTLE" }, + { "clear_flag", "EVENT_2ND_ROUTE22_RIVAL_BATTLE" }, + { "set_flag", "EVENT_ROUTE22_RIVAL_WANTS_BATTLE" }, + { "show_object", "ROUTE_22", "ROUTE22_RIVAL1" }, + { "jump", "end" }, + + { "label", "raise_young" }, + { "show_text", "_OaksLabOak1RaiseYourYoungPokemonText" }, + { "jump", "end" }, + + { "label", "pre_lab_battle" }, + { "check_flag", "EVENT_GOT_STARTER" }, + { "jump_if_true", "can_fight" }, + { "show_text", "_OaksLabOak1WhichPokemonDoYouWantText" }, + { "jump", "end" }, + { "label", "can_fight" }, + { "show_text", "_OaksLabOak1YourPokemonCanFightText" }, + { "jump", "end" }, + + { "label", "around_world" }, + { "show_text", "_OaksLabOak1PokemonAroundTheWorldText" }, + { "jump", "end" }, + + { "label", "give_balls" }, + { "check_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, + { "jump_if_true", "come_see" }, + { "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, + { "give_item", "POKE_BALL", 5, false }, + { "show_text", "_OaksLabOak1ReceivedPokeballsText" }, + { "show_text", "_OaksLabGivePokeballsExplanationText" }, + { "jump", "end" }, + + { "label", "come_see" }, + { "show_text", "_OaksLabOak1ComeSeeMeSometimesText" }, }, TEXT_OAKSLAB_CHARMANDER_POKE_BALL = diff --git a/data/scripts/story.lua b/data/scripts/story.lua index 1920f0ee..aa161946 100644 --- a/data/scripts/story.lua +++ b/data/scripts/story.lua @@ -878,16 +878,23 @@ M.HALL_OF_FAME = { M.CERULEAN_CITY = { talk = { + -- CeruleanCityRivalText: Bill line once beaten; pre-battle otherwise. + -- Post-fight walk matches CeruleanCityMovement4 (right then into town). TEXT_CERULEANCITY_RIVAL = { - { "face_player" }, -- 1 - { "check_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 2 - { "jump_if_true", 9 }, -- 3 - { "show_text", "_CeruleanCityRivalPreBattleText" }, -- 4 - { "rival_battle", "OPP_RIVAL1", 7 }, -- 5 - { "jump_if_false", 10 }, -- 6 - { "set_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 7 - { "show_text", "_CeruleanCityRivalDefeatedText" }, -- 8 - { "jump", 10 }, -- 9 + { "face_player" }, -- 1 + { "check_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 2 + { "jump_if_true", 13 }, -- 3 + { "show_text", "_CeruleanCityRivalPreBattleText" }, -- 4 + { "rival_battle", "OPP_RIVAL1", 7 }, -- 5 + { "jump_if_false", "end" }, -- 6 + { "set_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 7 + { "show_text", "_CeruleanCityRivalDefeatedText" }, -- 8 + { "show_text", "_CeruleanCityRivalIWentToBillsText" }, -- 9 + { "walk_npc", 1, { "right", "down", "down", "down", + "down", "down", "down" } }, -- 10 + { "hide_object", "CERULEAN_CITY", "CERULEANCITY_RIVAL" }, -- 11 + { "jump", "end" }, -- 12 + { "show_text", "_CeruleanCityRivalIWentToBillsText" }, -- 13 }, }, } diff --git a/data/scripts/story2.lua b/data/scripts/story2.lua index 9156d1f5..43a98f34 100644 --- a/data/scripts/story2.lua +++ b/data/scripts/story2.lua @@ -445,33 +445,67 @@ local function engageSuperNerd(game, ow, onDone) ow:engageTrainer(nerd, onDone) end -local function mtMoonFossil(itemId, otherName) +-- MtMoonB2FMoveSuperNerdScript: player-near-dome coords walk RIGHT then +-- UP (MoveRight falls through into MoveUp); near-helix walks UP only. +local function mtMoonNerdWalk(px, py, itemId) + if (px == 12 and py == 7) or (px == 11 and py == 6) or (px == 12 and py == 5) then + return { "right", "up" } + end + if (px == 13 and py == 7) or (px == 14 and py == 6) or (px == 14 and py == 5) then + return { "up" } + end + return (itemId == "DOME_FOSSIL") and { "right", "up" } or { "up" } +end + +-- scripts/MtMoonB2F.asm Dome/Helix fossil text_asm + MoveSuperNerd + +-- SuperNerdTakesOtherFossil: pick one, hide it, walk the nerd to the +-- other, "All right. Then this is mine!", hide the other. +local function mtMoonFossil(itemId, otherName, gotFlag) return function(game, ow, npc, done) local TextBox = require("src.render.TextBox") - local ChoiceBox = require("src.ui.ChoiceBox") - if game.save.flags.EVENT_GOT_A_FOSSIL then - game.stack:push(TextBox.new(game, "You already took\na fossil.", done)) + local t = game.data.text + local flags = game.save.flags + if flags.EVENT_GOT_DOME_FOSSIL or flags.EVENT_GOT_HELIX_FOSSIL then + done() return end - -- can't grab a fossil until the Super Nerd is beaten -- he intercepts if not superNerdBeaten(ow) then engageSuperNerd(game, ow, done) return end - local name = game.data.items[itemId].name - game.stack:push(TextBox.new(game, ("You want the\n%s?"):format(name), function() - game.stack:push(ChoiceBox.new(game, function(yes) - if not yes then done() return end - game.save.inventory[itemId] = 1 - game.save.flags.EVENT_GOT_A_FOSSIL = true - local Commands = require("src.script.Commands") - local ctx = { save = game.save, overworld = ow, game = game } - Commands.hide_object(ctx, "MT_MOON_B2F", npc.def.name) - Commands.hide_object(ctx, "MT_MOON_B2F", otherName) + local want = (itemId == "DOME_FOSSIL") + and (t._MtMoonB2FDomeFossilYouWantText or "You want the\nDOME FOSSIL?") + or (t._MtMoonB2FHelixFossilYouWantText or "You want the\nHELIX FOSSIL?") + game.stack:push(TextBox.new(game, want, nil, { choice = function(yes) + if not yes then done() return end + if not require("src.inventory.Bag").add(game.save, itemId, 1) then game.stack:push(TextBox.new(game, - ("%s got the\n%s!"):format(game.save.player.name, name), done)) - end)) - end)) + t._MtMoonB2FYouHaveNoRoomText or "Look, you've got\nno room for this.", + done)) + return + end + local idef = game.data.items[itemId] + game.stringBuffer = idef and idef.name or itemId + require("src.core.Sound").play(game.data, "Get_Key_Item") + local dirs = mtMoonNerdWalk(ow.player.cellX, ow.player.cellY, itemId) + game.stack:push(TextBox.new(game, + t._MtMoonB2FReceivedFossilText + or ("{PLAYER} got the\n" .. game.stringBuffer .. "!"), + function() + local Commands = require("src.script.Commands") + Commands.hide_object( + { save = game.save, overworld = ow, game = game }, + "MT_MOON_B2F", npc.def.name) + flags[gotFlag] = true + ow.runner:run({ + { "walk_npc", 1, dirs }, + { "text_opts", { auto = true } }, + { "show_text", "_MtMoonB2FSuperNerdThenThisIsMineText" }, + { "play_sound", "Get_Key_Item" }, + { "hide_object", "MT_MOON_B2F", otherName }, + }, { onDone = done }) + end)) + end })) end end @@ -486,8 +520,10 @@ M.MT_MOON_B2F = { return false end, talk = { - TEXT_MTMOONB2F_DOME_FOSSIL = mtMoonFossil("DOME_FOSSIL", "MTMOONB2F_HELIX_FOSSIL"), - TEXT_MTMOONB2F_HELIX_FOSSIL = mtMoonFossil("HELIX_FOSSIL", "MTMOONB2F_DOME_FOSSIL"), + TEXT_MTMOONB2F_DOME_FOSSIL = mtMoonFossil( + "DOME_FOSSIL", "MTMOONB2F_HELIX_FOSSIL", "EVENT_GOT_DOME_FOSSIL"), + TEXT_MTMOONB2F_HELIX_FOSSIL = mtMoonFossil( + "HELIX_FOSSIL", "MTMOONB2F_DOME_FOSSIL", "EVENT_GOT_HELIX_FOSSIL"), }, } @@ -822,6 +858,10 @@ M.DAYCARE = { if def then mon.stats = Stats.calc(def, mon.level, mon.dvs, mon.statExp) mon.hp = mon.stats.hp + -- Daycare.asm: WriteMonMoves with wLearningMovesFromDayCare + local Pokemon = require("src.pokemon.Pokemon") + Pokemon.learnMovesFromDayCare( + game.data, mon, def, startLevel, newLevel) end table.insert(game.save.party, mon) game.save.daycare = nil diff --git a/data/scripts/story4.lua b/data/scripts/story4.lua index 30b2827e..9ee5856f 100644 --- a/data/scripts/story4.lua +++ b/data/scripts/story4.lua @@ -212,30 +212,31 @@ M.COPYCATS_HOUSE_2F = { talk = { TEXT_COPYCATSHOUSE2F_COPYCAT = function(game, ow, npc, done) local t = text(game) + local Bag = require("src.inventory.Bag") if game.save.flags.EVENT_GOT_TM31 then - push(game, t._CopycatsHouse2FCopycatWhirlingText - or "Huh? Huh? Huh?", done) + push(game, t._CopycatsHouse2FCopycatTM31Explanation2Text, done) return end - if (game.save.inventory.POKE_DOLL or 0) > 0 then - ask(game, t._CopycatsHouse2FCopycatPokeDollText - or "Oh wow!\nA POKé DOLL!\fFor me?\nCan I have it?", function(yes) - if not yes then done() return end - game.save.inventory.POKE_DOLL = game.save.inventory.POKE_DOLL - 1 - if game.save.inventory.POKE_DOLL == 0 then - game.save.inventory.POKE_DOLL = nil - end - if not require("src.inventory.Bag").add(game.save, "TM_MIMIC", 1) then - push(game, "You don't have\nroom for TM31!", done) + -- Mimic line first; auto-trade POKE DOLL for TM31 (no YES/NO). + push(game, t._CopycatsHouse2FCopycatDoYouLikePokemonText, function() + if (game.save.inventory.POKE_DOLL or 0) <= 0 then + done() + return + end + push(game, t._CopycatsHouse2FCopycatTM31PreReceiveText, function() + if not Bag.add(game.save, "TM_MIMIC", 1) then + push(game, t._CopycatsHouse2FCopycatTM31NoRoomText, done) return end + game.stringBuffer = game.data.items.TM_MIMIC.name + require("src.core.Sound").play(game.data, "Get_Item1") + Bag.remove(game.save, "POKE_DOLL", 1) game.save.flags.EVENT_GOT_TM31 = true - push(game, ("%s got\nTM31!"):format(game.save.player.name), done) + push(game, t._CopycatsHouse2FCopycatReceivedTM31Text, function() + push(game, t._CopycatsHouse2FCopycatTM31Explanation1Text, done) + end) end) - return - end - push(game, t._CopycatsHouse2FCopycatText - or "I like to mimic\npeople!", done) + end) end, }, } diff --git a/data/scripts/story5.lua b/data/scripts/story5.lua index e460a953..5027640e 100644 --- a/data/scripts/story5.lua +++ b/data/scripts/story5.lua @@ -457,6 +457,19 @@ end -- EVENT_ROUTE22_RIVAL_WANTS_BATTLE in Oak's lab (expired by Pewter -- Gym) and again in Viridian Gym; we derive the same windows from the -- surrounding story flags so old saves work too. +-- Rival1Exit → Viridian (right/down); Rival2Exit → League (left). +local function route22ExitDirs(n, py) + if n == 2 then + if py == 5 then return { "left", "left", "left", "left" } end + return { "left", "left", "left" } + end + if py == 5 then + return { "right", "right", "down", "down", "down", "down", "down" } + end + return { "up", "right", "right", "right", + "down", "down", "down", "down", "down", "down" } +end + local function route22Scene(n, objIndex, objName, oppClass, baseParty, beatFlag, py) return { { "show_object", "ROUTE_22", objName }, -- 1 @@ -468,7 +481,7 @@ local function route22Scene(n, objIndex, objName, oppClass, baseParty, beatFlag, { "set_flag", beatFlag }, -- 7 { "show_text", "_Route22Rival" .. n .. "DefeatedText" }, -- 8 { "show_text", "_Route22RivalAfterBattleText" .. n }, -- 9 - { "move_npc_to", objIndex, 25, 5 }, -- 10 + { "walk_npc", objIndex, route22ExitDirs(n, py) }, -- 10 { "hide_object", "ROUTE_22", objName }, -- 11 } end @@ -495,6 +508,14 @@ M.ROUTE_22 = { -- Cerulean City: the Nugget Bridge rival ambush (CeruleanCityCoords2), -- the TM28 rocket thief with his forced-fight cells (Coords1), and the -- cave guard who steps aside once you are Champion. +-- Post-battle: Bill text then CeruleanCityMovement3/4 into town. +local function ceruleanRivalExitDirs(px) + if px == 20 then + return { "right", "down", "down", "down", "down", "down", "down" } + end + return { "left", "down", "down", "down", "down", "down", "down" } +end + local function ceruleanRivalScene(px, py) return { { "show_object", "CERULEAN_CITY", "CERULEANCITY_RIVAL" }, -- 1 @@ -502,41 +523,43 @@ local function ceruleanRivalScene(px, py) { "face_object", 1, "down" }, -- 3 { "show_text", "_CeruleanCityRivalPreBattleText" }, -- 4 { "rival_battle", "OPP_RIVAL1", 7 }, -- 5 - { "jump_if_false", 10 }, -- 6 + { "jump_if_false", 11 }, -- 6 { "set_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 7 { "show_text", "_CeruleanCityRivalDefeatedText" }, -- 8 - { "move_npc_to", 1, 20, 2 }, -- 9 - { "hide_object", "CERULEAN_CITY", "CERULEANCITY_RIVAL" }, -- 10 + { "show_text", "_CeruleanCityRivalIWentToBillsText" }, -- 9 + { "walk_npc", 1, ceruleanRivalExitDirs(px) }, -- 10 + { "hide_object", "CERULEAN_CITY", "CERULEANCITY_RIVAL" }, -- 11 } end -- scripts/CeruleanCity.asm CeruleanCityRocketText: fight the thief, --- then he returns TM28 (DIG) and hurries off +-- then he returns TM28 (DIG) and hurries off. CeruleanHideRocket +-- (CeruleanCity_2.asm) is GBFadeOutToBlack → Show GUARD1 / Hide GUARD2 / +-- Hide ROCKET → GBFadeInFromBlack — not a bare hide_object. local rocketRows = { { "face_player" }, -- 1 { "check_flag", "EVENT_GOT_TM28" }, -- 2 - { "jump_if_true", 15 }, -- 3 + { "jump_if_true", 15 }, -- 3 → CeruleanHideRocket { "check_flag", "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, -- 4 { "jump_if_true", 9 }, -- 5 { "show_text", "_CeruleanCityRocketText" }, -- 6 { "start_battle", "trainer", "OPP_ROCKET", 5 }, -- 7 - { "jump_if_false", 18 }, -- 8 + { "jump_if_false", "end" }, -- 8 { "show_text", "_CeruleanCityRocketIllReturnTheTMText" }, -- 9 { "set_flag", "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, -- 10 { "give_item", "TM_DIG", 1, false }, -- 11 (row 13 prints) { "set_flag", "EVENT_GOT_TM28" }, -- 12 { "show_text", "_CeruleanCityRocketReceivedTM28Text" }, -- 13 { "show_text", "_CeruleanCityRocketIBetterGetMovingText" }, -- 14 - { "hide_object", "CERULEAN_CITY", "CERULEANCITY_ROCKET" }, -- 15 - -- CeruleanHideRocket (scripts/CeruleanCity_2.asm) does all three behind - -- one fade: the ROCKET goes, GUARD1 (28,12) appears and GUARD2 (27,12) - -- disappears. GUARD2 stands on the only walkable neighbour of the - -- trashed house's south door, which is one of the two ways through the - -- fence dividing Cerulean, so this swap is what reconnects the city. - -- Bill's ticket performs the same swap (data/scripts/story.lua), and - -- both are idempotent, so whichever the player reaches first opens it. + { "fade", "out" }, -- 15 GBFadeOutToBlack + -- CeruleanHideRocket while black: GUARD1 (28,12) appears, GUARD2 + -- (27,12) and the ROCKET go. GUARD2 blocks the trashed-house south + -- door neighbour — the swap reconnects the city (Bill's ticket does + -- the same in story.lua; either route is enough). { "show_object", "CERULEAN_CITY", "CERULEANCITY_GUARD1" }, -- 16 { "hide_object", "CERULEAN_CITY", "CERULEANCITY_GUARD2" }, -- 17 + { "hide_object", "CERULEAN_CITY", "CERULEANCITY_ROCKET" }, -- 18 + { "fade", "in" }, -- 19 GBFadeInFromBlack } M.CERULEAN_CITY = { diff --git a/data/scripts/victories.lua b/data/scripts/victories.lua index 45c620c5..a31bbbb0 100644 --- a/data/scripts/victories.lua +++ b/data/scripts/victories.lua @@ -12,6 +12,12 @@ -- "; deactivate gym trainers" / SetEventRange in the other gyms, and -- FightingDojo.asm SetEventRange EVENT_BEAT_KARATE_MASTER .. -- EVENT_BEAT_FIGHTING_DOJO_TRAINER_3). +-- +-- `dialogue` is the end-battle + post-battle text chain each gym leader +-- runs (SaveEndBattleTextPointers then the map's *PostBattle / ReceiveTM +-- script). Leaders are not def_trainers entries, so engageTrainer has +-- no header.won — checkVictoryRewards shows this chain instead of a +-- synthetic "received badge/TM" stub. local function range(prefix, first, last) local t = {} @@ -31,28 +37,76 @@ return { hide = { { "PEWTER_CITY", "PEWTERCITY_YOUNGSTER" }, { "ROUTE_22", "ROUTE22_RIVAL1" }, + }, + dialogue = { + "_PewterGymBrockReceivedBoulderBadgeText", + "_PewterGymBrockBoulderBadgeInfoText", + "_PewterGymBrockWaitTakeThisText", + "_PewterGymReceivedTM34Text", + "_TM34ExplanationText", } }, ["OPP_MISTY#1"] = { badge = "CASCADEBADGE", flag = "EVENT_BEAT_MISTY", item = "TM_BUBBLEBEAM", - deactivate = range("EVENT_BEAT_CERULEAN_GYM_TRAINER_", 0, 1) }, + deactivate = range("EVENT_BEAT_CERULEAN_GYM_TRAINER_", 0, 1), + dialogue = { + "_CeruleanGymMistyReceivedCascadeBadgeText", + "_CeruleanGymMistyCascadeBadgeInfoText", + "_CeruleanGymMistyReceivedTM11Text", + } }, ["OPP_LT_SURGE#1"] = { badge = "THUNDERBADGE", flag = "EVENT_BEAT_LT_SURGE", item = "TM_THUNDERBOLT", - deactivate = range("EVENT_BEAT_VERMILION_GYM_TRAINER_", 0, 2) }, + deactivate = range("EVENT_BEAT_VERMILION_GYM_TRAINER_", 0, 2), + dialogue = { + "_VermilionGymLTSurgeReceivedThunderBadgeText", + "_VermilionGymLTSurgeThunderBadgeInfoText", + "_VermilionGymLTSurgeReceivedTM24Text", + "_TM24ExplanationText", + } }, ["OPP_ERIKA#1"] = { badge = "RAINBOWBADGE", flag = "EVENT_BEAT_ERIKA", item = "TM_MEGA_DRAIN", - deactivate = range("EVENT_BEAT_CELADON_GYM_TRAINER_", 0, 6) }, + deactivate = range("EVENT_BEAT_CELADON_GYM_TRAINER_", 0, 6), + dialogue = { + "_CeladonGymErikaReceivedRainbowBadgeText", + "_CeladonGymRainbowBadgeInfoText", + "_CeladonGymReceivedTM21Text", + "_TM21ExplanationText", + } }, ["OPP_KOGA#1"] = { badge = "SOULBADGE", flag = "EVENT_BEAT_KOGA", item = "TM_TOXIC", - deactivate = range("EVENT_BEAT_FUCHSIA_GYM_TRAINER_", 0, 5) }, + deactivate = range("EVENT_BEAT_FUCHSIA_GYM_TRAINER_", 0, 5), + dialogue = { + "_FuchsiaGymKogaReceivedSoulBadgeText", + "_FuchsiaGymKogaSoulBadgeInfoText", + "_FuchsiaGymKogaReceivedTM06Text", + "_FuchsiaGymKogaTM06ExplanationText", + } }, ["OPP_SABRINA#1"] = { badge = "MARSHBADGE", flag = "EVENT_BEAT_SABRINA", item = "TM_PSYWAVE", - deactivate = range("EVENT_BEAT_SAFFRON_GYM_TRAINER_", 0, 6) }, + deactivate = range("EVENT_BEAT_SAFFRON_GYM_TRAINER_", 0, 6), + dialogue = { + "_SaffronGymSabrinaReceivedMarshBadgeText", + "_SaffronGymSabrinaMarshBadgeInfoText", + "_SaffronGymSabrinaReceivedTM46Text", + "_TM46ExplanationText", + } }, ["OPP_BLAINE#1"] = { badge = "VOLCANOBADGE", flag = "EVENT_BEAT_BLAINE", item = "TM_FIRE_BLAST", - deactivate = range("EVENT_BEAT_CINNABAR_GYM_TRAINER_", 0, 6) }, + deactivate = range("EVENT_BEAT_CINNABAR_GYM_TRAINER_", 0, 6), + dialogue = { + "_CinnabarGymBlaineReceivedVolcanoBadgeText", + "_CinnabarGymBlaineVolcanoBadgeInfoText", + "_CinnabarGymBlaineReceivedTM38Text", + "_CinnabarGymBlaineTM38ExplanationText", + } }, ["OPP_GIOVANNI#3"] = { badge = "EARTHBADGE", flag = "EVENT_BEAT_GIOVANNI", item = "TM_FISSURE", - deactivate = range("EVENT_BEAT_VIRIDIAN_GYM_TRAINER_", 0, 7) }, + deactivate = range("EVENT_BEAT_VIRIDIAN_GYM_TRAINER_", 0, 7), + dialogue = { + "_ViridianGymGiovanniReceivedEarthBadgeText", + "_ViridianGymGiovanniEarthBadgeInfoText", + "_ViridianGymGiovanniReceivedTM27Text", + "_ViridianGymGiovanniTM27ExplanationText", + } }, -- Silph Co. Giovanni: unlocks the president's Master Ball gift ["OPP_GIOVANNI#2"] = { flag = "EVENT_BEAT_SILPH_CO_GIOVANNI" }, diff --git a/docs/launcher.md b/docs/launcher.md new file mode 100644 index 00000000..57b14d76 --- /dev/null +++ b/docs/launcher.md @@ -0,0 +1,163 @@ +# Launcher + +The launcher is `src/import/RomImporter.lua`, the first-run / title screen +that runs before `Game:load`. Besides ROM import (see the file's own header) +it hosts a tabbed shell covering per-game save slots and a mod manager. This +file documents the runtime model; the visual spec lives separately. + +## Android multi-ROM import + +On Android, `love.system.pickFile()` opens the Storage Access Framework +picker (`GameActivity.showRomFilePicker`); the chosen file is copied to +`picked_rom.gb` in the app save directory. `RomImporter` then imports on +refocus / Choose via `findPendingRom`: only a 1 MiB `.gb` whose SHA-1 maps +to a version that is **not** yet ready counts as pending. A leftover +`picked_rom.gb` from Red therefore cannot block Blue's Choose (issue #167). +After a successful import the consumed save-dir `.gb` is removed. + +**Manual check (device/emulator):** import Red → switch to Blue → Choose → +system file picker must appear (not a silent Red re-extract) → pick Blue → +Blue becomes ready beside Red. + +## Tab structure + +`self.tab` is one of `"red"`, `"blue"`, `"yellow"`, `"mods"`. The tab bar +draws one chip per game plus a MODS chip and rebuilds `self.tabRects` every +frame so `mousepressed` can dispatch clicks; switching tabs mid-import is +allowed (a dropped ROM still routes by SHA-1 regardless of which tab shows). + +- A game tab (`_drawGamePanel`) shows the ROM card, the SAVE FILES card, the + Play button, and the SAVE SLOT card in a responsive two-column grid (see + Responsiveness). The MODS tab (`_drawModsPanel`) shows the mod list instead. +- The self-updater banner (`self.Check`, see `docs/updater.md`) draws as a + centered pill in a reserved band just above the footer, on every tab. That + position is unchanged by this redesign, so `docs/updater.md` needed no edits. + +## Save slot model + +All slot I/O lives in `src/core/SaveData.lua` and goes through the same fs +abstraction (`persistFs`) every other save/options call uses, so portable +mode (an `io.*` filesystem used when `portable.txt` marks the install) +keeps working unchanged. + +- **Files.** A version's playthroughs live under `saves//`, one file + per slot: `saves//slot1.lua` plus a rolling `.bak` and staged + `.tmp` witness (`slotNames`), mirroring the write/recovery discipline + `SaveData.save`/`load` already use for the flat legacy file. Slot ids match + `slot%d+`; `createSlot` allocates one past the highest existing number so a + reused id can never collide with a lingering file. +- **Registry.** The ordered slot list and which one is active persist in + `options.lua` (via the existing `SaveData.loadOptions`/`saveOptions`): + `options.saveSlots = { [version] = { list = {"slot1", ...}, active = "slot1" } }`. +- **Active slot resolution.** `saveNames(version)`, the function every + existing caller (`TitleState` hasSave/load/save, recovery order) already + goes through, now resolves the *active* slot instead of a fixed flat name. + Resolved once per version per process (`ensureVersionSlots`, cached in + `activeSlotCache`/`slotsChecked`): a registry entry wins; otherwise a lazy + legacy migration may create one; otherwise the flat legacy path is used + (`save.lua` / `save_blue.lua`), so a pre-slots install keeps working as before. +- **Legacy migration.** One-time per version, lazy on first + `listSlots`/`load`/`saveNames` call (`tryMigrateLegacy`): if a flat legacy + file exists and no `saves//` registry does, its main + `.bak` are + copied into `saves//slot1.lua(.bak)`, verified readable + (`decodeSlot`: main, then `.tmp`, then `.bak`), and only then are the + originals removed and `slot1` registered as active. A copy that fails to + verify leaves the originals in place; migration never loses data. + +The launcher-facing API: +- `SaveData.listSlots(version)` -> array of `{id, exists, name, meta}` for + every registered slot. `name` is the save's player name, or `nil` for an + empty slot; `meta` is `{badges, timeText, dexCount}` (the same fields the + title screen's `ContinueInfo` shows) or `nil`. The pure part, + `SaveData.slotSummary(save)`, is unit-testable with no filesystem. +- `SaveData.setActiveSlot(version, slotId)` registers the id if new, persists + it as active, and updates the process cache so the very next save/load + lands there. The launcher calls this the moment a slot row is clicked + (`RomImporter:_selectSlot`); pressing Play needs no signature change, since + `Game.lua`/`main.lua` still just call `SaveData.load()`/`save()`. +- `SaveData.createSlot(version)` -> new slot id, registered but with **no + save file written**. An empty slot means the title screen offers NEW GAME + only, which needs no further changes. + +## Launcher mod manager + +`src/mods/LauncherMods.lua` is a launcher-only read of the mod set. It runs +before `Game:load`, so **it never loads a mod's entry chunk**; only +`manifest.json` is read and validated (`src/mods/Manifest.validate`), the way +`Loader:_discover` finds mods without running them. The real loader +(`src/mods/Loader.lua`) still owns the actual load at boot. + +- `LauncherMods.list()` scans `mods/` one level deep (first id wins on a + duplicate) and returns one row per mod: + `{id, name, version, badge, description, enabled, status, statusDetail}`. + `badge` is the manifest's `category`, falling back to `profile`, then + `"MOD"`, uppercased. `enabled` reads `options.mods[id]` (missing means + enabled, matching the loader's own default). +- `status` is `"ok"`, `"warn"`, or `"conflict"`, computed by the pure + `LauncherMods.deriveList`/`statusFor` against `ManagerState.resolveToggle` + and the validated manifests: `conflict` when enabling this mod collides + with another enabled one; `warn` for an out-of-range `game_version` or an + absent/disabled/wrong-version hard dependency; `ok` otherwise. Having no + `love.*` calls, this half is table-driven by the test suite on its own. +- `LauncherMods.setEnabled(id, bool)` persists `options.mods[id]` as a plain + boolean, the exact shape `Loader:_saveState` writes, so the running game + and the in-game `ManagerState` see the change on next boot. The mods panel + calls this on every toggle and re-derives the list right away + (`RomImporter:_refreshMods`) so a status change (e.g. a new conflict) + shows without waiting for a reload. +- `LauncherMods.installZip(path)` mounts the archive with + `love.filesystem.mount`, locates the mod root via `locateRoot` (manifest at + the zip root, or inside one top-level folder), validates its manifest, and + copies the tree into the save-dir `mods//` before unmounting. Rejects a + duplicate of an already-installed mod id, and accepts either an external + path string or a LOVE `DroppedFile`, staging a dropped file into a save-dir + temp first (mount only reaches save-dir-relative paths), the same way + `RomImporter` handles a dropped ROM. A failed copy rolls its partial tree + back, and every path unmounts and clears the staged temp file. + +## Import / Export save + +The SAVE FILES card wires a raw Gen1 `.sav` battery image to the save slots +through `src/import/SaveFileIO.lua`, which sits on top of +`src/save_convert/SaveConvert.lua` and the slot API in `SaveData`. + +- **Import save** is live once the game's ROM is imported (playable). It opens + a native `.sav` picker (`chooseSav`, the per-OS dialogs mirror `chooseZip`; + Android has no picker and shows a drop hint). `SaveFileIO.importToSlot` + reads the bytes (an absolute path, a dropped LOVE file, or raw bytes), + guards the 32768-byte size, runs `SaveConvert.importSav` (which also rejects + a bad main-data checksum), then registers a fresh slot (`SaveData.createSlot`), + writes it (`SaveData.writeSlot`), and makes it active (`SaveData.setActiveSlot`). + The meta stamp is re-stamped off `gen1_import` to the current numeric format + so `SaveData.load`'s migration pass accepts the slot. On success the SAVE SLOT + panel is refreshed with the new slot selected. +- **Export save** is live only when the active slot actually holds a save + (checked against `listSlots`). `SaveFileIO.exportActiveSlot` loads the active + slot, encodes it back with `SaveConvert.exportSav` (a slot never keeps + `rawImport`, so this is a zero-filled template export, which is valid), and + writes `exports/gen1recomp--.sav` in the save directory + (`love.filesystem.createDirectory("exports")`). It returns the absolute path + (`love.filesystem.getSaveDirectory()`), which the notice line shows with a + desktop "Open folder" affordance (`love.system.openURL("file://" .. dir)`). +- **Drag-drop.** `filedropped` routes a `.sav` to the import path for the + currently active game tab; when a non-game tab (mods, or the locked yellow + placeholder) is showing it defaults to red, the always-present first game + (`_savedropTarget`). `.gb` (ROM) and `.zip` (mod) routing is unchanged. +- **Failure UX.** Every error path (wrong size, bad checksum, write failure, + nothing to export, ROM not imported yet) surfaces as a red notice line on the + card. Nothing raises and nothing silently no-ops. + +`SaveFileIO` is love-free enough to unit-test through the same in-memory +filesystem stub the slot backend uses (`tests/engine/save_file_io_tests.lua`). + +## Responsiveness + +Every measurement derives from `love.graphics.getDimensions()` each frame +plus the existing global scale `s = clamp(height / 768, 0.7, 1.6)`; nothing +assumes a fixed window size. The game panel's two-column grid (ROM/SAVE +FILES/Play on the left, SAVE SLOT on the right) collapses to one stacked +column, slot card below Play, when the window is too narrow for both +`~300 * s`-wide columns. The save-slot list and the mod list both scroll +(wheel, or drag on touch/desktop) clamped to their own content extent, +recomputed every draw. The tab bar labels only the active chip so it stays +narrow-safe, and content caps out at `~1440 * s` wide, centered. diff --git a/docs/modding.md b/docs/modding.md index 222ddead..caac2c79 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -18,6 +18,111 @@ Regenerate the reference straight into a wiki checkout: luajit tools/gen_registry_docs.lua ../pokemon-gen1-recomp-project.wiki ``` +## Rendering pipelines + +Most registries hand the engine *content*. `render_pipelines` hands it +*drawing*: a pipeline is a display mode a mod owns, which may replace the +overworld's world pass with geometry of its own and/or post-process the +finished image. `mods/voxel_world` is the worked example — a 3D diorama +overworld plus a tilt-shift miniature pass, in about 120 lines of glue over +its renderer. + +A record declares what the mode *is*; the engine +(`src/render/Pipelines.lua`) supplies everything about *being a display +mode*: the OFF/1/2/3 ladder, an options row next to TILT, a hotkey, +persistence in `save.options.pipelines`, and the rule that a world pipeline +and the engine's own TILT are mutually exclusive. + +```lua +mod.content.render_pipelines:register("diorama", { + label = "DIORAMA", -- options row label + levels = { "OFF", "15", "35", "50" }, -- ladder; defaults to OFF/ON + hotkey = "6", -- checked after the engine's keys + priority = 20, -- highest eligible wins the world + available = function() return Renderer3D.ok() end, + update = function(dt, level) Camera.ease(dt, level) end, + drawWorld = function(ctx) return renderScene(ctx) end, +}) +``` + +Three draw stages, each optional; a record needs at least one: + +| stage | signature | runs | +| --- | --- | --- | +| `drawWorld` | `(ctx) -> canvas \| nil` | instead of the flat/tilt world pass | +| `worldPresent` | `(canvas, ctx) -> canvas` | over the world, **before** the UI composites | +| `present` | `(canvas, ctx) -> canvas` | over the whole frame, world and UI alike | + +`worldPresent` is the one to reach for when an effect must leave dialog +boxes and menus crisp — a depth-of-field or colour grade on the world only. +`present` is for effects that genuinely own the screen, like a CRT curve. + +`ctx` carries the frame: `state`, `cam`, `vw`/`vh` (world-pixel view), +`width`/`height` (window pixels), `scale`, `level`, `paletteFor(map)` and +`spriteColors(map)`. It also carries `ctx.drawFx(project, scale)` — call it +with your own projection and the engine draws every active field effect +(the "!" bubble, the Poké Center heal machine, the Fly bird, the fishing +rod, Rock Tunnel darkness) at its correct anchor under your camera. There +is exactly one copy of each effect, so a new engine effect works in your +pipeline without you touching anything. + +Three rules worth knowing: + +- **`gate` governs input, never the draw.** It decides whether the player + may *change* the mode (default: free-roam overworld only). A mode that + stopped rendering during a warp would flash the flat 2D world every time + the player walked through a door. +- **`available` is re-read every frame** and is the only thing that decides + whether the mode can render at all. Answer `false` on a headless run or a + driver with no depth canvas and the engine silently keeps the vanilla 2D + path — which is why shipping a pipeline enabled is safe. +- **A callback that throws retires its pipeline**, attributed to your mod in + the manager's error feed, and the frame falls back to 2D. A broken + renderer costs the player a display mode, never the game. + +Returning `nil` from `drawWorld` is a normal answer meaning "not this +frame"; the engine draws the vanilla world instead. + +## Battle sprite scaling + +The enemy's front pic draws at 1x and the player's back pic at 2x, the way +the Game Boy did. A mod can override either, per species or per image. + +Per species, on the `pokemon` record: + +```lua +-- MEW's back pic renders 1.5x; its front pic is untouched +mod.content.pokemon:patch("MEW", { battleScaleBack = 1.5 }) +``` + +`battleScaleFront` scales the enemy pic, `battleScaleBack` the player pic; +both take a number in `0.25 .. 4.0`. + +Per image, on the `battle_sprite_scales` registry, keyed by the asset path +exactly as the data references it: + +```lua +mod.content.battle_sprite_scales:register("abra_back", { + path = "assets/generated/battle/back/abrab.png", + scale = 1.5, +}) +``` + +An image-level entry beats the species scale for that one pic, and it is +the only way to scale a pic that is not species-keyed — the player's +trainer back sprite, held on screen until "Go!", is a bare image path. + +The resolution order at draw time is **image-level → species-level → +default** (1x front, 2x back). + +- **The pic stays grounded at every scale.** The player pic keeps its feet + flush on the text-box top (`y = 96`); the enemy pic keeps its bottom edge + and horizontal centre pinned in its 7×7 slot. A larger pic grows upward + and outward from that anchor, never off the shelf. +- **Scaling composes with the send-out grow.** The `AnimateSendingOutMon` + ball-to-pic grow multiplies your scale through each stage, so a rescaled + mon still grows into place from the ball, grounded the whole way. + ## Developer console Boot with developer mode on to unlock the in-game console and hot-reload diff --git a/docs/updater.md b/docs/updater.md new file mode 100644 index 00000000..5db55f3d --- /dev/null +++ b/docs/updater.md @@ -0,0 +1,126 @@ +# Updater + +A fused build (`love.filesystem.isFused()` true) ships a bundled `game.love` +baked into the executable, but that bundled copy is only ever the *fallback*. +On every launch, before anything else runs, `Boot.run` (`src/update/Boot.lua`) +looks in the save directory's `updates/` folder for a downloaded +`gen1recomp-X.Y.Z.love` payload that is both strictly newer than the bundled +engine version and runnable on this shell. If one qualifies, it is mounted +over `/` (so its files win over the fused source for every subsequent +`require`) and chainloaded in place: the payload's `main.lua` and `love.load` +run as if they had shipped in the executable. A dev/source checkout is never +fused, so `Boot.run` no-ops there and the working tree always runs itself. + +The pieces are deliberately layered so the risky part is small. `Boot.select` +is a pure function (no `love.*` calls) that, given probed candidates and the +bundled `engine`/`shell`, decides what to run and what stale payloads to +delete. `Boot.probePayload` mounts one archive at an isolated mountpoint and +reads its `src/core/Version.lua` with `loadstring` (never `require`, so it is +never cached as a module) to learn its `engine` and `minShell`. `Boot.run` +orchestrates the crash guard, enumeration, selection, and the mount + +chainload, with full rollback on any failure. Checking for and fetching a +new payload is a separate, slower path: `src/update/Check.lua` is a thin +main-thread state machine the launcher screen polls, while the curl calls, +JSON parsing, and sha256 verification run on a background `love.thread` +(`src/update/check_worker.lua`) so a hung network call never blocks a frame. + +## Version.lua fields + +`src/core/Version.lua` carries three fields the updater reads directly (the +existing `modApi`, `linkProtocol`, `saveFormat`, and `cache` fields are +untouched): + +- `engine` - the semver release, e.g. `"1.4.0"`. The repo default is the + `"0.0.0-dev"` placeholder; CI stamps the real `X.Y.Z` into the packed + `game.love` only, never the working tree. A `"0.0.0-dev"` engine always + reports itself up to date (it never chases a release, and it never counts + as a valid payload to chainload). +- `shell` - the native-shell contract this build's fused executable + implements. +- `minShell` - the lowest shell contract required to *run* this payload. + +Bump `minShell` only when a payload needs something the currently-shipped +native shell cannot provide, for example a LOVE version bump, a new required +system binary, or a change to `love.run` itself (see Known limitations +below). An older shell refuses to chainload a payload whose `minShell` +exceeds the shell it provides; `Boot.select` keeps that payload in `updates/` +rather than deleting it, in case a future shell upgrade can run it, and +`Check`'s worker reports `needs_full` so the player is pointed at a full +installer instead. Do not bump `minShell` for an ordinary Lua/data release; +that is exactly the case the updater exists to avoid a reinstall for. + +## Release assets + +Each tagged release `vX.Y.Z` carries the existing per-platform archives +(`gen1recomp-X.Y.Z-macos.zip`, `-windows.zip`, `-linux.zip`, +`-android.apk`) plus two assets the updater itself consumes: + +- `gen1recomp-X.Y.Z.love` - the payload, matched by the exact pattern + `gen1recomp-.love` (see `isPayloadName` in `Boot.lua` and + `Check.parseRelease`). +- `sha256sums.txt` - `shasum -a 256` output (` `, bare + filenames) covering at least the `.love` payload. `Check.parseSums` + tolerates a leading `*` binary marker and a `./` prefix but expects the + filename otherwise to match the asset name exactly. + +A release missing either asset is treated as "no in-place update available": +`Check` reports `needs_full` and sends the player to `Check.releaseUrl()` +(`https://github.com/bryanthaboi/pokemon-gen1-recomp-project/releases/latest`). + +## Save-directory layout + +Under the save directory (identity `pokemon-love2d`): + +``` +updates/gen1recomp-.love downloaded payload(s) +updates/pending.txt crash-guard marker +``` + +`pending.txt` holds the filename of the payload currently being chainloaded. +`Boot.run`'s `chainload` writes it immediately before mounting, and removes it +on both a successful handoff and a clean rollback. If it is still present the +*next* time `Boot.run` starts, the previous boot died mid-handoff, so that +named payload is distrusted: it and the marker are deleted before candidates +are enumerated. Boot may still fall back to an older valid payload, or to the +bundled game, in that case. + +## Update flow + +1. **Boot** (every launch, fused builds only): crash-guard check, enumerate + and probe every `updates/*.love`, pick the highest engine that is + strictly newer than the bundled one and whose `minShell` this shell + satisfies, delete stale payloads, chainload the winner (or run the + bundled game if none qualifies). +2. **Check** (launcher screen): `Check.start()` kicks off an async check + against the GitHub releases API; safe to call every frame, it is a no-op + once a check is in flight or has reached a terminal state. `Check.state()` + reports `idle | checking | uptodate | available | downloading | ready | + needs_full | error` plus the latest version and download progress. +3. **Download + verify**: on `available`, `Check.download()` tells the + worker to fetch the payload, polling the growing `.part` file for + progress. On completion the worker re-fetches `sha256sums.txt`, verifies + the payload's sha256, and probes it with `Boot.probePayload` to gate its + `minShell` against this shell's `shell`. A verified, runnable payload is + renamed into place and reported as `ready`; anything else reports + `error` or `needs_full` and leaves `updates/` clean. +4. **Restart to apply**: a `ready` payload just sits in `updates/` until the + player relaunches; the next launch's Boot step (1) is what actually + mounts and runs it. There is no in-session hot-swap. + +## Known limitations + +- **`love.run` persists across handoff.** By the time `chainload` runs, the + bundled `love.run` has already returned its stepper to LOVE; redefining the + global `love.run` from the payload's `main.lua` does not affect the loop + already driving the frame. A payload that must change `love.run` itself + needs a `minShell` bump so an older shell refuses to chainload it rather + than running with half its intended behavior. +- **Android has no in-app download transport yet.** `check_worker.lua` + shells out to curl for both the release check and the download; curl is + absent on Android, so `Check` degrades to `status = "error"` there (the + launcher UI hides on that status) and the player is directed to the + releases page via `Check.releaseUrl()` instead. +- **Dev/source runs never self-update.** `Boot.run` returns immediately when + `love.filesystem.isFused()` is false, and a working tree's `engine` is the + `"0.0.0-dev"` placeholder that always reports up to date, so a source + checkout is always "the game" itself; updating it means pulling the repo. diff --git a/main.lua b/main.lua index 08a4c46b..218c3136 100644 --- a/main.lua +++ b/main.lua @@ -56,6 +56,12 @@ local function bootGame(version) end function love.load(args) + -- Self-updater boot shell: a fused build may mount and chainload a newer + -- downloaded payload here. True means it took over, so we must stop. A + -- dev / source checkout no-ops (see src/update/Boot.lua). + local Boot = require("src.update.Boot") + if Boot.run(args) then return end + local savePath for i, a in ipairs(args or {}) do if a == "--editor" then diff --git a/mobile/ANDROID.md b/mobile/ANDROID.md index 559c1ecd..6d3251b6 100644 --- a/mobile/ANDROID.md +++ b/mobile/ANDROID.md @@ -43,9 +43,12 @@ The embedded `game.love` deliberately excludes `data/generated/`, `assets/generated/`, and any ROM. It contains the first-boot Lua importer and `tools/rom_manifest.json`. -The current importer has desktop file pickers only. A production Android -release still needs a Storage Access Framework handoff that passes the chosen -ROM to LÖVE; the APK packaging itself is data-free. +ROM import on Android uses `love.system.pickFile()` → +`GameActivity.showRomFilePicker` (Storage Access Framework), which copies the +chosen file to `picked_rom.gb` under the app save directory. `RomImporter` +imports pending (not-yet-ready) `.gb` files from that folder on Choose / +refocus; see `docs/launcher.md` (Android multi-ROM import). The APK payload +itself remains data-free (no embedded ROM or generated cache). ### SDK / NDK diff --git a/mods/examples/example_balance_tweaks/manifest.json b/mods/examples/example_balance_tweaks/manifest.json index 1bb3082a..26dad2ee 100644 --- a/mods/examples/example_balance_tweaks/manifest.json +++ b/mods/examples/example_balance_tweaks/manifest.json @@ -6,7 +6,7 @@ "entry": "main.lua", "profile": "content", "category": "BALANCE", - "game_version": ">=1.0.0 <2.0.0", + "game_version": ">=0.0.0-0 <2.0.0", "priority": 100, "dependencies": [], "optional_dependencies": [], diff --git a/mods/examples/example_dexnav/manifest.json b/mods/examples/example_dexnav/manifest.json index 7a028c3e..5aa4991b 100644 --- a/mods/examples/example_dexnav/manifest.json +++ b/mods/examples/example_dexnav/manifest.json @@ -6,7 +6,7 @@ "entry": "main.lua", "profile": "content", "category": "TOOL", - "game_version": ">=1.0.0 <2.0.0", + "game_version": ">=0.0.0-0 <2.0.0", "priority": 100, "dependencies": [], "optional_dependencies": [], diff --git a/mods/examples/example_jukebox/manifest.json b/mods/examples/example_jukebox/manifest.json index ee73465e..3bc36d68 100644 --- a/mods/examples/example_jukebox/manifest.json +++ b/mods/examples/example_jukebox/manifest.json @@ -6,7 +6,7 @@ "entry": "main.lua", "profile": "content", "category": "AUDIO", - "game_version": ">=1.0.0 <2.0.0", + "game_version": ">=0.0.0-0 <2.0.0", "priority": 100, "permissions": ["engine_internals"], "dependencies": [], diff --git a/mods/examples/example_lost_parcel/manifest.json b/mods/examples/example_lost_parcel/manifest.json index 9f22dfbc..b063acd6 100644 --- a/mods/examples/example_lost_parcel/manifest.json +++ b/mods/examples/example_lost_parcel/manifest.json @@ -6,7 +6,7 @@ "entry": "main.lua", "profile": "content", "category": "QUEST", - "game_version": ">=1.0.0 <2.0.0", + "game_version": ">=0.0.0-0 <2.0.0", "priority": 100, "permissions": ["engine_internals"], "dependencies": [], diff --git a/mods/examples/example_mini_conversion/manifest.json b/mods/examples/example_mini_conversion/manifest.json index 7664cc74..b3e52cbd 100644 --- a/mods/examples/example_mini_conversion/manifest.json +++ b/mods/examples/example_mini_conversion/manifest.json @@ -6,7 +6,7 @@ "entry": "main.lua", "profile": "total_conversion", "category": "TOTAL_CONVERSION", - "game_version": ">=1.0.0 <2.0.0", + "game_version": ">=0.0.0-0 <2.0.0", "priority": 900, "dependencies": [], "optional_dependencies": [], diff --git a/mods/examples/example_shiny_palette/manifest.json b/mods/examples/example_shiny_palette/manifest.json index 5cec1753..8c7961b7 100644 --- a/mods/examples/example_shiny_palette/manifest.json +++ b/mods/examples/example_shiny_palette/manifest.json @@ -6,7 +6,7 @@ "entry": "main.lua", "profile": "content", "category": "GRAPHICS", - "game_version": ">=1.0.0 <2.0.0", + "game_version": ">=0.0.0-0 <2.0.0", "priority": 100, "assets_transforms": "transforms.lua", "dependencies": [], diff --git a/mods/examples/example_weather/manifest.json b/mods/examples/example_weather/manifest.json index 077aa859..f74affa1 100644 --- a/mods/examples/example_weather/manifest.json +++ b/mods/examples/example_weather/manifest.json @@ -6,7 +6,7 @@ "entry": "main.lua", "profile": "overhaul", "category": "MECHANIC", - "game_version": ">=1.0.0 <2.0.0", + "game_version": ">=0.0.0-0 <2.0.0", "priority": 100, "dependencies": [], "optional_dependencies": [], diff --git a/scripts/build.sh b/scripts/build.sh index 6e44236c..61b61a7c 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -68,6 +68,31 @@ if unzip -Z1 "$LOVE_FILE" \ fi say "game.love: $(du -h "$LOVE_FILE" | cut -f1)" +# ------------------------------------------------------- stamp release version +# The working tree ships Version.lua with engine "0.0.0-dev"; the real release +# number only ever lives inside the packed archive. When --version is a strict +# X.Y.Z, patch a copy of Version.lua (engine set to that number) under a staging +# dir and replace the entry inside game.love in place -- never the source tree. +# Short-hash / "dev" builds are left with the "-dev" default so they cannot be +# mistaken for a release. The stamp is then read back out of the archive and the +# build fails if it did not take. +if printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then + say "stamping engine version $VERSION into game.love" + stamp_dir="$WORK/stamp" + rm -rf "$stamp_dir" + mkdir -p "$stamp_dir/src/core" + sed -E "s/(engine[[:space:]]*=[[:space:]]*\")[^\"]*(\")/\1$VERSION\2/" \ + "$ROOT/src/core/Version.lua" > "$stamp_dir/src/core/Version.lua" + (cd "$stamp_dir" && zip -q "$LOVE_FILE" src/core/Version.lua) + version_re="$(printf '%s' "$VERSION" | sed 's/\./\\./g')" + unzip -p "$LOVE_FILE" src/core/Version.lua \ + | grep -Eq "engine[[:space:]]*=[[:space:]]*\"$version_re\"" \ + || fail "version stamp failed: game.love does not report engine $VERSION" + say "stamped engine version: $VERSION" +else + say "version '$VERSION' is not X.Y.Z, shipping default engine (no stamp)" +fi + # --------------------------------------------------------------- macOS build_mac() { say "building macOS app" diff --git a/scripts/build_android.sh b/scripts/build_android.sh index b0e7b947..30425a3b 100755 --- a/scripts/build_android.sh +++ b/scripts/build_android.sh @@ -145,6 +145,32 @@ pack_game_love() { fail "game.love unexpectedly contains generated ROM data" fi say "game.love: $(du -h "$LOVE_FILE" | cut -f1) -> $LOVE_FILE" + + # This script packs its own game.love (it does not reuse build.sh's), so it + # stamps the release version the same way: patch a copy of Version.lua + # (engine set to $VERSION) under a throwaway staging dir and replace the + # entry inside the archive in place -- never the source tree. VERSION is + # already validated as X.Y.Z above; when it is empty the packaged game keeps + # the "0.0.0-dev" default. The stamp is read back out and the build fails if + # it did not take. + if printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then + say "stamping engine version $VERSION into game.love" + local stamp_dir + stamp_dir="$(mktemp -d)" + mkdir -p "$stamp_dir/src/core" + sed -E "s/(engine[[:space:]]*=[[:space:]]*\")[^\"]*(\")/\1$VERSION\2/" \ + "$ROOT/src/core/Version.lua" > "$stamp_dir/src/core/Version.lua" + (cd "$stamp_dir" && zip -q "$LOVE_FILE" src/core/Version.lua) + local version_re + version_re="$(printf '%s' "$VERSION" | sed 's/\./\\./g')" + unzip -p "$LOVE_FILE" src/core/Version.lua \ + | grep -Eq "engine[[:space:]]*=[[:space:]]*\"$version_re\"" \ + || fail "version stamp failed: game.love does not report engine $VERSION" + rm -rf "$stamp_dir" + say "stamped engine version: $VERSION" + else + say "no X.Y.Z --version, shipping default engine (no stamp)" + fi } # --------------------------------------------------------------- SDK check diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 3d1b84ad..cc4b7133 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -173,6 +173,14 @@ local function grayImage(img) return getImage(meta.path) or img end +-- the asset path a loaded battle image came from (nil for the headless +-- stub images), so the battle_sprite_scales registry can be looked up by +-- the same path data references +local function imagePathOf(img) + local m = imageMeta[img] + return m and m.path +end + -- the image a battler pic actually draws with this frame function BattleState:picImage(img) if self.grayPics then return grayImage(img) end @@ -267,6 +275,10 @@ local function makeBattler(data, mon, isPlayer, save) badgeBoosts = badgeBoosts, statuses = data.statuses, shownHP = mon.hp, -- the HP the bar displays (UpdateHPBar drain) + -- HUD status label (DrawHUDsAndHPBars); mon.status can land mid-move + -- while the tilemap still shows the prior condition until the next + -- post-action HUD refresh (core.asm after Execute*Move) + shownStatus = mon.status, stages = {}, -- volatile state; Transform/Conversion/Mimic override the cur* fields curStats = mon.stats, @@ -548,6 +560,12 @@ function BattleState:say(text) table.insert(self.queue, { text = text }) end +-- Message that opens YES/NO once typed out, keeping the text visible +-- underneath (pokered `done` + TWO_OPTION_MENU / TextBox opts.choice). +function BattleState:sayChoice(text, onChoose) + table.insert(self.queue, { text = text, choice = onChoose }) +end + function BattleState:act(fn) table.insert(self.queue, { fn = fn }) end @@ -808,8 +826,24 @@ function BattleState:updateQueue() if self.charIndex < self.total then self.charIndex = math.min(self.total, self.charIndex + 2) else + local item = self.current + -- TrainerAboutToUseText ends in `done` then DisplayTextBoxID: YES/NO + -- overlays the still-visible "Will … change POKéMON?" page. + if item and item.choice and not item.choiceOpen then + item.choiceOpen = true + self.waitingUI = true + local ChoiceBox = require("src.ui.ChoiceBox") + local battle = self + self.game.stack:push(ChoiceBox.new(self.game, function(yes) + local fn = item.choice + battle.current = nil + fn(yes) + end)) + return true + end local input = self.game.input - if input:wasPressed("a") or input:wasPressed("b") then + if not (item and item.choice) + and (input:wasPressed("a") or input:wasPressed("b")) then self.current = nil end end @@ -1060,11 +1094,14 @@ function BattleState:update(dt) self:updateFx() local input = self.game.input - -- safety net: HP changed outside a queued drain (level-up heals, - -- field effects) snaps once the queue is idle + -- safety net: HP/status changed outside a queued drain (level-up heals, + -- field effects, bag cures) snaps once the queue is idle if self.phase == "menu" then for _, b in ipairs({ self.player, self.enemy }) do - if b and b.shownHP then b.shownHP = b.mon.hp end + if b then + if b.shownHP then b.shownHP = b.mon.hp end + b.shownStatus = b.mon.status + end end end @@ -2145,90 +2182,105 @@ end -- move execution pipeline -- --------------------------------------------------------------------- +-- Mirror DrawHUDsAndHPBars: reveal mon.status on the HUD only after the +-- current action's queued anim/text have played (PoisonEffect sets the +-- bit before PlayCurrentMoveAnimation2 + PrintText, but the HUD redraw +-- waits until after Execute*Move returns). +function BattleState:syncShownStatus() + for _, b in ipairs({ self.player, self.enemy }) do + if b then b.shownStatus = b.mon.status end + end +end + function BattleState:executeAction(user, target, action) if user.mon.hp <= 0 or target.mon.hp <= 0 then return end if not action then return end - -- ghost battles: the ghost never attacks; its whole turn is the - -- GetOutText (ExecuteEnemyMove -> PrintGhostText, core.asm:5462-5463) - if self.ghost and not user.isPlayer then - self:sayNext(self.data.text._GetOutText or "GHOST: Get out...\nGet out...") - return - end - - -- refresh the held-in-place mirror before the status checks (see - -- lockedAction): the victim is held exactly while the opponent's - -- trapping bit is set -- including a counter sitting at 0 until the - -- end-of-turn CheckNumAttacksLeft clear - user.boundTurns = target.trappingTurns - and math.max(1, target.trappingTurns) or nil - - -- trainer class AI actions (engine/battle/trainer_ai.asm) - if action.special == "aiItem" then - self.aiUses = (self.aiUses or 1) - 1 - for _, m in ipairs(TrainerAI.useItem(self, action.item)) do - self:sayNext(prefixEnemy(m, self.enemy)) - end - self:drainNext() - require("src.core.Sound").play(self.data, "Heal_Ailment") - return - end - if action.special == "aiSwitch" then - self.aiUses = (self.aiUses or 1) - 1 - local previous = self.enemy - local oldName = self.enemy.name - self.enemyIndex = action.index - self.enemy = makeBattler(self.data, self.enemyParty[action.index], false) - -- EnemySendOutFirstMon (core.asm:1314-1315): clears player's trap - clearTrapping(self.player) - self:syncSides() - Runtime.emit("battle.battler_switched", { - battle = self, side = self.sides[2], battler = self.enemy, - previous = previous, - }) - self.aiUses = self:aiUsesFor() - markSeen(self.game, self.enemy.mon.species) - -- _AIBattleWithdrawText: "X with-/drew Y!" - self:sayNext(("%s with-\ndrew %s!"):format(self.trainer.name, oldName)) - self:sayNext(("%s sent\nout %s!"):format(self.trainer.name, self.enemy.name)) - return - end - - -- special locked actions. All of them still run the status gauntlet: - -- CheckPlayerStatusConditions (core.asm:3328-3583) evaluates sleep -> - -- freeze -> held-in-place -> flinch -> recharge -> disable tick -> - -- confusion -> paralysis BEFORE the bide/thrash/trapping handling. - if action.special == "recharge" then - -- only reaching .HyperBeamCheck consumes the flag (core.asm:3384- - -- 3392): sleep/freeze/held/flinch keep the mon recharging next turn - if self:preRechargeChecks(user, target) then return end - user.mustRecharge = nil - self:sayNext(("%s\nmust recharge!"):format(displayName(user))) - return - end - if action.special == "bound" then - if not target.trappingTurns then - -- the trap ended earlier this turn: the CANNOT_MOVE selection is - -- simply lost (ExecutePlayerMove returns immediately on $ff) + local function run() + -- ghost battles: the ghost never attacks; its whole turn is the + -- GetOutText (ExecuteEnemyMove -> PrintGhostText, core.asm:5462-5463) + if self.ghost and not user.isPlayer then + self:sayNext(self.data.text._GetOutText or "GHOST: Get out...\nGet out...") return end - -- sleep/freeze take precedence over the held-in-place message - if self:statusInterrupt(user, target) then return end - return - end - if action.special == "trapping" then - if self:statusInterrupt(user, target) then return end - self:continueTrapping(user, target) - return - end - if action.special == "bide" then - if self:statusInterrupt(user, target) then return end - self:continueBide(user, target) - return - end - if self:statusInterrupt(user, target) then return end - self:performMove(user, target, action, false) + -- refresh the held-in-place mirror before the status checks (see + -- lockedAction): the victim is held exactly while the opponent's + -- trapping bit is set -- including a counter sitting at 0 until the + -- end-of-turn CheckNumAttacksLeft clear + user.boundTurns = target.trappingTurns + and math.max(1, target.trappingTurns) or nil + + -- trainer class AI actions (engine/battle/trainer_ai.asm) + if action.special == "aiItem" then + self.aiUses = (self.aiUses or 1) - 1 + for _, m in ipairs(TrainerAI.useItem(self, action.item)) do + self:sayNext(prefixEnemy(m, self.enemy)) + end + self:drainNext() + require("src.core.Sound").play(self.data, "Heal_Ailment") + return + end + if action.special == "aiSwitch" then + self.aiUses = (self.aiUses or 1) - 1 + local previous = self.enemy + local oldName = self.enemy.name + self.enemyIndex = action.index + self.enemy = makeBattler(self.data, self.enemyParty[action.index], false) + -- EnemySendOutFirstMon (core.asm:1314-1315): clears player's trap + clearTrapping(self.player) + self:syncSides() + Runtime.emit("battle.battler_switched", { + battle = self, side = self.sides[2], battler = self.enemy, + previous = previous, + }) + self.aiUses = self:aiUsesFor() + markSeen(self.game, self.enemy.mon.species) + -- _AIBattleWithdrawText: "X with-/drew Y!" + self:sayNext(("%s with-\ndrew %s!"):format(self.trainer.name, oldName)) + self:sayNext(("%s sent\nout %s!"):format(self.trainer.name, self.enemy.name)) + return + end + + -- special locked actions. All of them still run the status gauntlet: + -- CheckPlayerStatusConditions (core.asm:3328-3583) evaluates sleep -> + -- freeze -> held-in-place -> flinch -> recharge -> disable tick -> + -- confusion -> paralysis BEFORE the bide/thrash/trapping handling. + if action.special == "recharge" then + -- only reaching .HyperBeamCheck consumes the flag (core.asm:3384- + -- 3392): sleep/freeze/held/flinch keep the mon recharging next turn + if self:preRechargeChecks(user, target) then return end + user.mustRecharge = nil + self:sayNext(("%s\nmust recharge!"):format(displayName(user))) + return + end + if action.special == "bound" then + if not target.trappingTurns then + -- the trap ended earlier this turn: the CANNOT_MOVE selection is + -- simply lost (ExecutePlayerMove returns immediately on $ff) + return + end + -- sleep/freeze take precedence over the held-in-place message + if self:statusInterrupt(user, target) then return end + return + end + if action.special == "trapping" then + if self:statusInterrupt(user, target) then return end + self:continueTrapping(user, target) + return + end + if action.special == "bide" then + if self:statusInterrupt(user, target) then return end + self:continueBide(user, target) + return + end + + if self:statusInterrupt(user, target) then return end + self:performMove(user, target, action, false) + end + run() + -- after announce/anim/effect text (pokered DrawHUDsAndHPBars) + self:actNext(function() self:syncShownStatus() end) end -- Sleep / confusion onomatopoeia from Check*StatusConditions @@ -2724,51 +2776,37 @@ function BattleState:enemyMonFainted() end if nextIndex then self.enemyIndex = nextIndex - -- SHIFT battle style (the default): announce the next mon and - -- offer a free switch (SET skips the prompt) + -- EnemySendOutFirstMon (core.asm:1366-1443): SHIFT offers a free + -- switch when party count > 1, the active mon is alive, and the + -- battle-style bit is clear. pokered counts party slots (not + -- remaining HP); SET / single-mon / fainted active skip the prompt. local nextMon = self.enemyParty[self.enemyIndex] local nextName = nextMon.nickname or self.data.pokemon[nextMon.species].name - local style = (self.game.save.options or {}).battleStyle or "shift" - local healthy = 0 - for _, mon in ipairs(self.game.save.party) do - if mon.hp > 0 then healthy = healthy + 1 end - end - if style ~= "set" and healthy > 1 and self.player.mon.hp > 0 then - self:say(("%s is\nabout to use\n%s!"):format(self.trainer.name, nextName)) - self:say(("Will %s\nchange POKéMON?"):format(self.game.save.player.name)) - local game = self.game - self:ui(function() - local ChoiceBox = require("src.ui.ChoiceBox") - return ChoiceBox.new(game, function(yes) + local style = tostring((self.game.save.options or {}).battleStyle or "shift") + :lower() + local partyCount = #self.game.save.party + -- SwitchPlayerMon runs AFTER TrainerSentOutText (core.asm:1436-1443) + local shiftSwitchMon = nil + if style ~= "set" and partyCount > 1 and self.player.mon.hp > 0 then + -- _TrainerAboutToUseText: "X is" / "about to use" then cont nick, + -- then para "Will PLAYER" / "change POKéMON?" with YES/NO. + self:say(("%s is\nabout to use"):format(self.trainer.name)) + self:say(("%s!"):format(nextName)) + self:sayChoice( + ("Will %s\nchange POKéMON?"):format(self.game.save.player.name), + function(yes) if not yes then return end + local game = self.game Screens.push(game, "PartyMenu", { battle = self, + forceSwitch = true, onSwitch = function(mon) if mon ~= self.player.mon and mon.hp > 0 then - local previous = self.player - self.player = makeBattler(self.data, mon, true, game.save) - clearTrapping(self.enemy) -- SendOutMon clears foe trap - self:syncSides() - Runtime.emit("battle.battler_switched", { - battle = self, side = self.sides[1], - battler = self.player, previous = previous, - }) - self:markParticipant() - self.nextInsert = 0 - self.sendingOut = true - self:sayNext(self:sendOutText(self.player.name)) - self:animNext("POOF_ANIM", false) - self:actNext(function() - self.sendingOut = false - -- SendOutMon (core.asm:1757-1762): poof, then the grow-in - self:startGrowIn(self.player) - require("src.core.Sound").playCry(self.data, self.player.mon.species) - end) + shiftSwitchMon = mon end end, }) end) - end) end self:act(function() local previous = self.enemy @@ -2797,6 +2835,29 @@ function BattleState:enemyMonFainted() end) end) end) + -- SwitchPlayerMon after the enemy is out (core.asm:1436-1443) + self:act(function() + local mon = shiftSwitchMon + if not mon then return end + local previous = self.player + self.player = makeBattler(self.data, mon, true, self.game.save) + clearTrapping(self.enemy) + self:syncSides() + Runtime.emit("battle.battler_switched", { + battle = self, side = self.sides[1], + battler = self.player, previous = previous, + }) + self:markParticipant() + self.nextInsert = 0 + self.sendingOut = true + self:sayNext(self:sendOutText(self.player.name)) + self:animNext("POOF_ANIM", false) + self:actNext(function() + self.sendingOut = false + self:startGrowIn(self.player) + require("src.core.Sound").playCry(self.data, self.player.mon.species) + end) + end) return end local prize = (self.trainer.baseMoney or 0) * self.enemy.mon.level @@ -2920,6 +2981,8 @@ function BattleState:openReplacementMenu() self:ui(function() return self:buildScreen("PartyMenu", { battle = self, + -- ChooseNextMon: pick immediately (no SWITCH/STATS/CANCEL) + forceSwitch = true, onSwitch = function(mon) if mon.hp <= 0 then self:say("There's no will\nto fight!") @@ -3130,6 +3193,9 @@ end -- called by BagMenu after an item is used in battle (consumes the turn) function BattleState:itemUsed(messages) + -- bag cures clear mon.status before the message UI; refresh the HUD + -- once control returns (pokered DrawHUDsAndHPBars after item use) + self:syncShownStatus() for _, m in ipairs(messages or {}) do self:say(m) end table.insert(self.queue, { drain = true }) -- potions animate the bar self:act(function() @@ -3151,10 +3217,39 @@ function BattleState:ballMissMessage(shakes) return t._ItemUseBallText04 or "Shoot! It was so\nclose too!" end +-- AskName (engine/menus/naming_screen.asm): ClearSprites, wild field blank, +-- PrintText, YES/NO while text stays (TextBox opts.choice). Shared by party +-- AddPartyMon and SendNewMonToBox (#172). +function BattleState:askNicknameUI(mon, displayName) + local game = self.game + self.lockedBall = nil + self.blankForAskName = true + local TextBox = require("src.render.TextBox") + local text = ("Do you want to\ngive a nickname\nto %s?"):format(displayName) + local label = game.data.text and game.data.text._DoYouWantToNicknameText + if label then + -- extractor CONT is \t; TextBox scrolls on \n/\v + text = label:gsub("\t", "\n"):gsub("{RAM:?[%w_]*}", displayName) + end + return TextBox.new(game, text, nil, { + choice = function(yes) + self.blankForAskName = false + if not yes then return end + pcall(Screens.push, game, "NamingScreen", { + title = "NICKNAME?", maxLen = 10, + onDone = function(name) + if name and #name > 0 then mon.nickname = name end + end, + }) + end, + }) +end + -- The caught mon joins the party or a PC box (ItemUseBall .captured, -- item_effects.asm:518-566): the caught text, then for a NEW species --- "New POKéDEX data will be added" + the dex entry page, then the --- party add (with the nickname ask) or the PC transfer text. +-- "New POKéDEX data will be added" + the dex entry page, then +-- AddPartyMon or SendNewMonToBox (both call AskName), then the PC +-- transfer text when the party was full. function BattleState:storeCaughtMon() -- ItemUseBall reloads the caught mon via LoadEnemyMonData -- (item_effects.asm:472-501), regenerating its move list from the @@ -3174,31 +3269,20 @@ function BattleState:storeCaughtMon() return self:buildScreen("DexEntryMenu", species) end) end - if Party.add(game.save.party, self.enemy.mon) then - -- nickname prompt (AskName runs inside AddPartyMon; box mons are - -- never offered a nickname) + local function askCaughtNickname() local caught = self.enemy.mon local enemyName = self.enemy.name self:uiNext(function() - local ChoiceBox = require("src.ui.ChoiceBox") - local TextBox = require("src.render.TextBox") - return TextBox.new(game, ("Do you want to\ngive a nickname\nto %s?") - :format(enemyName), function() - game.stack:push(ChoiceBox.new(game, function(yes) - if not yes then return end - pcall(Screens.push, game, "NamingScreen", { - title = "NICKNAME?", maxLen = 10, - onDone = function(name) - if name and #name > 0 then caught.nickname = name end - end, - }) - end)) - end) + return self:askNicknameUI(caught, enemyName) end) + end + if Party.add(game.save.party, self.enemy.mon) then + askCaughtNickname() else destination = "box" local boxNum = require("src.pokemon.Boxes").deposit(game.save, self.enemy.mon) if boxNum then + askCaughtNickname() -- _ItemUseBallText07/08 keyed on EVENT_MET_BILL local pc = (game.save.flags and game.save.flags.EVENT_MET_BILL) and "BILL's PC" or "someone's PC" @@ -3449,12 +3533,15 @@ function BattleState:fxFaintActive(battler) and fx.faint.frames > 0 or false end --- vertical slide offset for a fainting battler (the player's pic is --- drawn 2x, so it slides 2x as fast to sink at the same visual rate) -function BattleState:fxFaintOffset(battler) +-- vertical slide offset for a fainting battler. The offset is in screen +-- pixels, so it scales with the pic's draw scale (the player's default 2x +-- sinks 2x as fast to sink at the same visual rate); a mod scale composes +-- the same way. scale defaults to the vanilla side scale when unknown. +function BattleState:fxFaintOffset(battler, scale) local fx = self.fx if self:fxFaintActive(battler) then - return (30 - fx.faint.frames) * 2 * (battler.isPlayer and 2 or 1) + scale = scale or (battler.isPlayer and 2 or 1) + return (30 - fx.faint.frames) * 2 * scale end return 0 end @@ -3550,7 +3637,7 @@ function BattleState:drawBattlerPic(battler, x, y, scale) return end if self:fxFaintActive(battler) then - local off = self:fxFaintOffset(battler) + local off = self:fxFaintOffset(battler, scale) local visible = img:getHeight() - math.floor(off / scale) if visible > 0 then local quad = love.graphics.newQuad(0, 0, img:getWidth(), visible, @@ -3847,6 +3934,63 @@ function BattleState:drawAnimLayer(colorized) end end +-- ------------------------------------------------------------------ +-- Mod-facing battle sprite scaling. The enemy front pic draws at 1x and +-- the player back pic at 2x on the GB; a mod can override either per +-- species (pokemon.battleScaleFront / battleScaleBack) or per image path +-- (the battle_sprite_scales registry, which is the only handle on the +-- non-species pics like the trainer back). These resolvers and the +-- placement math are pure (no love.*) so the grounding contract -- feet +-- pinned at any scale -- is unit-tested directly. +-- ------------------------------------------------------------------ + +-- the vanilla scale for a side: enemy front 1x, player back 2x +BattleState.BATTLE_SCALE_DEFAULT = { front = 1, back = 2 } + +-- image-level override for an asset path, or nil. scales is the merged +-- data.battle_sprite_scales table (record id -> { path, scale }). +function BattleState.imageBattleScale(scales, path) + if not scales or not path then return nil end + for id, rec in pairs(scales) do + if id ~= "_owners" and type(rec) == "table" and rec.path == path then + return rec.scale + end + end + return nil +end + +-- effective battle scale for a pic: image-level override, else the +-- species-level override for the side, else the side default. side is +-- "front" (enemy) or "back" (player); species may be nil (a non-species +-- pic like the trainer back, which only image-level scaling reaches). +function BattleState.resolveBattleScale(data, side, path, species) + local img = data and BattleState.imageBattleScale(data.battle_sprite_scales, path) + if img then return img end + local def = species and data and data.pokemon and data.pokemon[species] + local field = side == "back" and "battleScaleBack" or "battleScaleFront" + local override = def and def[field] + if override then return override end + return BattleState.BATTLE_SCALE_DEFAULT[side] or 1 +end + +-- Player (back) placement: feet flush on the text-box top (y=96) at any +-- scale, with the left transparent columns pulled back so opaque pixels +-- land where hardware's white-on-white columns left them. Returns the +-- top-left x, y and the scale (slide/shake offsets are added by the +-- caller). Feet stay at 96 for every scale: y + (h - pad) * scale == 96. +function BattleState.backPlacement(w, h, pad, padL, scale) + return 8 - padL * scale, 96 - (h - pad) * scale, scale +end + +-- Enemy (front) placement: given the s=1 slot origin (ex, ey) from the +-- 7x7 tile layout, keep the bottom edge and horizontal centre pinned as +-- the pic scales -- the same compensation AnimateSendingOutMon's grow +-- uses. Returns top-left x, y and the scale. The bottom edge stays put +-- for every scale: y + h * scale == ey + h. +function BattleState.frontPlacement(ex, ey, w, h, scale) + return ex + w * (1 - scale) / 2, ey + h * (1 - scale), scale +end + -- Front/trainer pics: LoadUncompressedSpriteData centers the sprite in -- a 7x7 tile buffer, then CopyUncompressedPicToTilemap places that -- buffer at hlcoord 12,0. Horizontal pad is floor((8-w)/2) tiles; @@ -3890,16 +4034,23 @@ function BattleState:drawPicsLayer(slide, sx, sy) local img = self:picImage(self.enemy.sprite) love.graphics.setColor(1, 1, 1, 1) local ex, ey = enemyPicXY(img, slide, sx, sy) + local s = BattleState.resolveBattleScale(self.data, "front", + imagePathOf(img), self.enemy.mon and self.enemy.mon.species) local gs = self:growInScale(self.enemy) if gs then -- AnimateSendingOutMon: the downscaled pic keeps its bottom edge - -- and horizontal center pinned to the mon's slot while it grows - if gs > 0 then - love.graphics.draw(img, ex + img:getWidth() * (1 - gs) / 2, - ey + img:getHeight() * (1 - gs), 0, gs, gs) + -- and horizontal center pinned to the mon's slot while it grows -- + -- the mod scale composes multiplicatively with the grow stage + local eff = s * gs + if eff > 0 then + local dx, dy = BattleState.frontPlacement(ex, ey, + img:getWidth(), img:getHeight(), eff) + love.graphics.draw(img, dx, dy, 0, eff, eff) end else - self:drawBattlerPic(self.enemy, ex, ey, 1) + local dx, dy = BattleState.frontPlacement(ex, ey, + img:getWidth(), img:getHeight(), s) + self:drawBattlerPic(self.enemy, dx, dy, s) end end @@ -3913,9 +4064,14 @@ function BattleState:drawPicsLayer(slide, sx, sy) local img = self:picImage(self.playerBackPic) local pad = imagePadBottom[self.playerBackPic] or 0 local padL = imagePadLeft[self.playerBackPic] or 0 + -- the trainer back is a bare pic, not species-keyed, so only an + -- image-level battle_sprite_scales entry can rescale it + local s = BattleState.resolveBattleScale(self.data, "back", + imagePathOf(self.playerBackPic), nil) love.graphics.setColor(1, 1, 1, 1) - love.graphics.draw(img, 8 - padL * 2 + slide + sx, - 96 - (img:getHeight() - pad) * 2 + sy, 0, 2, 2) + local dx, dy = BattleState.backPlacement(img:getWidth(), img:getHeight(), + pad, padL, s) + love.graphics.draw(img, dx + slide + sx, dy + sy, 0, s, s) elseif self.player and self.player.sprite and not hidePlayer and not self.sendingOut and not self:fxHidden(self.player) then local img = self:picImage(self.player.sprite) @@ -3923,19 +4079,24 @@ function BattleState:drawPicsLayer(slide, sx, sy) -- feet flush on the text box top (y=96), ignoring baked-in padding local pad = imagePadBottom[self.player.sprite] or 0 local padL = imagePadLeft[self.player.sprite] or 0 - local px = 8 - padL * 2 + sx + local s = BattleState.resolveBattleScale(self.data, "back", + imagePathOf(self.player.sprite), + self.player.mon and self.player.mon.species) local gs = self:growInScale(self.player) if gs then -- the player-side AnimateSendingOutMon grow (after the poof, - -- core.asm:1757-1762): feet pinned at y=96, center at x=8+w - if gs > 0 then - love.graphics.draw(img, px + img:getWidth() * (1 - gs), - 96 - (img:getHeight() - pad) * 2 * gs + sy, - 0, 2 * gs, 2 * gs) + -- core.asm:1757-1762): feet pinned at y=96, horizontal centre + -- pinned, mod scale composed with the grow stage + local eff = s * gs + if eff > 0 then + love.graphics.draw(img, + 8 - padL * s + img:getWidth() * s * (1 - gs) / 2 + sx, + 96 - (img:getHeight() - pad) * eff + sy, 0, eff, eff) end else - self:drawBattlerPic(self.player, px, - 96 - (img:getHeight() - pad) * 2 + sy, 2) + local dx, dy = BattleState.backPlacement(img:getWidth(), + img:getHeight(), pad, padL, s) + self:drawBattlerPic(self.player, dx + sx, dy + sy, s) end end if clipped then @@ -3956,8 +4117,11 @@ function BattleState:drawHUDs(slide) local barData = self:colorMode() and {} or self.data -- gray fill when zoned local fx = self.fx local hudShake = (fx and fx.hudShakeX) or 0 + -- FaintEnemyPokemon clears the enemy HUD area; it stays blank through + -- TrainerAboutToUseText until DrawEnemyHUDAndHPBar after the next send-out if self.enemy and not self.showEnemyTrainer and not self.enemySendingOut - and not self:growInScale(self.enemy) and slide == 0 then + and not self:growInScale(self.enemy) and slide == 0 + and not self.enemy.fainted then -- enemy HUD (DrawEnemyHUDAndHPBar): name row 0, +level (4,1), -- HP bar (2,2) with the vertical tick at (1,2), underline row 3; -- AnimationShakeEnemyHUD nudges just this block via SCX @@ -3967,8 +4131,8 @@ function BattleState:drawHUDs(slide) end love.graphics.setColor(0, 0, 0, 1) Font.draw(self.enemy.name, nameX(1, self.enemy.name), 0) - if self.enemy.mon.status then - Font.draw(self:statusLabel(self.enemy.mon), 40, 8) + if self.enemy.shownStatus then + Font.draw(self:statusLabel({ status = self.enemy.shownStatus }), 40, 8) else hudTile(0x6E, 32, 8) -- Font.draw(tostring(self.enemy.mon.level), 40, 8) @@ -4015,8 +4179,8 @@ function BattleState:drawHUDs(slide) -- the tick at (18,10) and the triangle at (9,11) love.graphics.setColor(0, 0, 0, 1) Font.draw(self.player.name, nameX(10, self.player.name), 56) - if self.player.mon.status then - Font.draw(self:statusLabel(self.player.mon), 120, 64) + if self.player.shownStatus then + Font.draw(self:statusLabel({ status = self.player.shownStatus }), 120, 64) else hudTile(0x6E, 112, 64) -- Font.draw(tostring(self.player.mon.level), 120, 64) @@ -4122,6 +4286,13 @@ function BattleState:drawTextArea() end function BattleState:draw() + -- AskName: ClearSprites + wild ClearScreenArea — white field under the + -- nickname TextBox / YES/NO (naming_screen.asm); overlays draw on top. + if self.blankForAskName then + love.graphics.setColor(1, 1, 1, 1) + love.graphics.rectangle("fill", 0, 0, 160, 144) + return + end local fx = self.fx -- window shakes (SE_SHAKE_SCREEN / the enemy-hit vertical shake); -- the animations-off fallback keeps the old +-2 alternation diff --git a/src/core/Data.lua b/src/core/Data.lua index 47cf2429..9b62fe8f 100644 --- a/src/core/Data.lua +++ b/src/core/Data.lua @@ -102,6 +102,8 @@ function Data:seedDefaults() -- after-battle rows so Blaine's SetEventRange deactivation and talk -- after-text work like the other gyms (scripts/CinnabarGym.asm). self:seedCinnabarGymTrainerHeaders() + -- #189: 1F cabin door order vs rooms map (survey zoom) + require("src.world.SsAnneLayout").apply(self.maps) end function Data:seedCinnabarGymTrainerHeaders() diff --git a/src/core/Game.lua b/src/core/Game.lua index 0d9532fe..324323ca 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -37,6 +37,9 @@ function Game:load() self.mods = ModLoader.new() self.mods:load(Data) self.modStatus = self.mods:status() + -- render pipelines dispatch off the merged dataset; point them at the + -- one the mods just merged into before anything can draw a frame + require("src.render.Pipelines").install(Data) self.input = Input Input:init() @@ -198,6 +201,9 @@ function Game:update(dt) -- Overworld tilt toggle tween: presentational, so it runs on the real -- frame dt (not the fixed logic step) for a smooth ~0.25s glide. require("src.render.Tilt").update(dt) + -- mod render pipelines tween on the same real-frame clock, for the same + -- reason: they are presentational, so fast-forward must not speed them up + require("src.render.Pipelines").update(dt) pcall(function() require("src.core.DiscordPresence").update(dt) end) -- Steady-state memory backstop: advance the incremental collector one -- small step every rendered frame. The heavy GPU objects are now freed @@ -341,6 +347,17 @@ function Game:keypressed(key) 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 + -- pipelines). syncOptions writes the whole ladder back, including the + -- tilt exclusion a world pipeline forces. + local Pipelines = require("src.render.Pipelines") + if Pipelines.hotkey(key, self.stack:top(), self.overworld) then + Pipelines.syncOptions(self.save.options) + require("src.render.Tilt").setLevel(self.save.options.tilt or 0) + self:writeOptions() + return + end Input:keypressed(key) end @@ -457,6 +474,9 @@ function Game:applyOptions(opts) if Sound.applyOptions then Sound.applyOptions(opts) end require("src.render.PaletteFX").applyOptions(opts) require("src.render.Tilt").applyOptions(opts) + -- after Tilt, so a persisted world pipeline can switch the tilt level it + -- just restored back off (the two are mutually exclusive) + 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 diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index 3e96383a..1214db33 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -17,6 +17,7 @@ local Runtime = require("src.mods.Runtime") local Semver = require("src.mods.Semver") local Boxes = require("src.pokemon.Boxes") local Bag = require("src.inventory.Bag") +local Badges = require("src.inventory.Badges") local GameVersion = require("src.core.GameVersion") @@ -31,10 +32,11 @@ local OPTIONS_FILENAME = "options.lua" -- Main / backup / staged-witness names for a version (defaults to the active -- one). The backup is a rolling copy and .tmp is the staged-write witness; -- load promotes either when the main file is missing or fails to parse. -local function saveNames(version) - local main = "save" .. GameVersion.saveSuffix(version) .. ".lua" - return main, main .. ".bak", main .. ".tmp" -end +-- Forward-declared here so saveFilename (just below) and every save/load +-- caller share the one upvalue; the body is filled in under "save slots" +-- once the options IO it depends on exists, because it now resolves the +-- ACTIVE slot for a version rather than the fixed flat name. +local saveNames -- The main save filename for a version -- used by the title screen's -- CONTINUE gate so it looks for the right game's save. @@ -91,6 +93,18 @@ local function makePortableFs(dir) os.remove(full(name)) return true end, + createDirectory = function(name) + -- portable mode writes real files through io.*, which will not + -- create missing parent directories; mkdir the tree so a slot path + -- like "saves/red" exists before a write lands inside it + local osPath = full(name):gsub("/", SEP) + if SEP == "\\" then + os.execute('mkdir "' .. osPath .. '" 2>nul') + else + os.execute('mkdir -p "' .. osPath .. '" 2>/dev/null') + end + return true + end, } end @@ -198,6 +212,11 @@ function SaveData.defaultOptions() videoMode = "windowed", -- hard render frame-rate cap; render-only pacing (issue #88, FrameCap.lua) fpsCap = 60, + -- Per-pipeline display levels, keyed by render_pipelines id (see + -- src/render/Pipelines.lua). A level for a mod that is not installed + -- is kept rather than pruned, so re-enabling the mod restores the mode + -- the player left it in. + pipelines = {}, -- Native mod enablement is an installation option, not save-slot data. -- Missing entries mean enabled so newly installed mods work by default. mods = {}, @@ -283,6 +302,264 @@ function SaveData.loadOptions(fs) return SaveData.mergeOptions(data) end +-- ------- save slots + +-- A version's playthroughs live in numbered slots under saves//; +-- the active slot is where in-game SAVE and CONTINUE land. The registry +-- (the ordered slot list plus which one is active) persists in options.lua +-- under options.saveSlots[version]; the active slot is also cached +-- process-wide (like GameVersion.current) so the hot saveNames path does +-- not re-read options every call. A false cache entry means "no slot in +-- use" and the flat legacy path (save.lua / save_blue.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 function slotDir(version) return "saves/" .. version end + +local function slotNames(version, id) + local main = slotDir(version) .. "/" .. 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 for Blue); still the destination before any slot exists +local function legacyNames(version) + local main = "save" .. GameVersion.saveSuffix(version) .. ".lua" + return main, main .. ".bak", main .. ".tmp" +end + +-- Slot resolution is only meaningful for versions GameVersion actually knows +-- (red/blue). The launcher also renders a locked placeholder tab ("yellow") +-- that has no info entry and therefore no saveSuffix; resolving its legacy +-- names would index a nil info table and crash. Treat any unknown version as +-- having no slots so the slot APIs degrade to empty/no-op instead. +local function knownVersion(version) + return GameVersion.info(version) ~= nil +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 +-- no-op there. +local function ensureParentDir(fs, name) + local dir = name:match("^(.*)/[^/]+$") + if dir and fs.createDirectory then fs.createDirectory(dir) end +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 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 "") + if data then return data end + data = fs.getInfo(bak) and SaveSerializer.decode(fs.read(bak) or "") + return data or nil +end + +-- One-time legacy consolidation: a pre-slots install has a flat save file +-- (+ .bak) and no saves// registry. Copy both into slot1, verify +-- the copy reads back, then remove the originals and register slot1 as the +-- 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 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) + 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 + 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 } + SaveData.saveOptions(opts, fs) + return id +end + +-- Resolve (once per version per process) which slot in-game saves use: an +-- existing registry wins; otherwise a lazy legacy migration may create +-- slot1; otherwise false, meaning the 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 + return + end + local opts = SaveData.loadOptions(fs) + local reg = opts.saveSlots and opts.saveSlots[version] + if reg and type(reg.list) == "table" and #reg.list > 0 then + activeSlotCache[version] = reg.active or reg.list[1] + return + end + activeSlotCache[version] = tryMigrateLegacy(version, fs) or 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. +function saveNames(version) + version = version or GameVersion.get() + local fs = persistFs(nil) + ensureVersionSlots(version, fs) + local slot = activeSlotCache[version] + if slot then return slotNames(version, slot) end + return legacyNames(version) +end + +-- Pure extraction of the launcher's per-slot summary from a decoded save, +-- factored out so it is unit-testable with no filesystem: the player name +-- (nil for an empty slot) and { badges, timeText, dexCount } -- the same +-- fields the title screen's ContinueInfo derives. Badges resolve against +-- the vanilla gym list (launcher has no loaded Data), which is what the +-- flat launcher meta line needs. +function SaveData.slotSummary(save) + if type(save) ~= "table" then return nil, nil end + local name = save.player and save.player.name or nil + local dexCount = 0 + for _ in pairs((save.pokedex and save.pokedex.owned) or {}) do + dexCount = dexCount + 1 + end + local t = math.floor(save.playTime or 0) + local timeText = ("%d:%02d"):format(math.floor(t / 3600), + math.floor(t / 60) % 60) + return name, { + badges = Badges.count(nil, save), + timeText = timeText, + dexCount = dexCount, + } +end + +-- Slots visible to the launcher: every registered slot for a version, 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. +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 } + end + return out +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 fs = persistFs(nil) + local opts = SaveData.loadOptions(fs) + opts.saveSlots = opts.saveSlots or {} + local reg = opts.saveSlots[version] or { list = {}, active = nil } + 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 + SaveData.saveOptions(opts, fs) + slotsChecked[version] = true + activeSlotCache[version] = slotId + return slotId +end + +-- Register a new empty slot for the version 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 fs = persistFs(nil) + ensureVersionSlots(version, fs) + local opts = SaveData.loadOptions(fs) + opts.saveSlots = opts.saveSlots or {} + local reg = opts.saveSlots[version] or { list = {}, active = nil } + local maxN = 0 + for _, id in ipairs(reg.list) do + local n = tonumber(tostring(id):match("^slot(%d+)$")) + if n and n > maxN then maxN = n end + end + local id = "slot" .. (maxN + 1) + reg.list[#reg.list + 1] = id + opts.saveSlots[version] = reg + SaveData.saveOptions(opts, fs) + return id +end + +-- The active slot id in use for a version (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. +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 +end + +-- Write saveTable into an existing slot's file (SaveSerializer.encode), through +-- the same fs seam every other save/load call uses (so portable mode keeps +-- working) and the same .tmp-witness / .bak recovery discipline SaveData.save +-- uses for the flat path. Used by the launcher's save-import glue, which has +-- 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 + 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 encoded = SaveSerializer.encode(saveTable) + local fs = persistFs(nil) + ensureParentDir(fs, main) + if fs.getInfo(main) then + local prev = fs.read(main) + if prev then fs.write(bak, prev) end + end + local ok, err = fs.write(tmp, encoded) + if not ok then return false, err end + remove(fs, main) + ok, err = fs.write(main, encoded) + if not ok then return false, err end + remove(fs, tmp) + 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.resetSlotState() + for k in pairs(activeSlotCache) do activeSlotCache[k] = nil end + for k in pairs(slotsChecked) do slotsChecked[k] = nil end +end + -- ------- meta -- the version/engine/mod-set stamp every v2 save carries; mods is the @@ -525,6 +802,9 @@ function SaveData.save(data, mods) end local encoded = SaveSerializer.encode(gameOnly) local fs = persistFs(nil) + -- the active slot may live in saves//, which must exist before + -- the .tmp/.bak/main writes land (a no-op for the flat legacy path) + ensureParentDir(fs, FILENAME) if fs.getInfo(FILENAME) then local prev = fs.read(FILENAME) if prev then fs.write(BACKUP_FILENAME, prev) end diff --git a/src/core/Version.lua b/src/core/Version.lua index 914fe00b..c57b91a2 100644 --- a/src/core/Version.lua +++ b/src/core/Version.lua @@ -4,14 +4,22 @@ -- tests. local Version = { - engine = "1.0.0", -- game/engine release (semver triple) + engine = "0.0.0-dev", -- game/engine release (semver). Repo default is the + -- "-dev" placeholder; CI stamps the real X.Y.Z into + -- the packed game.love only, never the working tree. + shell = 1, -- native-shell contract this build implements + minShell = 1, -- lowest shell contract that can RUN this payload. + -- Bump only when a payload needs a newer native + -- binary (e.g. a LOVE version bump); an older shell + -- refuses to chainload a payload whose minShell + -- exceeds the shell it provides. modApi = 2, -- mod API major (manifest `api`) linkProtocol = 2, -- link handshake wire version (Handshake.PROTOCOL) saveFormat = 4, -- save.meta.format cache = "rom-cache-v5", -- ROM import cache generation (RomImporter marker) } --- "gen1recomp v1.0.0" +-- "gen1recomp v0.0.0-dev" (or the stamped release version in shipped builds) function Version.title(base) return (base or "gen1recomp") .. " v" .. Version.engine diff --git a/src/import/RomExtractor.lua b/src/import/RomExtractor.lua index 7982241f..6b3c3a21 100644 --- a/src/import/RomExtractor.lua +++ b/src/import/RomExtractor.lua @@ -375,6 +375,8 @@ function RomExtractor:extractMaps() } self:tick("Maps", mapIndex, #keys) end + -- keep the extract ROM-faithful; Data:seedDefaults applies the #189 + -- S.S. Anne 1F cabin reorder at load time self:write("maps", out) return out end diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 496f5429..57546109 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -66,6 +66,23 @@ local PAL = { -- disabled "Coming soon" button disabled = { 120, 132, 158 }, disabledInk = { 149, 161, 189 }, -- #95a1bd + -- redesign (FirstRun.dc.html): tab chrome, cards, status pills + green = { 62, 224, 138 }, -- #3ee08a "GOOD TO GO" / toggle-on / LOADED + greenDark = { 22, 163, 90 }, -- #16a35a + labelGray = { 143, 163, 200 }, -- #8fa3c8 letterspaced ROM / SAVE FILES labels + cardBorder = { 120, 150, 220 }, -- rgba(120,150,220,*) card + divider strokes + slotBg = { 9, 14, 34 }, -- rgba(9,14,34,0.6) save-slot row interior + modDot = { 159, 180, 221 }, -- #9fb4dd MODS chip grid dots + underline + -- tab-chip gradients (top -> bottom) + chipRedTop = { 255, 92, 103 }, -- #ff5c67 + chipRedBot = { 181, 35, 42 }, -- #b5232a + chipBlueTop = { 106, 168, 255 }, -- #6aa8ff + chipBlueBot = { 30, 86, 168 }, -- #1e56a8 + chipGoldTop = { 255, 217, 74 }, -- #ffd94a + chipGoldBot = { 199, 154, 0 }, -- #c79a00 + chipModTop = { 61, 74, 109 }, -- #3d4a6d + chipModBot = { 32, 42, 69 }, -- #202a45 + chipInkGold = { 58, 44, 0 }, -- #3a2c00 dark "Y" on the gold chip } -- CacheFs.exists checks the game folder directly for a portable install, @@ -220,6 +237,23 @@ local function trim(value) return value and value:gsub("^%s+", ""):gsub("%s+$", "") or "" end +-- Turn a filesystem path into a well-formed file:// URI for love.system.openURL. +-- openURL feeds SDL_OpenURL, whose macOS backend ([NSURL URLWithString:]) returns +-- nil for any unencoded space -- and the default save dir lives under +-- "Application Support" -- so the click silently no-ops on real macOS installs. +-- Windows needs forward slashes and a leading slash on the drive path so the +-- authority is empty (file:///C:/...), not a hostname. Percent-encode the rest +-- (spaces -> %20) but keep the unreserved set plus "/" and ":" (drive letter and +-- path separators stay literal so the shell resolves the folder). +local function fileUrl(path) + path = tostring(path):gsub("\\", "/") + if path:sub(1, 1) ~= "/" then path = "/" .. path end + local encoded = path:gsub("[^%w%-%._~/:]", function(c) + return string.format("%%%02X", string.byte(c)) + end) + return "file://" .. encoded +end + local function commandOutput(command) local pipe = io.popen(command, "r") if not pipe then return nil end @@ -236,10 +270,21 @@ end -- via USB or a file manager, no runtime permission needed), and this scans -- it directly through love.filesystem -- already mounted at the physfs -- root, so no io.* absolute-path handling is needed. -local function scanForRom() +-- +-- Only a .gb whose SHA maps to a version that is not yet ready counts as +-- pending. GameActivity always writes the SAF pick to picked_rom.gb, so a +-- naive "first .gb wins" scan would re-import Red when the player tries to +-- add Blue (issue #167). +local function findPendingRom(ready) for _, name in ipairs(love.filesystem.getDirectoryItems("")) do if name:lower():match("%.gb$") and love.filesystem.getInfo(name, "file") then - return name + local data = love.filesystem.read(name) + if type(data) == "string" and #data == 1024 * 1024 then + local version = GameVersion.forSha1(sha1(data)) + if version and not ready[version] then + return name, data + end + end end end return nil @@ -274,6 +319,80 @@ local function chooseRom(promptName) return nil end +-- Open a native picker for a mod .zip (mirrors chooseRom's per-OS dialogs). +-- Returns the chosen absolute path or nil. Android has no picker; the mods +-- panel steers that case to the drag-drop hint instead. +local function chooseZip() + local prompt = "Choose a mod .zip" + local platform = love.system.getOS() + if platform == "OS X" then + return commandOutput( + ([[osascript -e 'POSIX path of (choose file with prompt "%s" of type {"zip"})' 2>/dev/null]]) + :format(prompt)) + elseif platform == "Windows" then + local script = table.concat({ + "Add-Type -AssemblyName System.Windows.Forms;", + "$d=New-Object System.Windows.Forms.OpenFileDialog;", + "$d.Title='" .. prompt .. "';", + "$d.Filter='Mod archive (*.zip)|*.zip|All files (*.*)|*.*';", + "if($d.ShowDialog() -eq 'OK'){[Console]::Write($d.FileName)}", + }) + return commandOutput( + 'powershell -NoProfile -STA -Command "' .. script .. '"') + elseif platform == "Linux" then + local path = commandOutput( + ([[zenity --file-selection --title="%s" --file-filter="Mod archive | *.zip" 2>/dev/null]]) + :format(prompt)) + if path then return path end + return commandOutput( + [[kdialog --getopenfilename "$HOME" "*.zip|Mod archive" 2>/dev/null]]) + end + return nil +end + +-- Open a native picker for a raw .sav battery save (mirrors chooseZip's per-OS +-- dialogs). Returns the chosen absolute path or nil. Android has no picker; +-- the SAVE FILES card steers that case to the drag-drop hint instead. +local function chooseSav() + local prompt = "Choose a .sav save file" + local platform = love.system.getOS() + if platform == "OS X" then + return commandOutput( + ([[osascript -e 'POSIX path of (choose file with prompt "%s" of type {"sav"})' 2>/dev/null]]) + :format(prompt)) + elseif platform == "Windows" then + local script = table.concat({ + "Add-Type -AssemblyName System.Windows.Forms;", + "$d=New-Object System.Windows.Forms.OpenFileDialog;", + "$d.Title='" .. prompt .. "';", + "$d.Filter='Game Boy save (*.sav)|*.sav|All files (*.*)|*.*';", + "if($d.ShowDialog() -eq 'OK'){[Console]::Write($d.FileName)}", + }) + return commandOutput( + 'powershell -NoProfile -STA -Command "' .. script .. '"') + elseif platform == "Linux" then + local path = commandOutput( + ([[zenity --file-selection --title="%s" --file-filter="Game Boy save | *.sav" 2>/dev/null]]) + :format(prompt)) + if path then return path end + return commandOutput( + [[kdialog --getopenfilename "$HOME" "*.sav|Game Boy save" 2>/dev/null]]) + end + return nil +end + +-- The self-updater only surfaces on the real distributed build: a fused, +-- interactive launcher with no scripted-run override. A dev / source checkout +-- (unfused, where Boot.run already no-ops) or an autopilot / driver / +-- import-only run all skip the release check so headless and CI runs never spin +-- up the background worker or reach out to the network. +local function updaterAllowed() + if not (love.filesystem.isFused and love.filesystem.isFused()) then return false end + if os.getenv("POKEPORT_AUTOPILOT") or os.getenv("POKEPORT_DRIVER") then return false end + if os.getenv("POKEPORT_IMPORT_ONLY") == "1" then return false end + return true +end + -- The launcher runs Red and Blue as two independent columns. 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/), so both can be imported and @@ -289,6 +408,7 @@ function RomImporter.new(onComplete, opts) launcher = opts.launcher or false, forceImport = opts.forceImport or false, android = android, + tab = "red", -- active launcher tab: "red"/"blue"/"yellow"/"mods" logo = love.graphics.newImage("assets/logo/logo.png"), bcg = love.graphics.newImage("assets/logo/bcg.png"), ready = {}, returning = {}, romName = {}, @@ -298,6 +418,21 @@ function RomImporter.new(onComplete, opts) notice = nil, -- { version, status, detail } transient hint (Android) status = "", detail = "", progress = 0, stageCurrent = 0, stageTotal = 1, pulse = 0, + -- SAVE SLOT panel state (pass 2): each keyed by version. slots is the + -- cached SaveData.listSlots array (refreshed lazily on first draw and after + -- 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 = {}, + -- 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 + -- affordance (desktop love.system.openURL). + saveNotice = {}, + -- MODS panel state (pass 3): mods is the cached LauncherMods.list() array + -- (refreshed lazily on first draw and after any toggle/install); modScroll + -- is the list scroll offset (px, clamped in draw); modNotice is the last + -- install result { ok, text } shown as a line above the list. + mods = nil, modScroll = 0, modNotice = nil, }, RomImporter) for _, version in ipairs(GameVersion.ORDER) do @@ -315,12 +450,39 @@ function RomImporter.new(onComplete, opts) self.romName[version] = "pokemon_" .. info.id .. ".gb" end - -- Android has no native picker until the player copies a ROM into the - -- external folder; import any .gb already there (routed by SHA-1) unless - -- both games are already imported. + -- Android: import a save-dir .gb that is not yet ready (USB drop or a + -- leftover SAF pick), routed by SHA-1. Already-imported carts are skipped + -- so a stale picked_rom.gb cannot block the opposite version. if android and not (self.ready.red and self.ready.blue) then - local name = scanForRom() - if name then self:startData(love.filesystem.read(name), name) end + local name, data = findPendingRom(self.ready) + if name then self:startData(data, name) end + end + + -- Mouse-wheel scroll for the save-slot / mods lists. main.lua (off limits) + -- swallows love.wheelmoved while the launcher is up and never forwards it + -- here, so the interactive launcher chains the global handler once, + -- non-destructively: our scroll runs first, then the previous handler (which + -- no-ops while the Importer is live and resumes feeding the game after + -- handoff). Only the interactive launcher installs this; the scripted / + -- import-only paths (launcher = false) leave the handler untouched. + if self.launcher and love and love.wheelmoved then + local prevWheel = love.wheelmoved + love.wheelmoved = function(dx, dy) + if not self._handedOff then pcall(self.wheelmoved, self, dx, dy) end + if prevWheel then return prevWheel(dx, dy) end + end + end + + -- Self-updater: the interactive launcher on a real fused build kicks off one + -- async release check as it comes up; draw() polls Check.state() to render an + -- unobtrusive banner beneath the columns. Held behind pcall so a broken or + -- absent updater can never take the launcher down with it. + if self.launcher and updaterAllowed() then + local ok, Check = pcall(require, "src.update.Check") + if ok and Check then + self.Check = Check + pcall(Check.start) + end end return self @@ -329,13 +491,13 @@ end -- The system picker runs as a separate top activity, so LOVE's own -- love.focus/love.visible pause while it's up (see main.lua) -- once the -- player returns here with a file picked, GameActivity has already copied --- it into the folder scanForRom checks, so a rescan on refocus picks it up --- without the player needing to tap the button again. +-- it into the save directory, so a pending-ROM rescan on refocus picks it +-- up without the player needing to tap the button again. function RomImporter:focus(f) if not (f and self.android and self.workState ~= "working") then return end if self.ready.red and self.ready.blue then return end - local name = scanForRom() - if name then self:startData(love.filesystem.read(name), name) end + local name, data = findPendingRom(self.ready) + if name then self:startData(data, name) end end function RomImporter:setError(message, version) @@ -385,6 +547,11 @@ function RomImporter:startData(data, displayName) end local info = GameVersion.info(version) + -- Bring the launcher to this version's tab so its progress bar is on screen + -- (a dropped cart is routed by SHA-1 regardless of which tab was showing). + if self.tab == "red" or self.tab == "blue" or self.tab == "yellow" then + self.tab = version + end self.importing = version self.workState = "working" self.notice = nil @@ -430,6 +597,12 @@ function RomImporter:startData(data, displayName) self.returning[version] = false self.romName[version] = (displayName and (displayName:match("[^/\\]+$") or displayName)) or self.romName[version] + -- Android: drop the consumed save-dir .gb (picked_rom.gb or a USB copy) + -- so the next Choose / focus cannot treat it as a fresh pending ROM. + if self.android and type(displayName) == "string" + and not displayName:find("[/\\]") then + love.filesystem.remove(displayName) + end self.importing = nil self.workState = "complete" self.completeVersion = version @@ -440,6 +613,7 @@ function RomImporter:startData(data, displayName) -- Stay on the launcher; the player presses Play to boot the new game. return end + self._handedOff = true resetPointerCursor(self) if self.onComplete then self.onComplete(version) end end) @@ -457,6 +631,22 @@ end function RomImporter:filedropped(file) if self.workState == "working" then return end + -- A dropped .zip is a mod archive: hand it straight to the mods installer + -- (which mounts + validates it). Everything else is treated as a ROM. The + -- dropped file itself is passed through -- installZip opens it the same way + -- readDroppedFile does here. + local name = file:getFilename() or "" + if name:lower():match("%.zip$") then + self:_installMod(file) + return + end + -- A dropped .sav is a battery save: import it to a new slot for the active + -- game tab (see _savedropTarget for the tab-selection rule). It never steals + -- .gb/.zip routing above. + if name:lower():match("%.sav$") then + self:_importSave(self:_savedropTarget(), file) + return + end local data, readError = readDroppedFile(file) if not data then self:setError("Could not read the dropped file: " .. tostring(readError)) @@ -465,6 +655,100 @@ function RomImporter:filedropped(file) self:startData(data, file:getFilename()) end +-- Install a mod .zip from a picker path or a dropped file, then surface the +-- result on the mods panel (switching to it so the notice is visible). The +-- source is whatever LauncherMods.installZip accepts: an absolute path string +-- or a love DroppedFile. +function RomImporter:_installMod(source) + if self.workState == "working" then return end + local LauncherMods = require("src.mods.LauncherMods") + local ok, res = LauncherMods.installZip(source) + if ok then + self:_refreshMods() + self.modNotice = { ok = true, text = "Installed " .. tostring(res) } + else + self.modNotice = { ok = false, text = tostring(res) } + end + self.tab = "mods" +end + +-- "Import mod .zip" button: open a native picker (desktop) and install the +-- pick. Android has no picker, so it points the player at the drop path. +function RomImporter:chooseMod() + if self.workState == "working" then return end + if self.android then + self.modNotice = + { ok = false, text = "Drop a mod .zip onto the window to install it." } + return + end + local path = chooseZip() + if path then self:_installMod(path) end +end + +-- Which game a dropped .sav imports into: a .sav has no version signature of +-- its own, so it lands on the active game tab. When a non-game tab (mods, or +-- the locked yellow placeholder) is showing, default to red -- the always- +-- present first game -- rather than guess. +function RomImporter:_savedropTarget() + local v = self.tab + if v == "red" or v == "blue" then return v end + return "red" +end + +-- Import a raw .sav into a fresh slot for a version, from a picker path or a +-- dropped file, and surface the outcome on that game's SAVE FILES card. Brings +-- the target tab forward so the notice (and, on success, the new active slot) +-- is visible. Requires the ROM to be imported first, since a save is only +-- playable with its game's data present. +function RomImporter:_importSave(version, source) + if self.workState == "working" then return end + if self.tab == "red" or self.tab == "blue" or self.tab == "mods" + or self.tab == "yellow" then + self.tab = version + end + if not self.ready[version] then + self.saveNotice[version] = { ok = false, text = "Import the " + .. GameVersion.info(version).displayName .. " ROM before importing a save." } + return + end + local ok, res = require("src.import.SaveFileIO").importToSlot(source, version) + if ok then + self:_refreshSlots(version) + self.activeSlot[version] = res + self.slotScroll[version] = math.huge -- pin the new row on screen (clamped in draw) + self.saveNotice[version] = { ok = true, text = "Imported save into " .. tostring(res) .. "." } + else + self.saveNotice[version] = { ok = false, text = tostring(res) } + end +end + +-- "Import save" button: open a native .sav picker (desktop) and import the pick. +-- Android has no picker, so it points the player at the drop path. +function RomImporter:chooseSaveImport(version) + if self.workState == "working" then return end + if self.android then + self.saveNotice[version] = + { ok = false, text = "Drop a .sav onto the window to import it." } + return + end + local path = chooseSav() + if path then self:_importSave(version, path) end +end + +-- "Export save" button: write the active slot back out to a raw .sav in the save +-- directory's exports/ folder and show the path (with an open-folder affordance) +-- on the SAVE FILES card. +function RomImporter:exportSave(version) + if self.workState == "working" then return end + local ok, res = require("src.import.SaveFileIO").exportActiveSlot(version) + if ok then + local dir = res:match("^(.*)[/\\][^/\\]+$") + self.saveNotice[version] = { ok = true, text = "Exported to " .. res, dir = dir } + else + self.saveNotice[version] = { ok = false, text = tostring(res) } + end +end + -- Open a picker (or, on Android, scan the external folder) for a column. The -- version argument only titles the dialog and steers error/notice text; the -- picked ROM is still routed by its SHA-1, so choosing a Blue cart in the Red @@ -473,9 +757,12 @@ function RomImporter:choose(version) if self.workState == "working" then return end self.chooseVersion = version or "red" if self.android then - local name = scanForRom() + -- Prefer a not-yet-imported .gb already in the save dir (USB copy, or a + -- fresh SAF pick). Never reuse an already-imported cart's file -- that + -- was the #167 failure mode (second Choose just re-extracted Red). + local name, data = findPendingRom(self.ready) if name then - self:startData(love.filesystem.read(name), name) + self:startData(data, name) elseif not love.system.pickFile() then -- Picker unavailable (API < 19, or no document-picker app installed): -- fall back to the USB folder-drop path as a friendly notice, not an @@ -520,6 +807,7 @@ end function RomImporter:play(version) if self.workState == "working" then return end if not self.ready[version] then return end + self._handedOff = true resetPointerCursor(self) if self.onComplete then self.onComplete(version) end end @@ -626,31 +914,134 @@ local function buttonShine(x, y, w, h, r, phase) love.graphics.setStencilTest() end +-- Draw letterspaced text (the UI font has no tracking control): advance glyph +-- by glyph. Returns the total drawn width so a caller can align to it. +local function printSpaced(font, text, x, y, spacing) + local cx = x + for i = 1, #text do + local ch = text:sub(i, i) + love.graphics.print(ch, cx, y) + cx = cx + font:getWidth(ch) + spacing + end + return math.max(0, cx - x - spacing) +end + +-- Clip text to a pixel width, appending an ellipsis when it overflows (the UI +-- font has no built-in truncation). Used for save-slot names / meta lines. +local function ellipsize(font, text, maxW) + text = tostring(text or "") + if maxW <= 0 or font:getWidth(text) <= maxW then return text end + local ell = "..." + local ew = font:getWidth(ell) + while #text > 0 and font:getWidth(text) + ew > maxW do + text = text:sub(1, #text - 1) + end + return text .. ell +end + +-- Stroke a rounded rectangle as a dashed outline (LOVE has no dashed line): +-- sample the path into a polyline -- corners as short arcs -- then walk it, +-- toggling on/off every dash/gap. Used for the "+ New save slot" button and +-- the empty-slots box. Caller sets colour + line width. +local function dashedRoundRect(x, y, w, h, r, dash, gap) + r = math.min(r, w / 2, h / 2) + local seg = 4 + local pts = {} + local function arc(cx, cy, a0, a1) + for i = 0, seg do + local a = a0 + (a1 - a0) * (i / seg) + pts[#pts + 1] = cx + math.cos(a) * r + pts[#pts + 1] = cy + math.sin(a) * r + end + end + arc(x + w - r, y + r, -math.pi / 2, 0) + arc(x + w - r, y + h - r, 0, math.pi / 2) + arc(x + r, y + h - r, math.pi / 2, math.pi) + arc(x + r, y + r, math.pi, math.pi * 1.5) + pts[#pts + 1] = pts[1]; pts[#pts + 1] = pts[2] -- close the loop + local remaining, drawing = dash, true + for i = 1, #pts - 2, 2 do + local x1, y1 = pts[i], pts[i + 1] + local dx, dy = pts[i + 2] - x1, pts[i + 3] - y1 + local segLen = math.sqrt(dx * dx + dy * dy) + local pos = 0 + while pos < segLen do + local step = math.min(remaining, segLen - pos) + if drawing then + local t0, t1 = pos / segLen, (pos + step) / segLen + love.graphics.line(x1 + dx * t0, y1 + dy * t0, x1 + dx * t1, y1 + dy * t1) + end + pos = pos + step + remaining = remaining - step + if remaining <= 0.0001 then + drawing = not drawing + remaining = drawing and dash or gap + end + end + end +end + +-- The redesign's standard content card: a faint top-lit blue tint fading into +-- a dark interior, with a thin cool-gray border. Shared by the ROM / SAVE +-- FILES / SAVE SLOT cards and the mod cards so every panel matches. +local function roundedCard(x, y, w, h, r) + fillGradRounded(x, y, w, h, r, PAL.blue, PAL.cardBlue, 0.08, 0.5) + love.graphics.setLineWidth(1) + col(PAL.cardBorder, 0.28) + love.graphics.rectangle("line", x, y, w, h, r, r) +end + function RomImporter:draw() local width, height = love.graphics.getDimensions() - local third = width / 3 local s = clamp(height / 768, 0.7, 1.6) local pulse = self.pulse + self._s = s - -- Hover state (desktop only -- touch has no cursor). anyHover and ptIn are - -- captured by drawCard and the footer below; the cursor is set at the end. - local mx, my = love.mouse.getPosition() - local hoverEnabled = not self.android - local anyHover = false - local function ptIn(r) - return r and mx >= r.x and mx <= r.x + r.width and my >= r.y and my <= r.y + r.height - end + -- Hover state (desktop only -- touch has no cursor). Panel methods read the + -- pointer + set self._anyHover through self:_hover; the cursor is set at the + -- end. Reset the per-frame hit rects so a tab with no controls (mods) cannot + -- inherit last frame's game-panel buttons. + self._mx, self._my = love.mouse.getPosition() + self._hoverEnabled = not self.android + self._anyHover = false + self.romButtonRect = nil + self.playButtonRect = nil + self.tabRects = {} + -- Rebuilt only by the active version's SAVE SLOT panel, so the mods tab (or a + -- version with no panel drawn this frame) cannot inherit last frame's rows. + self.slotRects = nil + self.newSlotRect = nil + -- Rebuilt only by the mods panel; nil elsewhere so a game tab cannot inherit + -- last frame's mod toggles / import button. + self.modRects = nil + self.modImportRect = nil + -- Rebuilt only by the active game panel's SAVE FILES card; nil elsewhere so + -- the mods tab cannot inherit last frame's save Import/Export/open-folder hits. + self.saveImportRect = nil + self.saveExportRect = nil + self.saveFolderRect = nil -- Fonts + size-dependent scenery, rebuilt only when the window size changes. local fontKey = ("%dx%d"):format(width, height) if self.fontKey ~= fontKey then self.fontKey = fontKey local function f(px) return love.graphics.newFont(math.max(8, math.floor(px + 0.5))) end - self.headFont = f(19 * s) - self.detailFont = f(14 * s) - self.buttonFont = f(19 * s) - self.hintFont = f(13 * s) - self.warningFont = f(11 * s) + self.headFont = f(19 * s) + self.detailFont = f(14 * s) + self.buttonFont = f(19 * s) + self.hintFont = f(13 * s) + self.warningFont = f(11 * s) + -- redesign faces + self.gameNameFont = f(26 * s) -- game / "Mods" heading + self.pillFont = f(13 * s) -- status pill + self.labelFont = f(12 * s) -- letterspaced ROM / SAVE FILES / SAVE SLOT + self.stateFont = f(16 * s) -- ROM state line + self.saveBtnFont = f(14 * s) -- glassy card buttons + self.chipFont = f(20 * s) -- R / B / Y tab letters + self.tabLabelFont = f(14 * s) -- active tab label + self.readyFont = f(12 * s) -- "N of 3 ready" + self.playFont = f(20 * s) -- Play button + self.slotNameFont = f(15 * s) -- save-slot player name / "NEW GAME" -- Background: a radial gradient (bright navy at top-centre -> near black). -- A triangle fan from the top-centre gives the radial falloff; the screen @@ -727,266 +1118,92 @@ function RomImporter:draw() love.graphics.setColor(1, 1, 1, 1) love.graphics.draw(self.bgMesh) - -- neon tri-segment top bar (Red | Blue | Yellow) with a soft downward bloom - local barH = math.max(10, 16 * s) + -- Centered content container (max ~1440 scaled units on very wide windows) + -- with a responsive side gutter; every column below derives from these. + local appW = math.min(width, 1440 * s) + local appX = (width - appW) / 2 + local padH = clamp(appW * 0.03, 12 * s, 26 * s) + local third = appW / 3 + + -- tricolor strip (Red | Blue | Yellow), 6px tall, with a soft downward bloom + local stripH = math.max(4, 6 * s) local segs = { - { PAL.red, 0, third }, - { PAL.blue, third, third }, - { PAL.gold, 2 * third, width - 2 * third }, + { PAL.red, appX, third }, + { PAL.blue, appX + third, third }, + { PAL.gold, appX + 2 * third, appW - 2 * third }, } love.graphics.setBlendMode("add") for _, seg in ipairs(segs) do - fillGrad(seg[2], 0, seg[3], barH * 3.2, seg[1], seg[1], 0.30, 0.0) + fillGrad(seg[2], stripH, seg[3], stripH * 3.6, seg[1], seg[1], 0.30, 0.0) end love.graphics.setBlendMode("alpha") for _, seg in ipairs(segs) do - col(seg[1]); love.graphics.rectangle("fill", seg[2], 0, seg[3], barH) + col(seg[1]); love.graphics.rectangle("fill", seg[2], 0, seg[3], stripH) end -- Footer (Boi's Club Games logo + trust warning), measured first so the - -- columns know where they must stop. Drawn near the end. - local warningWidth = math.min(width - 32, 620) + -- content region knows where it must stop. Drawn near the end. + local warningWidth = math.min(appW - 32 * s, 640 * s) local _, warningLines = self.warningFont:getWrap(TRUST_WARNING, warningWidth) local warningH = #warningLines * self.warningFont:getHeight() - local warningY = height - warningH - 10 + local warningY = height - warningH - 12 * s local bcgW, bcgH = self.bcg:getDimensions() - local bcgScale = math.min(math.min(width - 48, 200) / bcgW, height * 0.07 / bcgH) + local bcgScale = math.min(math.min(appW - 48 * s, 190 * s) / bcgW, height * 0.06 / bcgH) local bcgDW, bcgDH = bcgW * bcgScale, bcgH * bcgScale - local bcgX, bcgY = (width - bcgDW) / 2, warningY - bcgDH - 8 + local bcgX, bcgY = appX + (appW - bcgDW) / 2, warningY - bcgDH - 6 * s self.bcgButton = { x = bcgX, y = bcgY, width = bcgDW, height = bcgDH } - local footerTop = bcgY - 12 + local footerTop = bcgY - 10 * s - -- Logo metrics (drawn later with a bob so the cards never shift; layout uses - -- the resting position). + -- Logo: centred over the strip, width clamped, gentle bob + glow pulse. The + -- resting metrics fix the tab bar's top so the layout never shifts as it bobs. local logoW, logoH = self.logo:getDimensions() - local logoScale = math.min(math.min(width - 40, 440 * s) / logoW, height * 0.20 / logoH) + local logoTargetW = math.max(math.min(180 * s, appW - 32 * s), + math.min(330 * s, appW - 32 * s)) + local logoScale = math.min(logoTargetW / logoW, height * 0.15 / logoH) local logoDW, logoDH = logoW * logoScale, logoH * logoScale - local logoY = barH + 20 * s + local logoY = stripH + 14 * s - -- shared card metrics so the three columns line up exactly - local padX = 20 * s - local padTop = 22 * s - local padBot = 24 * s - local cardW = math.min(third - 24 * s, 300 * s) - local buttonH = 50 * s - local gapHead = 8 * s - local gapDetail = 18 * s - local gapButton = 10 * s - local hintH = self.hintFont:getHeight() + -- Tab bar: R/B/Y/divider/MODS chips (label + underline on the active one), + -- with "N of 3 ready" right-aligned. + local chip = 44 * s + local tabBarY = logoY + logoDH + 6 * s + local tabBarH = chip + 22 * s - -- natural height a card needs for its (wrapped) heading + detail - local function contentH(spec) - local innerW = cardW - 2 * padX - local _, hl = self.headFont:getWrap(spec.heading, innerW) - local _, dl = self.detailFont:getWrap(spec.detail, innerW) - local headH = math.max(1, #hl) * self.headFont:getHeight() - local detH = math.max(1, #dl) * self.detailFont:getHeight() - return padTop + headH + gapHead + detH + gapDetail - + buttonH + gapButton + hintH + padBot - end - - -- Red and Blue are each live: a column shows Play once its ROM is imported, - -- Choose ROM / drag-drop before that, and a progress bar while extracting. - local dropHint = self.android and "or copy the .gb via USB" - or "or drop the .gb file here" - local function columnSpec(version, style) - local info = GameVersion.info(version) - local spec = { - version = version, - accent = style.accent, interior = style.interior, - alpha = 1, glowScale = 1, period = style.period, - } - local importing = self.importing == version - local erroring = self.workState == "error" and self.errorVersion == version - local notice = self.notice and self.notice.version == version and self.notice - if importing and - (self.workState == "working" or self.workState == "complete") then - spec.heading = self.status - spec.detail = self.detail - spec.progress = self.progress or 0 - elseif self.ready[version] then - spec.heading = info.label .. " ROM ready" - spec.detail = "Your ROM is verified. Press Play to start." - spec.button = { kind = "play", text = "Play " .. info.label } - spec.link = { name = self.romName[version] } - elseif erroring then - spec.heading = "That ROM could not be imported" - spec.detail = self.detail - spec.button = { kind = "choose", text = "Choose ROM" } - spec.hint = dropHint - elseif notice then - spec.heading = notice.status - spec.detail = notice.detail - spec.button = { kind = "choose", text = "Choose ROM" } - spec.hint = dropHint - elseif self.returning[version] then - spec.heading = "Update required" - spec.detail = "This build needs a few more things from your " - .. info.label .. " ROM. Re-import to continue." - spec.button = { kind = "choose", text = "Choose ROM" } - spec.hint = dropHint - else - spec.heading = "Choose or drop a " .. info.label .. " ROM" - spec.detail = "The ROM is verified before any files are created." - spec.button = { kind = "choose", text = "Choose ROM" } - spec.hint = dropHint - end - return spec - end - - local redSpec = columnSpec("red", - { accent = PAL.red, interior = PAL.cardRed, period = 2.6 }) - local blueSpec = columnSpec("blue", - { accent = PAL.blue, interior = PAL.cardBlue, period = 3.4 }) - -- Yellow stays a lit placeholder until that game is supported. - local yellowSpec = { - accent = PAL.gold, interior = PAL.cardGold, - alpha = 0.92, glowScale = 0.5, period = 3.8, - heading = "Choose or drop a Yellow ROM", detail = "Yellow support is on the way.", - button = { kind = "disabled", text = "Coming soon" }, hint = "not yet available", - } - - local cardH = math.max(contentH(redSpec), contentH(blueSpec), contentH(yellowSpec)) - local regionTop = logoY + logoDH + 14 * s - local top = regionTop + math.max(0, (footerTop - regionTop - cardH) / 2) - - -- Draw one neon cartridge card and return its button/link hit rects. - local function drawCard(colX, spec, ty) - local cx = colX + third / 2 - local x = cx - cardW / 2 - local y = ty - local a = spec.alpha or 1 - local r = 16 * s - - -- pulsing neon halo - local g = 0.5 + 0.5 * math.sin(pulse * 2 * math.pi / spec.period) - neonGlow(x, y, cardW, cardH, r, spec.accent, (0.35 + 0.55 * g) * (spec.glowScale or 1)) - - -- card body: accent tint at the top fading into a dark interior, + border - fillGradRounded(x, y, cardW, cardH, r, spec.accent, spec.interior, 0.16 * a, 0.55 * a) - love.graphics.setLineWidth(math.max(1, 1.5 * s)) - col(spec.accent, 0.6 * a) - love.graphics.rectangle("line", x, y, cardW, cardH, r, r) - - local contentY = y + padTop - - -- heading - love.graphics.setFont(self.headFont) - col(PAL.heading, a) - printfB(spec.heading, x + padX, contentY, cardW - 2 * padX, "center") - local _, hl = self.headFont:getWrap(spec.heading, cardW - 2 * padX) - contentY = contentY + math.max(1, #hl) * self.headFont:getHeight() + gapHead - - -- detail - love.graphics.setFont(self.detailFont) - col(PAL.detail, a) - love.graphics.printf(spec.detail, x + padX, contentY, cardW - 2 * padX, "center") - local _, dl = self.detailFont:getWrap(spec.detail, cardW - 2 * padX) - contentY = contentY + math.max(1, #dl) * self.detailFont:getHeight() + gapDetail - - -- button, or a neon progress bar while extracting - local bx, bw, by = x + padX, cardW - 2 * padX, contentY - local buttonRect - if spec.progress ~= nil then - local h2 = math.max(8, 10 * s) - local track = by + (buttonH - h2) / 2 - col(PAL.bgBot, 0.85) - love.graphics.rectangle("fill", bx, track, bw, h2, h2 / 2, h2 / 2) - local pw = bw * clamp(spec.progress, 0, 1) - if pw > h2 then - neonGlow(bx, track, pw, h2, h2 / 2, spec.accent, 0.6) - col(spec.accent, a) - love.graphics.rectangle("fill", bx, track, pw, h2, h2 / 2, h2 / 2) - end - elseif spec.button then - local br = 12 * s - local kind = spec.button.kind - buttonRect = { x = bx, y = by, width = bw, height = buttonH } - if kind == "disabled" then - col(PAL.disabled, 0.4 * a) - love.graphics.rectangle("fill", bx, by, bw, buttonH, br, br) - love.graphics.setLineWidth(1) - col(spec.accent, 0.3 * a) - love.graphics.rectangle("line", bx, by, bw, buttonH, br, br) - love.graphics.setFont(self.buttonFont) - col(PAL.disabledInk, a) - printfB(spec.button.text, bx, by + (buttonH - self.buttonFont:getHeight()) / 2, bw, "center") - buttonRect = nil -- placeholder columns are inert - else - local cTop, cBot, ink - if kind == "play" then cTop, cBot, ink = PAL.playTop, PAL.playBot, PAL.playInk - else cTop, cBot, ink = PAL.chooseTop, PAL.chooseBot, PAL.white end - local hot = hoverEnabled and ptIn(buttonRect) - if hot then anyHover = true end - neonGlow(bx, by, bw, buttonH, br, cTop, (0.7 + 0.25 * g) * (hot and 1.7 or 1)) - fillGradRounded(bx, by, bw, buttonH, br, cTop, cBot, 1, 1) - if hot then - -- brighten the face on hover - love.graphics.setBlendMode("add") - love.graphics.setColor(1, 1, 1, 0.12) - love.graphics.rectangle("fill", bx, by, bw, buttonH, br, br) - love.graphics.setBlendMode("alpha") - end - buttonShine(bx, by, bw, buttonH, br, (pulse % 2.8) / 2.8) - love.graphics.setFont(self.buttonFont) - if kind == "play" then - -- filled play triangle + label, centred as a group - local label = spec.button.text - local tw = self.buttonFont:getWidth(label) - local tri = self.buttonFont:getHeight() * 0.55 - local groupW = tri + 12 * s + tw - local gx = bx + (bw - groupW) / 2 - local gy = by + buttonH / 2 - col(ink) - love.graphics.polygon("fill", gx, gy - tri / 2, gx, gy + tri / 2, gx + tri * 0.9, gy) - printB(label, gx + tri + 12 * s, by + (buttonH - self.buttonFont:getHeight()) / 2) - else - col(ink) - printfB(spec.button.text, bx, by + (buttonH - self.buttonFont:getHeight()) / 2, bw, "center") - end - end - end - contentY = by + buttonH + gapButton - - -- subline: a hint, or the " · re-import" link - love.graphics.setFont(self.hintFont) - local linkRect - if spec.link then - local prefix = spec.link.name .. " · " - local link = "re-import" - local pw = self.hintFont:getWidth(prefix) - local lw = self.hintFont:getWidth(link) - local startX = cx - (pw + lw) / 2 - col(PAL.detail, a) - love.graphics.print(prefix, startX, contentY) - love.graphics.print(link, startX + pw, contentY) - love.graphics.setLineWidth(1) - love.graphics.line(startX + pw, contentY + hintH, startX + pw + lw, contentY + hintH) - linkRect = { x = startX + pw, y = contentY, width = lw, height = hintH + 3 } - elseif spec.hint then - col(PAL.detail, a) - love.graphics.printf(spec.hint, x + padX, contentY, cardW - 2 * padX, "center") - end - - return buttonRect, linkRect - end - - -- Per-column hit rects, rebuilt each frame and keyed by version so clicks - -- route to the right game (Yellow has none -- it is inert). - self.buttons = {} - self.reimportRects = {} - local function place(colX, spec) - local b, l = drawCard(colX, spec, top) - if spec.version then - self.buttons[spec.version] = b - self.reimportRects[spec.version] = l + -- Self-updater banner state: computed up front so its band can be reserved + -- above the footer, then drawn after the content below. Only the four + -- actionable states surface anything. + local upStatus, upLatest, upProgress + if self.Check then + local ok, st = pcall(self.Check.state) + st = (ok and type(st) == "table") and st or nil + local status = st and st.status + if status == "available" or status == "downloading" + or status == "ready" or status == "needs_full" then + upStatus, upLatest, upProgress = status, st.latest, st.progress end end - place(0, redSpec) - place(third, blueSpec) - place(2 * third, yellowSpec) + local bannerActive = upStatus ~= nil + local bannerH = 46 * s - -- logo, centred over the split, with a gentle bob + gold glow + -- Content region: from below the tab bar down to the footer, minus the + -- updater band when one is showing. + local contentTop = tabBarY + tabBarH + 16 * s + local contentBottom = footerTop - (bannerActive and (bannerH + 20 * s) or 6 * s) + local cX = appX + padH + local cW = appW - 2 * padH + local cH = math.max(0, contentBottom - contentTop) + + -- tab bar (rebuilds self.tabRects) + self:_drawTabBar(cX, tabBarY, cW, tabBarH, chip) + + -- content: game panel for a version tab, mods panel for the mods tab + if self.tab == "mods" then + self:_drawModsPanel(cX, contentTop, cW, cH) + else + self:_drawGamePanel(self.tab, cX, contentTop, cW, cH) + end + + -- logo, over the split, with a gentle bob + gold glow + sweeping shine local bob = math.sin(pulse * (2 * math.pi / 4)) * 6 * s local lx, ly = (width - logoDW) / 2, logoY + bob love.graphics.setBlendMode("add") @@ -994,7 +1211,6 @@ function RomImporter:draw() love.graphics.draw(self.logo, (width - logoDW * 1.05) / 2, ly - logoDH * 0.025, 0, logoScale * 1.05, logoScale * 1.05) love.graphics.setBlendMode("alpha") - -- main logo, with the same sweeping shine the active buttons get local shineW = 0.16 self.shineShader:send("shinePos", -shineW + ((pulse % 2.8) / 2.8) * (1 + 2 * shineW)) self.shineShader:send("shineW", shineW) @@ -1003,9 +1219,99 @@ function RomImporter:draw() love.graphics.draw(self.logo, lx, ly, 0, logoScale, logoScale) love.graphics.setShader() - -- footer: BCG mark (inverted to white, glowing brighter on hover) + warning - local bcgHot = hoverEnabled and ptIn(self.bcgButton) - if bcgHot then anyHover = true end + -- Self-updater banner: a compact pill centred in the reserved band just above + -- the footer, on every tab. Same green "Play" treatment on its CTA. + self.updateButton = nil + if bannerActive then + local bannerW = math.min(appW - 32 * s, 560 * s) + local bx = appX + (appW - bannerW) / 2 + local by = contentBottom + math.max(0, (footerTop - contentBottom - bannerH) / 2) + local r = 12 * s + local accent = PAL.gold + + neonGlow(bx, by, bannerW, bannerH, r, accent, 0.28) + fillGradRounded(bx, by, bannerW, bannerH, r, accent, PAL.bgBot, 0.14, 0.6) + love.graphics.setLineWidth(math.max(1, 1.2 * s)) + col(accent, 0.5) + love.graphics.rectangle("line", bx, by, bannerW, bannerH, r, r) + + local padX = 16 * s + local innerX = bx + padX + local innerW = bannerW - 2 * padX + + local function actionButton(label) + love.graphics.setFont(self.detailFont) + local bw = math.min(innerW * 0.62, self.detailFont:getWidth(label) + 34 * s) + local bh = bannerH - 12 * s + local abx = bx + bannerW - padX - bw + local aby = by + (bannerH - bh) / 2 + local br = 9 * s + local rect = { x = abx, y = aby, width = bw, height = bh } + local hot = self:_hover(rect) + local gp = 0.5 + 0.5 * math.sin(pulse * 2 * math.pi / 2.4) + neonGlow(abx, aby, bw, bh, br, PAL.playTop, (0.6 + 0.25 * gp) * (hot and 1.7 or 1)) + fillGradRounded(abx, aby, bw, bh, br, PAL.playTop, PAL.playBot, 1, 1) + if hot then + love.graphics.setBlendMode("add") + love.graphics.setColor(1, 1, 1, 0.12) + love.graphics.rectangle("fill", abx, aby, bw, bh, br, br) + love.graphics.setBlendMode("alpha") + end + buttonShine(abx, aby, bw, bh, br, (pulse % 2.8) / 2.8) + love.graphics.setFont(self.detailFont) + col(PAL.playInk) + printfB(label, abx, aby + (bh - self.detailFont:getHeight()) / 2, bw, "center") + return rect + end + + local function message(text, reserveW) + love.graphics.setFont(self.detailFont) + col(PAL.heading) + love.graphics.printf(text, innerX, + by + (bannerH - self.detailFont:getHeight()) / 2, + math.max(1, innerW - reserveW - 12 * s), "left") + end + + if upStatus == "available" then + local rect = actionButton("Update") + self.updateButton = { x = rect.x, y = rect.y, width = rect.width, + height = rect.height, action = "download" } + message(upLatest and ("Update v" .. upLatest .. " available") + or "An update is available", rect.width) + elseif upStatus == "needs_full" then + local rect = actionButton("Open releases") + self.updateButton = { x = rect.x, y = rect.y, width = rect.width, + height = rect.height, action = "openurl" } + message("A new version needs a fresh download", rect.width) + elseif upStatus == "ready" then + local rect = actionButton("Restart to update") + self.updateButton = { x = rect.x, y = rect.y, width = rect.width, + height = rect.height, action = "restart" } + message("Update downloaded", rect.width) + elseif upStatus == "downloading" then + love.graphics.setFont(self.hintFont) + col(PAL.detail) + love.graphics.print("Downloading update", innerX, by + 7 * s) + local h2 = math.max(8, 10 * s) + local track = by + bannerH - h2 - 8 * s + col(PAL.bgBot, 0.85) + love.graphics.rectangle("fill", innerX, track, innerW, h2, h2 / 2, h2 / 2) + local pw = innerW * clamp(upProgress or 0, 0, 1) + if pw > h2 then + neonGlow(innerX, track, pw, h2, h2 / 2, accent, 0.6) + col(accent) + love.graphics.rectangle("fill", innerX, track, pw, h2, h2 / 2, h2 / 2) + end + end + end + + -- footer: a hairline top border, the BCG mark (inverted to white, glowing + -- brighter on hover) + the trust warning with its live bois.icu link. + love.graphics.setLineWidth(1) + col(PAL.cardBorder, 0.18) + love.graphics.line(appX + padH, footerTop, appX + appW - padH, footerTop) + + local bcgHot = self:_hover(self.bcgButton) love.graphics.setShader(self.invertShader) love.graphics.setBlendMode("add") love.graphics.setColor(1, 1, 1, bcgHot and 0.5 or 0.22) @@ -1016,28 +1322,24 @@ function RomImporter:draw() love.graphics.draw(self.bcg, bcgX, bcgY, 0, bcgScale, bcgScale) love.graphics.setShader() - -- trust warning; the bois.icu URL inside it is a real hover link love.graphics.setFont(self.warningFont) col(PAL.warning) - love.graphics.printf(TRUST_WARNING, (width - warningWidth) / 2, warningY, warningWidth, "center") + local wrapX = appX + (appW - warningWidth) / 2 + love.graphics.printf(TRUST_WARNING, wrapX, warningY, warningWidth, "center") self.linkUrlRect = nil do - local wrapX = (width - warningWidth) / 2 local lh = self.warningFont:getHeight() local _, lines = self.warningFont:getWrap(TRUST_WARNING, warningWidth) for i, line in ipairs(lines) do local sidx = line:find(COMMUNITY_URL, 1, true) if sidx then - -- the URL sits on a centred wrapped line: find its exact x-offset so the - -- coloured link overdraws the plain-warning glyphs already printed there local before = line:sub(1, sidx - 1) local lineW = self.warningFont:getWidth(line) local ux = wrapX + (warningWidth - lineW) / 2 + self.warningFont:getWidth(before) local uy = warningY + (i - 1) * lh local uw = self.warningFont:getWidth(COMMUNITY_URL) self.linkUrlRect = { x = ux, y = uy, width = uw, height = lh } - local linkHot = hoverEnabled and ptIn(self.linkUrlRect) - if linkHot then anyHover = true end + local linkHot = self:_hover(self.linkUrlRect) col(linkHot and PAL.linkHover or PAL.link) love.graphics.print(COMMUNITY_URL, ux, uy) love.graphics.setLineWidth(1) @@ -1053,9 +1355,13 @@ function RomImporter:draw() love.graphics.draw(self.vignetteMesh) love.graphics.setColor(1, 1, 1, 1) + -- drag-to-scroll the save-slot list (polls the pointer; no move/release + -- events reach the launcher, so click-vs-drag is resolved here) + self:_updateSlotDrag() + -- pointer cursor over any interactive element (desktop only) - if hoverEnabled and love.mouse.isCursorSupported and love.mouse.isCursorSupported() then - if anyHover then + if self._hoverEnabled and love.mouse.isCursorSupported and love.mouse.isCursorSupported() then + if self._anyHover then self.handCursor = self.handCursor or love.mouse.getSystemCursor("hand") love.mouse.setCursor(self.handCursor) else @@ -1074,15 +1380,86 @@ function RomImporter:mousepressed(x, y, button) love.system.openURL(COMMUNITY_URL) return end + -- Self-updater banner (touch routes here through love.touchpressed too). + -- Kept ahead of the "working" guard so it stays live during a ROM import. + if inside(self.updateButton, x, y) then + local action = self.updateButton.action + if action == "download" and self.Check then + pcall(self.Check.download) + elseif action == "restart" then + love.event.quit("restart") + elseif action == "openurl" and self.Check then + love.system.openURL(self.Check.releaseUrl()) + end + return + end + -- Tab chips switch panels even mid-import so the player can look around + -- while a ROM extracts. + for _, t in ipairs(self.tabRects or {}) do + if inside(t, x, y) then + self.tab = t.id + self._slotPress = nil -- drop any half-started slot drag on tab change + self._modPress = nil -- and any half-started mod toggle press + return + end + end if self.workState == "working" then return end - local buttons = self.buttons or {} - local reimports = self.reimportRects or {} - for _, version in ipairs(GameVersion.ORDER) do - if self.ready[version] then - if inside(reimports[version], x, y) then self:reimport(version); return - elseif inside(buttons[version], x, y) then self:play(version); return end - elseif inside(buttons[version], x, y) then - self:choose(version); return + -- Active game panel's controls (only the shown version has live hit rects). + if inside(self.playButtonRect, x, y) then + self:play(self.panelVersion); return + end + if inside(self.romButtonRect, x, y) then + local version = self.panelVersion + if self.ready[version] then self:reimport(version) else self:choose(version) end + return + end + -- SAVE FILES card: Import save / Export save, and the open-folder affordance + -- shown on the notice line after a successful export. + if inside(self.saveImportRect, x, y) then + self:chooseSaveImport(self.panelVersion); return + end + if inside(self.saveExportRect, x, y) then + self:exportSave(self.panelVersion); return + end + if inside(self.saveFolderRect, x, y) then + if self.saveFolderRect.dir then + love.system.openURL(fileUrl(self.saveFolderRect.dir)) + end + return + end + -- SAVE SLOT rows. On desktop a press only ARMS a click: _updateSlotDrag + -- commits it on release when the pointer did not move (a moved pointer scrolls + -- instead). Android has no reliable pointer polling, so it selects on press. + for _, r in ipairs(self.slotRects or {}) do + if inside(r, x, y) then + if self.android then + self:_selectSlot(self.panelVersion, r.id) + else + self._slotPress = { version = self.panelVersion, id = r.id, y0 = y, + scroll0 = self.slotScroll[self.panelVersion] or 0, moved = false } + end + return + end + end + if inside(self.newSlotRect, x, y) then + self:_newSlot(self.panelVersion); return + end + -- Mods panel: the import button dispatches on press (fixed header, no scroll + -- conflict); a toggle switch, which lives in the scrollable list, only ARMS a + -- press so _updateSlotDrag can tell a click from a drag-scroll (Android, with + -- no pointer polling, toggles on press). + if inside(self.modImportRect, x, y) then + self:chooseMod(); return + end + for _, r in ipairs(self.modRects or {}) do + if inside(r, x, y) then + if self.android then + self:_toggleMod(r.id) + else + self._modPress = { id = r.id, y0 = y, + scroll0 = self.modScroll or 0, moved = false } + end + return end end end @@ -1090,12 +1467,861 @@ end function RomImporter:keypressed(key) if self.workState == "working" then return end if key == "return" or key == "space" or key == "kpenter" then - -- Keyboard is ambiguous across columns: Play the first imported game, - -- otherwise open the Red picker. - for _, version in ipairs(GameVersion.ORDER) do - if self.ready[version] then self:play(version); return end + -- Enter acts on the visible game tab: Play if its ROM is ready, otherwise + -- open its picker. The mods / placeholder tabs have no keyboard action. + local version = self.tab + if version == "red" or version == "blue" then + if self.ready[version] then self:play(version) else self:choose(version) end end - self:choose("red") + end +end + +-- ------- Redesign panel rendering (FirstRun.dc.html) ------------------------ +-- These run inside draw(): they read the per-frame pointer through self:_hover +-- and self._s, and set the hit rects mousepressed dispatches (self.tabRects, +-- self.romButtonRect, self.playButtonRect, self.panelVersion). + +function RomImporter:_ptIn(r) + local mx, my = self._mx, self._my + return r and mx >= r.x and mx <= r.x + r.width and my >= r.y and my <= r.y + r.height +end + +function RomImporter:_hover(r) + local hot = self._hoverEnabled and self:_ptIn(r) or false + if hot then self._anyHover = true end + return hot +end + +-- A glassy white-on-dark button (ROM import + the disabled SAVE FILES pair). +-- Returns its hit rect when live, or nil when disabled (inert). +function RomImporter:_glassyButton(x, y, w, h, label, font, enabled) + local s = self._s + local r = 10 * s + love.graphics.setFont(font) + if enabled == false then + col(PAL.disabled, 0.25) + love.graphics.rectangle("fill", x, y, w, h, r, r) + love.graphics.setLineWidth(1) + col(PAL.disabledInk, 0.3) + love.graphics.rectangle("line", x, y, w, h, r, r) + col(PAL.disabledInk) + printfB(label, x, y + (h - font:getHeight()) / 2, w, "center") + return nil + end + local rect = { x = x, y = y, width = w, height = h } + local hot = self:_hover(rect) + fillGradRounded(x, y, w, h, r, PAL.white, PAL.white, hot and 0.24 or 0.16, 0.04) + love.graphics.setLineWidth(1) + col(PAL.white, 0.18) + love.graphics.rectangle("line", x, y, w, h, r, r) + col(PAL.white) + printfB(label, x, y + (h - font:getHeight()) / 2, w, "center") + return rect +end + +-- The tall green Play button (ready) or a disabled placeholder. Sets +-- self.playButtonRect. +function RomImporter:_playButton(x, y, w, h, gameName, ready, locked) + local s, pulse = self._s, self.pulse + local r = 12 * s + love.graphics.setFont(self.playFont) + if ready then + local rect = { x = x, y = y, width = w, height = h } + local hot = self:_hover(rect) + local g = 0.5 + 0.5 * math.sin(pulse * 2 * math.pi / 2.4) + neonGlow(x, y, w, h, r, PAL.playTop, (0.7 + 0.25 * g) * (hot and 1.6 or 1)) + fillGradRounded(x, y, w, h, r, PAL.playTop, PAL.playBot, 1, 1) + if hot then + love.graphics.setBlendMode("add") + love.graphics.setColor(1, 1, 1, 0.12) + love.graphics.rectangle("fill", x, y, w, h, r, r) + love.graphics.setBlendMode("alpha") + end + buttonShine(x, y, w, h, r, (pulse % 2.8) / 2.8) + local label = "Play " .. gameName + local tw = self.playFont:getWidth(label) + local tri = self.playFont:getHeight() * 0.55 + local groupW = tri + 12 * s + tw + local gx = x + (w - groupW) / 2 + local gy = y + h / 2 + col(PAL.playInk) + love.graphics.polygon("fill", gx, gy - tri / 2, gx, gy + tri / 2, gx + tri * 0.9, gy) + printB(label, gx + tri + 12 * s, y + (h - self.playFont:getHeight()) / 2) + self.playButtonRect = rect + else + col(PAL.disabled, 0.3) + love.graphics.rectangle("fill", x, y, w, h, r, r) + love.graphics.setLineWidth(1) + col(PAL.disabledInk, 0.3) + love.graphics.rectangle("line", x, y, w, h, r, r) + col(PAL.disabledInk) + local label = locked and "Coming soon" or "Import a ROM to play" + printfB(label, x, y + (h - self.playFont:getHeight()) / 2, w, "center") + self.playButtonRect = nil + end +end + +-- The R/B/Y/divider/MODS chip row. Only the active tab shows its label + +-- underline; the rest are dimmed. Rebuilds self.tabRects (chip squares). +function RomImporter:_drawTabBar(x, y, w, h, chip) + local s, pulse = self._s, self.pulse + local tabs = { + { id = "red", letter = "R", top = PAL.chipRedTop, bot = PAL.chipRedBot, + under = PAL.red, label = "RED", ink = PAL.white }, + { id = "blue", letter = "B", top = PAL.chipBlueTop, bot = PAL.chipBlueBot, + under = PAL.blue, label = "BLUE", ink = PAL.white }, + { id = "yellow", letter = "Y", top = PAL.chipGoldTop, bot = PAL.chipGoldBot, + under = PAL.gold, label = "YELLOW", ink = PAL.chipInkGold }, + { id = "mods", mods = true, top = PAL.chipModTop, bot = PAL.chipModBot, + under = PAL.modDot, label = "MODS" }, + } + local gap = 10 * s + local r = 12 * s + local chipY = y + (h - chip) / 2 - 2 * s + local underY = y + h - 3 * s + local cursorX = x + for _, t in ipairs(tabs) do + if t.mods then + -- divider between the game chips and MODS + col(PAL.cardBorder, 0.25) + love.graphics.rectangle("fill", cursorX, y + (h - 34 * s) / 2, + math.max(1, 1 * s), 34 * s) + cursorX = cursorX + gap + 6 * s + end + local active = self.tab == t.id + -- chip body + fillGradRounded(cursorX, chipY, chip, chip, r, t.top, t.bot, 1, 1) + if t.mods then + local d = 5 * s + local gd = 3 * s + local grid = 3 * d + 2 * gd + local gx = cursorX + (chip - grid) / 2 + local gy = chipY + (chip - grid) / 2 + col(PAL.modDot) + for row = 0, 2 do + for c2 = 0, 2 do + love.graphics.rectangle("fill", gx + c2 * (d + gd), gy + row * (d + gd), d, d) + end + end + else + love.graphics.setFont(self.chipFont) + col(t.ink) + printfB(t.letter, cursorX, chipY + (chip - self.chipFont:getHeight()) / 2, chip, "center") + end + if not active then + col(PAL.bgBot, 0.62) + love.graphics.rectangle("fill", cursorX, chipY, chip, chip, r, r) + end + self.tabRects[#self.tabRects + 1] = + { x = cursorX, y = chipY, width = chip, height = chip, id = t.id } + local segEnd = cursorX + chip + if active then + love.graphics.setFont(self.tabLabelFont) + col(PAL.white) + local labelX = cursorX + chip + gap + local lw = printSpaced(self.tabLabelFont, t.label, labelX, + y + (h - self.tabLabelFont:getHeight()) / 2, 2 * s) + segEnd = labelX + lw + neonGlow(cursorX, underY, segEnd - cursorX, 3 * s, 2 * s, t.under, 0.45) + col(t.under) + love.graphics.rectangle("fill", cursorX, underY, segEnd - cursorX, 3 * s) + end + cursorX = segEnd + gap + end + -- "N of 3 ready" (Red + Blue count; Yellow never ready), hidden if no room + local ready = 0 + for _, v in ipairs(GameVersion.ORDER) do if self.ready[v] then ready = ready + 1 end end + love.graphics.setFont(self.readyFont) + local label = ("%d of 3 ready"):format(ready) + local lw = self.readyFont:getWidth(label) + if x + w - lw > cursorX + 8 * s then + col(PAL.labelGray) + love.graphics.print(label, x + w - lw, y + h - self.readyFont:getHeight() - 6 * s) + end + love.graphics.setLineWidth(1) + col(PAL.cardBorder, 0.22) + love.graphics.line(x, y + h, x + w, y + h) +end + +-- One version's game panel: header (name + status pill), then a responsive +-- two-column grid (left: ROM + SAVE FILES cards + Play; right: SAVE SLOT). +function RomImporter:_drawGamePanel(version, x, y, w, h) + local s, pulse = self._s, self.pulse + self.panelVersion = version + local locked = version == "yellow" + local info = (not locked) and GameVersion.info(version) or nil + local gameName = locked and "Pokemon Yellow" or info.displayName + local ready = (not locked) and self.ready[version] or false + + -- header: name + status pill + love.graphics.setFont(self.gameNameFont) + col(PAL.white) + printB(gameName, x, y) + local nameW = self.gameNameFont:getWidth(gameName) + local pill + if ready then pill = { text = "GOOD TO GO", c = PAL.green } + elseif locked then pill = { text = "COMING SOON", c = PAL.disabledInk } + else pill = { text = "ROM REQUIRED", c = PAL.gold } end + love.graphics.setFont(self.pillFont) + local pw = self.pillFont:getWidth(pill.text) + 24 * s + local ph = self.pillFont:getHeight() + 8 * s + local px = x + nameW + 14 * s + local py = y + (self.gameNameFont:getHeight() - ph) / 2 + col(pill.c, 0.1) + love.graphics.rectangle("fill", px, py, pw, ph, ph / 2, ph / 2) + love.graphics.setLineWidth(1) + col(pill.c, 0.55) + love.graphics.rectangle("line", px, py, pw, ph, ph / 2, ph / 2) + col(pill.c) + printfB(pill.text, px, py + (ph - self.pillFont:getHeight()) / 2, pw, "center") + + local headerH = math.max(self.gameNameFont:getHeight(), ph) + local bodyTop = y + headerH + 14 * s + local bodyH = math.max(0, (y + h) - bodyTop) + + -- responsive grid: two columns when they comfortably fit, else stacked + local colGap = 18 * s + local twoCol = w >= (300 * s * 2 + colGap) + local colW = twoCol and (w - colGap) / 2 or w + local leftX = x + local rightX = twoCol and (x + colW + colGap) or x + + -- ROM card contents by state (rehomes the existing import flow) + local dropHint = self.android and "Copy the .gb via USB." + or "Or drop the .gb file here." + local accent = locked and PAL.gold or (version == "red" and PAL.red or PAL.blue) + local romState, romDetail, romBtnLabel, romBtnEnabled, romProgress + if locked then + romState, romDetail = "Not supported yet", "Yellow support is on the way." + romBtnLabel, romBtnEnabled = "Import unavailable", false + else + local importing = self.importing == version + local erroring = self.workState == "error" and self.errorVersion == version + local notice = self.notice and self.notice.version == version and self.notice + if importing and (self.workState == "working" or self.workState == "complete") then + romState = self.status or "Importing" + romDetail = self.detail or "" + romProgress = self.progress or 0 + elseif ready then + romState = self.romName[version] or "ROM imported" + romDetail = "Verified." + romBtnLabel, romBtnEnabled = "Re-import ROM", true + elseif erroring then + romState = "Import failed" + romDetail = self.detail or "That ROM could not be imported." + romBtnLabel, romBtnEnabled = "Import ROM", true + elseif notice then + romState = "No ROM imported" + romDetail = trim((notice.status or "") .. " " .. (notice.detail or "")) + romBtnLabel, romBtnEnabled = "Import ROM", true + elseif self.returning[version] then + romState = "Update required" + romDetail = "This build needs a few more things from your " + .. info.label .. " ROM. Re-import to continue." + romBtnLabel, romBtnEnabled = "Re-import ROM", true + else + romState = "No ROM imported" + romDetail = "The ROM is verified before any files are created. " .. dropHint + romBtnLabel, romBtnEnabled = "Import ROM", true + end + end + + -- card metrics + local pad = 16 * s + local innerW = colW - 2 * pad + local labelH = self.labelFont:getHeight() + love.graphics.setFont(self.stateFont) + local _, stl = self.stateFont:getWrap(romState, innerW) + local stateH = math.max(1, #stl) * self.stateFont:getHeight() + love.graphics.setFont(self.hintFont) + local _, dtl = self.hintFont:getWrap(romDetail, innerW) + local detailH = math.max(1, #dtl) * self.hintFont:getHeight() + local btnH = math.max(40 * s, self.saveBtnFont:getHeight() + 22 * s) + local romCardH = pad + labelH + 10 * s + stateH + 5 * s + detailH + 14 * s + btnH + pad + + -- SAVE FILES card: Import save is live once the ROM is imported (playable); + -- Export save is live only when the active slot actually holds a save. The + -- locked yellow placeholder has no save backend, so both stay disabled. The + -- hint line doubles as the last import/export outcome (green ok / red error). + local sfImportEnabled, sfExportEnabled = false, false + if not locked then + self:_ensureSlots(version) + sfImportEnabled = ready and true or false + local activeId = self.activeSlot[version] + for _, sl in ipairs(self.slots[version] or {}) do + if sl.id == activeId and sl.exists then sfExportEnabled = true; break end + end + end + local sfNotice = (not locked) and self.saveNotice[version] or nil + local sfHintText, sfHintCol + if sfNotice then + sfHintText, sfHintCol = sfNotice.text, (sfNotice.ok and PAL.green or PAL.red) + elseif locked then + sfHintText, sfHintCol = "Not available yet.", PAL.warning + elseif self.android then + sfHintText, sfHintCol = "Import a .sav, or drop one on the window.", PAL.warning + else + sfHintText, sfHintCol = + "Import a .sav to a new slot, or export the active slot.", PAL.warning + end + + local sfBtnH = math.max(38 * s, self.saveBtnFont:getHeight() + 20 * s) + love.graphics.setFont(self.hintFont) + local _, sfHl = self.hintFont:getWrap(sfHintText, innerW) + local sfHintH = math.max(1, #sfHl) * self.hintFont:getHeight() + local sfFolderH = (sfNotice and sfNotice.dir) and (self.hintFont:getHeight() + 4 * s) or 0 + local saveFilesH = pad + labelH + 10 * s + sfBtnH + 6 * s + sfHintH + sfFolderH + pad + local playH = math.max(50 * s, self.playFont:getHeight() + 30 * s) + + -- vertical placement of the left column + local romY = bodyTop + local saveFilesY = romY + romCardH + 12 * s + local playY + if twoCol then + playY = bodyTop + bodyH - playH -- pinned to the column's bottom + else + playY = saveFilesY + saveFilesH + 12 * s + end + + -- ROM card + roundedCard(leftX, romY, colW, romCardH, 16 * s) + local ix, iy = leftX + pad, romY + pad + love.graphics.setFont(self.labelFont) + col(PAL.labelGray) + printSpaced(self.labelFont, "ROM", ix, iy, 2 * s) + iy = iy + labelH + 10 * s + love.graphics.setFont(self.stateFont) + col(PAL.white) + printfB(romState, ix, iy, innerW, "left") + iy = iy + stateH + 5 * s + love.graphics.setFont(self.hintFont) + col(PAL.detail) + love.graphics.printf(romDetail, ix, iy, innerW, "left") + iy = iy + detailH + 14 * s + if romProgress ~= nil then + local barH = math.max(8, 10 * s) + local track = iy + (btnH - barH) / 2 + col(PAL.bgBot, 0.85) + love.graphics.rectangle("fill", ix, track, innerW, barH, barH / 2, barH / 2) + local pw2 = innerW * clamp(romProgress, 0, 1) + if pw2 > barH then + neonGlow(ix, track, pw2, barH, barH / 2, accent, 0.6) + col(accent) + love.graphics.rectangle("fill", ix, track, pw2, barH, barH / 2, barH / 2) + end + else + self.romButtonRect = + self:_glassyButton(ix, iy, innerW, btnH, romBtnLabel, self.saveBtnFont, romBtnEnabled) + end + + -- SAVE FILES card: Import save (new slot) + Export save (active slot), with an + -- outcome/hint line under them and an open-folder affordance after an export. + roundedCard(leftX, saveFilesY, colW, saveFilesH, 16 * s) + ix, iy = leftX + pad, saveFilesY + pad + love.graphics.setFont(self.labelFont) + col(PAL.labelGray) + printSpaced(self.labelFont, "SAVE FILES", ix, iy, 2 * s) + iy = iy + labelH + 10 * s + local bGap = 10 * s + local halfW = (innerW - bGap) / 2 + self.saveImportRect = + self:_glassyButton(ix, iy, halfW, sfBtnH, "Import save", self.saveBtnFont, sfImportEnabled) + self.saveExportRect = self:_glassyButton(ix + halfW + bGap, iy, halfW, sfBtnH, + "Export save", self.saveBtnFont, sfExportEnabled) + iy = iy + sfBtnH + 6 * s + love.graphics.setFont(self.hintFont) + col(sfHintCol) + love.graphics.printf(sfHintText, ix, iy, innerW, "left") + iy = iy + sfHintH + if sfNotice and sfNotice.dir then + iy = iy + 4 * s + love.graphics.setFont(self.hintFont) + local label = "Open folder" + local lw = self.hintFont:getWidth(label) + local frect = { x = ix, y = iy, width = lw, height = self.hintFont:getHeight(), + dir = sfNotice.dir } + local fhot = self:_hover(frect) + col(fhot and PAL.linkHover or PAL.link) + love.graphics.print(label, ix, iy) + love.graphics.setLineWidth(1) + love.graphics.line(ix, iy + self.hintFont:getHeight() - 1, ix + lw, + iy + self.hintFont:getHeight() - 1) + self.saveFolderRect = frect + end + + -- Play button + self:_playButton(leftX, playY, colW, playH, gameName, ready, locked) + + -- SAVE SLOT card (right column, or stacked below Play when single-column). + -- The locked Yellow placeholder has no save backend (no GameVersion entry, so + -- no slots can exist); skip the panel entirely rather than draw an empty, + -- non-functional "+ New save slot" on a COMING SOON game. + if not locked then + if twoCol then + self:_drawSaveSlotPanel(version, rightX, bodyTop, colW, bodyH) + else + local slotY = playY + playH + 12 * s + local slotH = math.max(160 * s, (bodyTop + bodyH) - slotY) + self:_drawSaveSlotPanel(version, leftX, slotY, colW, slotH) + 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 +end + +function RomImporter:_ensureSlots(version) + if not self.slots[version] then self:_refreshSlots(version) end +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 +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) + 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 +end + +-- Poll the pointer once per frame to drive drag-scroll + deferred click on the +-- save-slot list. main.lua forwards neither move nor release events to the +-- launcher, so a press only ARMS a click (see mousepressed) and this resolves +-- it: a pointer that moved past the threshold scrolls; one that did not, on +-- release, selects. Desktop only -- Android selects on press instead. +function RomImporter:_updateSlotDrag() + if self.android then return end + local down = love.mouse.isDown(1) + local p = self._slotPress + if p then + if down then + local d = self._my - p.y0 + if math.abs(d) > 4 * (self._s or 1) then p.moved = true end + if p.moved then + local maxS = (self._slotMax and self._slotMax[p.version]) or 0 + self.slotScroll[p.version] = clamp(p.scroll0 - d, 0, maxS) + end + else + if not p.moved then self:_selectSlot(p.version, p.id) end + self._slotPress = nil + end + end + -- The same click-vs-drag resolution for the mods list: a moved pointer scrolls + -- the list, a still one toggles the armed mod on release. + local mp = self._modPress + if mp then + if down then + local d = self._my - mp.y0 + if math.abs(d) > 4 * (self._s or 1) then mp.moved = true end + if mp.moved then + self.modScroll = clamp(mp.scroll0 - d, 0, self._modMax or 0) + end + else + if not mp.moved then self:_toggleMod(mp.id) end + self._modPress = nil + end + end +end + +-- Mouse wheel over a game tab scrolls its save-slot list (installed onto the +-- global love.wheelmoved in new(); see the chain there). Clamped to the last +-- content extent draw computed for that version. +function RomImporter:wheelmoved(_, dy) + local step = 48 * (self._s or 1) + if self.tab == "mods" then + local maxS = self._modMax or 0 + if maxS <= 0 then return end + self.modScroll = clamp((self.modScroll or 0) - dy * step, 0, maxS) + return + end + local version = self.panelVersion + if not version or self.tab ~= version then return end + local maxS = (self._slotMax and self._slotMax[version]) or 0 + if maxS <= 0 then return end + self.slotScroll[version] = clamp((self.slotScroll[version] or 0) - dy * step, 0, maxS) +end + +-- SAVE SLOT card: header ("SAVE SLOT" + "N slots"), a scrollable list of slot +-- rows (name + meta, LOADED pill on the active one), and a dashed "+ New save +-- slot" button pinned to the bottom. Empty registries show a dashed hint box. +function RomImporter:_drawSaveSlotPanel(version, x, y, w, h) + local s = self._s + local pad = 16 * s + roundedCard(x, y, w, h, 16 * s) + self:_ensureSlots(version) + local slots = self.slots[version] or {} + local active = self.activeSlot[version] + local n = #slots + + -- header: "SAVE SLOT" (left) + "N slots" / "1 slot" (right) + love.graphics.setFont(self.labelFont) + col(PAL.labelGray) + printSpaced(self.labelFont, "SAVE SLOT", x + pad, y + pad, 2 * s) + local countTxt = (n == 1) and "1 slot" or (n .. " slots") + local cw = self.labelFont:getWidth(countTxt) + love.graphics.print(countTxt, x + w - pad - cw, y + pad) + + local labelH = self.labelFont:getHeight() + local listTop = y + pad + labelH + 12 * s + + -- "+ New save slot" pinned to the card bottom; the list fills the gap above. + local newBtnH = math.max(38 * s, self.saveBtnFont:getHeight() + 18 * s) + local newBtnY = y + h - pad - newBtnH + local listBottom = newBtnY - 10 * s + local listH = math.max(0, listBottom - listTop) + local rx, rw = x + pad, w - 2 * pad + + if n == 0 then + -- empty state: a dashed box with the centred hint + love.graphics.setLineWidth(math.max(1, 1 * s)) + col(PAL.cardBorder, 0.45) + dashedRoundRect(rx, listTop, rw, listH, 12 * s, 7 * s, 5 * s) + love.graphics.setFont(self.hintFont) + col(PAL.warning) + love.graphics.printf("No saves yet - start a new game or import one.", + rx + 12 * s, listTop + listH / 2 - self.hintFont:getHeight() / 2, + rw - 24 * s, "center") + elseif listH > 0 then + local nameH = self.slotNameFont:getHeight() + local metaH = self.labelFont:getHeight() + local rowPadV = 10 * s + local rowH = rowPadV * 2 + nameH + 4 * s + metaH + local rowGap = 8 * s + local rr = 12 * s + + -- clamp scroll against the current content extent, and stash the max so the + -- wheel handler (which has no geometry) can clamp against the same value + local totalH = n * rowH + (n - 1) * rowGap + local maxScroll = math.max(0, totalH - listH) + self._slotMax = self._slotMax or {} + self._slotMax[version] = maxScroll + local scroll = clamp(self.slotScroll[version] or 0, 0, maxScroll) + self.slotScroll[version] = scroll + + self.slotRects = {} + love.graphics.setScissor(math.floor(rx), math.floor(listTop), + math.ceil(rw), math.ceil(listH)) + for i, slot in ipairs(slots) do + local ry = listTop - scroll + (i - 1) * (rowH + rowGap) + if ry + rowH >= listTop and ry <= listBottom then + local selected = slot.id == active + if selected then neonGlow(rx, ry, rw, rowH, rr, PAL.green, 0.5) end + fillGradRounded(rx, ry, rw, rowH, rr, PAL.slotBg, PAL.slotBg, 0.6, 0.6) + love.graphics.setLineWidth(math.max(1, (selected and 1.5 or 1) * s)) + col(selected and PAL.green or PAL.cardBorder, selected and 0.9 or 0.22) + love.graphics.rectangle("line", rx, ry, rw, rowH, rr, rr) + + -- LOADED pill (top-right of the active row), then reserve its width + local pillW = 0 + if selected then + love.graphics.setFont(self.warningFont) + local pText = "LOADED" + local pw = self.warningFont:getWidth(pText) + 14 * s + local ph = self.warningFont:getHeight() + 6 * s + local ppx = rx + rw - 12 * s - pw + local ppy = ry + rowPadV + col(PAL.green) + love.graphics.rectangle("fill", ppx, ppy, pw, ph, ph / 2, ph / 2) + col(PAL.playInk) + printfB(pText, ppx, ppy + (ph - self.warningFont:getHeight()) / 2, pw, "center") + pillW = pw + 10 * s + end + + love.graphics.setFont(self.slotNameFont) + col(PAL.white) + local name = slot.name or "NEW GAME" + printB(ellipsize(self.slotNameFont, name, rw - 24 * s - pillW), + rx + 12 * s, ry + rowPadV) + + local metaTxt + if slot.exists and slot.meta then + metaTxt = ("%d badges - %s - %d caught"):format( + slot.meta.badges or 0, slot.meta.timeText or "0:00", + slot.meta.dexCount or 0) + else + metaTxt = "empty slot" + end + love.graphics.setFont(self.labelFont) + col(PAL.warning) + love.graphics.print(ellipsize(self.labelFont, metaTxt, rw - 24 * s), + rx + 12 * s, ry + rowPadV + nameH + 4 * s) + + -- clip the hit rect to the visible list band so a partly-scrolled row + -- is only clickable where it actually shows + local vy = math.max(ry, listTop) + local vy2 = math.min(ry + rowH, listBottom) + if vy2 > vy then + self.slotRects[#self.slotRects + 1] = + { x = rx, y = vy, width = rw, height = vy2 - vy, id = slot.id } + end + end + end + love.graphics.setScissor() + + -- thin scrollbar thumb when the list overflows + if maxScroll > 0 then + local trackH = listH + local thumbH = math.max(24 * s, trackH * (listH / totalH)) + local thumbY = listTop + (trackH - thumbH) * (scroll / maxScroll) + col(PAL.cardBorder, 0.35) + love.graphics.rectangle("fill", rx + rw - 3 * s, thumbY, 3 * s, thumbH, + 1.5 * s, 1.5 * s) + end + end + + -- "+ New save slot" (dashed, transparent) pinned to the bottom + local nrect = { x = rx, y = newBtnY, width = rw, height = newBtnH } + local nhot = self:_hover(nrect) + love.graphics.setLineWidth(math.max(1, 1.4 * s)) + col(PAL.cardBorder, nhot and 0.7 or 0.45) + dashedRoundRect(nrect.x, nrect.y, nrect.width, nrect.height, 10 * s, 6 * s, 5 * s) + love.graphics.setFont(self.saveBtnFont) + col(PAL.detail, nhot and 1 or 0.9) + printfB("+ New save slot", nrect.x, + nrect.y + (newBtnH - self.saveBtnFont:getHeight()) / 2, nrect.width, "center") + self.newSlotRect = nrect +end + +-- Reload the mods list from LauncherMods (the source of truth: it reads the +-- same options.mods enable-state the loader persists). Cheap enough to call on +-- any toggle / install; the per-frame draw calls it lazily through _ensureMods +-- so a still list costs nothing after the first paint. +function RomImporter:_refreshMods() + local LauncherMods = require("src.mods.LauncherMods") + self.mods = LauncherMods.list() or {} +end + +function RomImporter:_ensureMods() + if not self.mods then self:_refreshMods() end +end + +-- Flip a mod's enabled flag (persisted via LauncherMods.setEnabled) and relist +-- so the toggle, count, and every status chip reflect the new resolution. +function RomImporter:_toggleMod(id) + local LauncherMods = require("src.mods.LauncherMods") + local cur = false + for _, m in ipairs(self.mods or {}) do + if m.id == id then cur = m.enabled; break end + end + LauncherMods.setEnabled(id, not cur) + self:_refreshMods() +end + +-- The status-chip label + colour for a mod row (deriveList's status verdict). +local function modStatusChip(status) + if status == "ok" then return "Ready", PAL.green end + if status == "conflict" then return "Conflict", PAL.red end + return "Incompatible", PAL.gold -- "warn": bad range or missing dependency +end + +-- MODS panel. Header ("Mods" + "N of M enabled" + "Import mod .zip"), an +-- install-result / drag-drop notice line, then a scrollable list of mod cards +-- (name + badge chip + description, a status chip, and a toggle switch). An +-- empty install shows a friendly dashed hint box. +function RomImporter:_drawModsPanel(x, y, w, h) + local s = self._s + self:_ensureMods() + local mods = self.mods or {} + + -- header: "Mods" + "N of M enabled" (left) and "Import mod .zip" (right) + love.graphics.setFont(self.gameNameFont) + col(PAL.white) + printB("Mods", x, y) + local nameW = self.gameNameFont:getWidth("Mods") + local headerH = self.gameNameFont:getHeight() + + local enabledCount = 0 + for _, m in ipairs(mods) do if m.enabled then enabledCount = enabledCount + 1 end end + love.graphics.setFont(self.hintFont) + col(PAL.warning) + love.graphics.print(("%d of %d enabled"):format(enabledCount, #mods), + x + nameW + 14 * s, y + (headerH - self.hintFont:getHeight()) / 2) + + local btnLabel = "Import mod .zip" + local btnH = math.max(38 * s, self.saveBtnFont:getHeight() + 20 * s) + local btnW = math.min(w * 0.5, self.saveBtnFont:getWidth(btnLabel) + 40 * s) + local btnX = x + w - btnW + local btnY = y + (headerH - btnH) / 2 + self.modImportRect = + self:_glassyButton(btnX, btnY, btnW, btnH, btnLabel, self.saveBtnFont, true) + + local top = y + headerH + 14 * s + + -- notice line: the last install result, else the drag-drop hint + love.graphics.setFont(self.hintFont) + if self.modNotice then + col(self.modNotice.ok and PAL.green or PAL.red) + love.graphics.printf(self.modNotice.text, x, top, w, "left") + else + col(PAL.warning) + love.graphics.printf(self.android and "Copy a mod .zip via USB." + or "Or drop a mod .zip onto the window.", x, top, w, "left") + end + top = top + self.hintFont:getHeight() + 12 * s + + local listH = math.max(0, (y + h) - top) + + -- empty state: a dashed box with a centred hint + if #mods == 0 then + local boxH = math.min(listH, 120 * s) + love.graphics.setLineWidth(math.max(1, 1 * s)) + col(PAL.cardBorder, 0.45) + dashedRoundRect(x, top, w, boxH, 14 * s, 7 * s, 5 * s) + love.graphics.setFont(self.hintFont) + col(PAL.warning) + love.graphics.printf("No mods installed - drop a mod .zip here to add one.", + x + 16 * s, top + boxH / 2 - self.hintFont:getHeight() / 2, w - 32 * s, "center") + self.modRects = {} + self._modMax = 0 + return + end + + -- card metrics (design: rounded 14, padding 14x16; toggle 56x28) + local padH, padV = 16 * s, 14 * s + local cardGap, cardR = 10 * s, 14 * s + local tw, th = 52 * s, 28 * s + local innerW = w - 2 * padH + local chipH = self.hintFont:getHeight() + 8 * s + local clusterH = chipH + 6 * s + th -- status chip stacked over the toggle + + love.graphics.setFont(self.stateFont) + local nameH = self.stateFont:getHeight() + + -- pre-pass: per-card layout + total height, so scroll can clamp to content + local layout, total = {}, 0 + for i, m in ipairs(mods) do + local chipText = modStatusChip(m.status) + local chipW = self.hintFont:getWidth(chipText) + 20 * s + local clusterW = math.max(chipW, tw) + local leftW = math.max(40 * s, innerW - clusterW - 14 * s) + local descH = 0 + if m.description ~= "" then + love.graphics.setFont(self.hintFont) + local _, dl = self.hintFont:getWrap(m.description, leftW) + descH = math.max(1, #dl) * self.hintFont:getHeight() + end + local contentH = padV * 2 + nameH + (descH > 0 and (6 * s + descH) or 0) + local cardH = math.max(contentH, padV * 2 + clusterH) + layout[i] = { h = cardH, leftW = leftW, clusterW = clusterW, + chipText = chipText, chipW = chipW } + total = total + cardH + end + total = total + (#mods - 1) * cardGap + + local maxScroll = math.max(0, total - listH) + self._modMax = maxScroll + local scroll = clamp(self.modScroll or 0, 0, maxScroll) + self.modScroll = scroll + self.modRects = {} + + love.graphics.setScissor(math.floor(x), math.floor(top), + math.ceil(w), math.ceil(listH)) + local cy = top - scroll + for i, m in ipairs(mods) do + local L = layout[i] + local cardH = L.h + if cy + cardH >= top and cy <= top + listH then + roundedCard(x, cy, w, cardH, cardR) + local nx = x + padH + local ny = cy + padV + + -- name (ellipsized to leave room for the badge chip) + badge chip + love.graphics.setFont(self.warningFont) + local badgeTW = self.warningFont:getWidth(m.badge) + local badgeW = badgeTW + 12 * s + local badgeH = self.warningFont:getHeight() + 6 * s + love.graphics.setFont(self.stateFont) + col(PAL.white) + local drawnName = ellipsize(self.stateFont, m.name, L.leftW - badgeW - 8 * s) + printB(drawnName, nx, ny) + local bxx = nx + self.stateFont:getWidth(drawnName) + 8 * s + local byy = ny + (nameH - badgeH) / 2 + love.graphics.setLineWidth(1) + col(PAL.cardBorder, 0.5) + love.graphics.rectangle("line", bxx, byy, badgeW, badgeH, 5 * s, 5 * s) + love.graphics.setFont(self.warningFont) + col(PAL.warning) + love.graphics.print(m.badge, bxx + 6 * s, + byy + (badgeH - self.warningFont:getHeight()) / 2) + + -- description under the name, wrapped in the left block + if m.description ~= "" then + love.graphics.setFont(self.hintFont) + col(PAL.detail) + love.graphics.printf(m.description, nx, ny + nameH + 6 * s, L.leftW, "left") + end + + -- right cluster: status chip stacked over the toggle, vertically centred + local clusterX = x + w - padH - L.clusterW + local clusterY = cy + (cardH - clusterH) / 2 + local _, chipColor = modStatusChip(m.status) + local chipX = clusterX + (L.clusterW - L.chipW) / 2 + col(chipColor, 0.1) + love.graphics.rectangle("fill", chipX, clusterY, L.chipW, chipH, chipH / 2, chipH / 2) + love.graphics.setLineWidth(1) + col(chipColor, 0.55) + love.graphics.rectangle("line", chipX, clusterY, L.chipW, chipH, chipH / 2, chipH / 2) + love.graphics.setFont(self.hintFont) + col(chipColor) + printfB(L.chipText, chipX, + clusterY + (chipH - self.hintFont:getHeight()) / 2, L.chipW, "center") + + -- toggle switch (ON = green gradient + glow, knob right; OFF = gray, left) + local tx = clusterX + (L.clusterW - tw) / 2 + local ty = clusterY + chipH + 6 * s + local rr = th / 2 + local trect = { x = tx - 6 * s, y = ty - 6 * s, + width = tw + 12 * s, height = th + 12 * s, id = m.id } + self:_hover(trect) + if m.enabled then + neonGlow(tx, ty, tw, th, rr, PAL.green, 0.45) + fillGradRounded(tx, ty, tw, th, rr, PAL.playTop, PAL.playBot, 1, 1) + else + col(PAL.disabled, 0.35) + love.graphics.rectangle("fill", tx, ty, tw, th, rr, rr) + end + local kd = th - 6 * s + local kcx = m.enabled and (tx + tw - 3 * s - kd / 2) or (tx + 3 * s + kd / 2) + col(PAL.white) + love.graphics.circle("fill", kcx, ty + th / 2, kd / 2) + + -- hit rect clipped to the visible list band (a partly-scrolled toggle is + -- only clickable where it actually shows) + local vy = math.max(trect.y, top) + local vy2 = math.min(trect.y + trect.height, top + listH) + if vy2 > vy then + self.modRects[#self.modRects + 1] = + { x = trect.x, y = vy, width = trect.width, height = vy2 - vy, id = m.id } + end + end + cy = cy + cardH + cardGap + end + love.graphics.setScissor() + + -- thin scrollbar thumb when the list overflows + if maxScroll > 0 then + local thumbH = math.max(24 * s, listH * (listH / total)) + local thumbY = top + (listH - thumbH) * (scroll / maxScroll) + col(PAL.cardBorder, 0.35) + love.graphics.rectangle("fill", x + w - 3 * s, thumbY, 3 * s, thumbH, 1.5 * s, 1.5 * s) end end diff --git a/src/import/SaveFileIO.lua b/src/import/SaveFileIO.lua new file mode 100644 index 00000000..8824e7e2 --- /dev/null +++ b/src/import/SaveFileIO.lua @@ -0,0 +1,112 @@ +-- SaveFileIO -- the launcher's glue between a raw Gen1 .sav battery image and +-- this project's save slots. Keeps RomImporter lean: the SAVE FILES card just +-- calls importToSlot / exportActiveSlot and renders the {ok, result} outcome. +-- +-- Import reads bytes (an absolute picker path, a dropped LOVE file, or raw +-- bytes), runs them through SaveConvert.importSav (32768-byte + checksum +-- validated), then registers a fresh slot, writes it, and makes it active. +-- Export loads the active slot, encodes it back to a 32768-byte SRAM image, and +-- drops it in the save directory's exports/ folder, returning the absolute path +-- so the launcher can offer an "open folder" affordance. +-- +-- Every failure returns false + a friendly one-line message (never raises), so +-- the card can surface it as a red notice line rather than crashing. + +local SaveConvert = require("src.save_convert.SaveConvert") +local SaveData = require("src.core.SaveData") +local GameVersion = require("src.core.GameVersion") + +local SaveFileIO = {} + +local SAVE_SIZE = SaveConvert.SAVE_SIZE + +-- Resolve raw save bytes from whatever the launcher hands us: +-- * a LOVE DroppedFile (a table/userdata with :open/:read/:getSize), read the +-- way RomImporter reads a dropped ROM; +-- * a raw 32768-byte string (the tests and the in-memory path) used as-is; +-- * any other string treated as an absolute picker path opened with io.open. +-- A picker path is never 32768 bytes long, so the length test disambiguates it +-- from a raw image cleanly. Returns bytes, or nil + an error string. +local function readSource(source) + local t = type(source) + if t == "table" or t == "userdata" then + if type(source.read) ~= "function" then + return nil, "that file could not be read" + end + local ok, openErr = source:open("r") + if not ok then return nil, "could not open the dropped file: " .. tostring(openErr) end + local data, readErr = source:read(source:getSize()) + source:close() + if not data then return nil, "could not read the dropped file: " .. tostring(readErr) end + return data + end + if t ~= "string" then + return nil, "no save file was provided" + end + if #source == SAVE_SIZE then + return source + end + local f, openErr = io.open(source, "rb") + if not f then return nil, "could not read the save file: " .. tostring(openErr) end + local data = f:read("*a") + f:close() + if type(data) ~= "string" then return nil, "the save file was empty" end + return data +end + +-- importToSlot(source, version) -> ok, slotIdOrErr +-- source: an absolute path, a LOVE DroppedFile, or raw 32768 bytes. On success +-- registers a new slot for the version, writes the imported save into it, makes +-- it the active slot, and returns true + the new slot id. On any failure +-- returns false + a friendly message. +function SaveFileIO.importToSlot(source, version) + version = version or GameVersion.get() + local bytes, readErr = readSource(source) + if not bytes then return false, readErr end + if #bytes ~= SAVE_SIZE then + return false, ("A save file must be %d bytes (32 KB); this one is %d.") + :format(SAVE_SIZE, #bytes) + end + local save, convertErr = SaveConvert.importSav(bytes, version) + if not save then return false, convertErr end + -- Tag the game version and normalize the meta stamp: SaveConvert leaves + -- meta.format = "gen1_import", but SaveData.load's migration pass compares + -- the format numerically, so re-stamp it to the current format (the imported + -- table is already current-shaped, so no migration is skipped by doing so). + save.version = version + save.meta = SaveData.buildMeta(nil, save.meta) + local slotId = SaveData.createSlot(version) + if not slotId then return false, "this game has no save slots to import into" end + local ok, writeErr = SaveData.writeSlot(version, slotId, save) + if not ok then + return false, "could not write the imported save: " .. tostring(writeErr) + end + SaveData.setActiveSlot(version, slotId) + return true, slotId +end + +-- exportActiveSlot(version) -> ok, pathOrErr +-- Loads the version's active slot save (SaveData.load semantics), encodes it +-- back to a 32768-byte SRAM image, and writes it to +-- exports/gen1recomp--.sav in the save directory (created if +-- absent). Returns true + the absolute path on success, false + a friendly +-- message otherwise. +function SaveFileIO.exportActiveSlot(version) + version = version or GameVersion.get() + local save = SaveData.load(version) + if not save then return false, "this game has no save to export yet" end + local bytes, exportErr = SaveConvert.exportSav(save) + if not bytes then return false, exportErr end + local slotId = SaveData.activeSlot(version) or "save" + local fs = love and love.filesystem + if not (fs and fs.write) then return false, "no filesystem available to export to" end + if fs.createDirectory then fs.createDirectory("exports") end + local rel = ("exports/gen1recomp-%s-%s.sav"):format(version, slotId) + local ok, writeErr = fs.write(rel, bytes) + if not ok then return false, "could not write the export: " .. tostring(writeErr) end + local base = fs.getSaveDirectory and fs.getSaveDirectory() or "" + if base ~= "" then return true, base .. "/" .. rel end + return true, rel +end + +return SaveFileIO diff --git a/src/inventory/ItemEffects.lua b/src/inventory/ItemEffects.lua index 0e00be94..7cbed126 100644 --- a/src/inventory/ItemEffects.lua +++ b/src/inventory/ItemEffects.lua @@ -114,10 +114,11 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) local itemDef = data.items[itemId] local name = itemDef and itemDef.name or itemId - -- ItemUseVitamin / ItemUsePPUp / ItemUseEvoStone / ItemUseCoinCase - -- all refuse mid-battle (jp nz, ItemUseNotTime) + -- ItemUseVitamin / ItemUsePPUp / ItemUseEvoStone / ItemUseCoinCase / + -- ItemUseTMHM all refuse mid-battle (jp nz, ItemUseNotTime) if battle and (VITAMINS[itemId] or STONES[itemId] or itemId == "PP_UP" - or itemId == "RARE_CANDY" or itemId == "COIN_CASE") then + or itemId == "RARE_CANDY" or itemId == "COIN_CASE" + or (itemDef and itemDef.machine)) then return "failed", { "OAK: " .. save.player.name .. "!\nThis isn't the\ntime to use that!" } end diff --git a/src/link/LinkState.lua b/src/link/LinkState.lua index 28f98f7a..2023fa48 100644 --- a/src/link/LinkState.lua +++ b/src/link/LinkState.lua @@ -501,6 +501,10 @@ function LinkState:updateTrade(input) require("src.core.Sound").play(game.data, "Trade_Machine") Screens.push(game, "TradeAnim", { sent = sent, received = received, + enemyName = (self.peerName or "TRAINER"), + playerOt = game.save.player.name, + playerOtId = sent.otId or game.save.player.id, + enemyOtId = received.otId, onDone = function() game.stack:push(TextBox.new(game, ("Trade completed!\f%s received\n%s!"):format(game.save.player.name, name), diff --git a/src/mods/LauncherMods.lua b/src/mods/LauncherMods.lua new file mode 100644 index 00000000..1b0b6aa3 --- /dev/null +++ b/src/mods/LauncherMods.lua @@ -0,0 +1,335 @@ +-- Launcher-side mod surface (18/launcher redesign): the mods panel runs +-- BEFORE Game:load, so this NEVER loads a mod entry chunk -- it scans +-- manifests only. The full loader (src/mods/Loader.lua) still owns the real +-- load at boot; this reads the same options.mods enable-state the loader +-- writes, derives per-mod status with the pure ManagerState.resolveToggle, +-- and installs a dropped/chosen .zip into the save-dir "mods//" tree. +-- +-- Split in two: the pure derivation (deriveList, locateRoot) has no love and +-- no filesystem, so the engine tier can table-drive it; the discovery and +-- install paths reach for love.filesystem and SaveData. + +local Manifest = require("src.mods.Manifest") +local ManagerState = require("src.mods.ManagerState") +local Semver = require("src.mods.Semver") +local Version = require("src.core.Version") +local SaveData = require("src.core.SaveData") + +local LauncherMods = {} + +-- ------- pure status derivation + +-- A hard-dependency / conflict / version verdict for one manifest. mods is +-- the id -> validated-manifest map resolveToggle reads (its dependencySpecs, +-- conflictSpecs, version and game_version are exactly the fields the loader's +-- Manifest.validate produced); enabledSet is the current desired enable-set. +local function statusFor(mods, id, enabledSet, enabled) + local m = mods[id] + -- conflict only bites an enabled mod: resolveToggle's conflict list is + -- bidirectional (this mod's conflicts spec vs an enabled other, and an + -- enabled other's spec vs this mod), which is exactly the launcher chip. + if enabled then + local r = ManagerState.resolveToggle(mods, id, true, enabledSet) + if #r.conflicts > 0 then + local otherId = r.conflicts[1] + local other = mods[otherId] + return "conflict", + "Conflicts with " .. ((other and other.name) or otherId) + end + end + -- warn: the engine is outside the mod's game_version range + if m.game_version + and not Semver.satisfies(Version.engine, m.game_version) then + return "warn", "Needs engine " .. m.game_version + .. " (have " .. Version.engine .. ")" + end + -- warn: a hard dependency is absent, switched off, or the wrong version. + -- resolveToggle would cascade-enable a merely-disabled dep rather than flag + -- it, so the disabled case is judged straight off the manifest here. + for _, spec in ipairs(m.dependencySpecs or {}) do + local dep = mods[spec.id] + if not dep then + return "warn", "Needs " .. spec.id .. " (not installed)" + elseif not enabledSet[spec.id] then + return "warn", "Needs " .. spec.id .. " (disabled)" + elseif spec.range + and not Semver.satisfies(dep.version, spec.range) then + return "warn", "Needs " .. spec.id .. " " .. spec.range + end + end + return "ok", "Ready" +end + +-- deriveList(manifests, options) -> the panel row list, pure. +-- manifests is an array of validated manifests (Manifest.validate output); +-- options is the options table (only options.mods is read). Rows come back +-- sorted by id so the panel order is stable. +function LauncherMods.deriveList(manifests, options) + local mods = options and options.mods or {} + local ordered = {} + for _, m in ipairs(manifests) do ordered[#ordered + 1] = m end + table.sort(ordered, function(a, b) return a.id < b.id end) + + local byId, enabledSet = {}, {} + for _, m in ipairs(ordered) do + byId[m.id] = m + -- missing entry means enabled, matching the loader's default + if mods[m.id] ~= false then enabledSet[m.id] = true end + end + + local out = {} + for _, m in ipairs(ordered) do + local enabled = enabledSet[m.id] == true + local status, detail = statusFor(byId, m.id, enabledSet, enabled) + local raw = m.raw or {} + out[#out + 1] = { + id = m.id, + name = m.name or m.id, + version = m.version, + -- category, then profile, then a generic fallback -- uppercased + badge = tostring(raw.category or m.profile or "MOD"):upper(), + description = m.description or "", + enabled = enabled, + status = status, + statusDetail = detail, + } + end + return out +end + +-- locateRoot(paths) -> the mod-root prefix inside a mounted archive, pure. +-- paths is a shallow listing: top-level file names as-is, and for a top-level +-- directory a "/manifest.json" entry when it holds one. Returns "" when +-- the manifest sits at the archive root, "" when a single top-level +-- folder holds it, or nil + a user-presentable reason. +function LauncherMods.locateRoot(paths) + for _, p in ipairs(paths) do + if p == "manifest.json" then return "" end + end + local topDirs, seen, hasManifest = {}, {}, {} + for _, p in ipairs(paths) do + local top, rest = p:match("^([^/]+)/(.+)$") + if top then + if not seen[top] then + seen[top] = true + topDirs[#topDirs + 1] = top + end + if rest == "manifest.json" then hasManifest[top] = true end + end + end + if #topDirs == 1 and hasManifest[topDirs[1]] then return topDirs[1] end + if #topDirs > 1 then + return nil, "the .zip must contain a single mod folder" + end + return nil, "no manifest.json found in the .zip" +end + +-- ------- discovery (love.filesystem) + +local function decodeManifest(raw, path) + local Json = require("src.link.Json") + local data, decodeErr = Json.decode(raw) + if not data then return nil, decodeErr end + local ok, manifest = pcall(Manifest.validate, data, path) + if not ok then return nil, manifest end + return manifest +end + +-- Scan "mods/" one level deep for valid manifests (mirrors Loader:_discover, +-- but validates only -- no entry chunk is ever loaded). First id wins on a +-- duplicate. Returns an array of validated manifests. +local function discover() + local fs = love and love.filesystem + local out = {} + if not (fs and fs.getInfo and fs.getDirectoryItems) then return out end + if not fs.getInfo("mods") then return out end + local seen = {} + for _, name in ipairs(fs.getDirectoryItems("mods")) do + local path = "mods/" .. name + local info = fs.getInfo(path) + if info and info.type == "directory" then + local raw = fs.read(path .. "/manifest.json") + if raw then + local manifest = decodeManifest(raw, path) + if manifest and not seen[manifest.id] then + seen[manifest.id] = true + out[#out + 1] = manifest + end + end + end + end + return out +end + +-- list() -> the mods-panel rows for the current install. Reads the same +-- options.mods enable-state the loader persists, so a toggle here is what the +-- game sees on its next boot. +function LauncherMods.list() + local options = SaveData.loadOptions() + return LauncherMods.deriveList(discover(), options) +end + +-- setEnabled(id, enabled): persist options.mods[id] in the exact shape +-- Loader:_saveState writes (a plain boolean), so the running game and the +-- in-game ManagerState pick it up unchanged. +function LauncherMods.setEnabled(id, enabled) + local options = SaveData.loadOptions() + options.mods = options.mods or {} + options.mods[id] = enabled and true or false + SaveData.saveOptions(options) + return true +end + +-- ------- install (love.filesystem) + +-- Read a .zip source into bytes. A string is an external absolute path (like +-- a chosen ROM) read with io.*, falling back to a save-dir-relative +-- love.filesystem read; a love DroppedFile is opened the way RomImporter +-- ingests dropped ROMs. +local function readArchive(source) + local t = type(source) + if (t == "userdata" or t == "table") and type(source.open) == "function" then + local ok = source:open("r") + if not ok then return nil, "could not open the dropped file" end + local data = source:read(source:getSize()) + source:close() + if not data then return nil, "the dropped file could not be read" end + return data + end + if t == "string" then + local f = io.open(source, "rb") + if f then + local data = f:read("*a") + f:close() + if not data then return nil, "could not read " .. source end + return data + end + if love and love.filesystem then + local data = love.filesystem.read(source) + if data then return data end + end + return nil, "could not open " .. source + end + return nil, "unsupported archive source" +end + +-- Shallow listing of a mounted archive shaped for locateRoot: files by name, +-- and for each top-level directory a "/manifest.json" marker only when it +-- actually holds one (so a lone folder with no manifest still reads as empty). +local function topLevelPaths(mount) + local fs = love.filesystem + local paths = {} + for _, name in ipairs(fs.getDirectoryItems(mount)) do + local info = fs.getInfo(mount .. "/" .. name) + if info and info.type == "directory" then + if fs.getInfo(mount .. "/" .. name .. "/manifest.json", "file") then + paths[#paths + 1] = name .. "/manifest.json" + end + else + paths[#paths + 1] = name + end + end + return paths +end + +local function copyTree(src, dst) + local fs = love.filesystem + if not fs.createDirectory(dst) then + return nil, "could not create " .. dst + end + for _, name in ipairs(fs.getDirectoryItems(src)) do + local s = src .. "/" .. name + local d = dst .. "/" .. name + local info = fs.getInfo(s) + if info and info.type == "directory" then + local ok, err = copyTree(s, d) + if not ok then return nil, err end + else + local data = fs.read(s) + if data == nil then return nil, "could not read " .. name end + local ok, err = fs.write(d, data) + if not ok then return nil, "could not write " .. name .. ": " .. tostring(err) end + end + end + return true +end + +local function removeTree(path) + local fs = love.filesystem + local info = fs.getInfo(path) + if not info then return end + if info.type == "directory" then + for _, child in ipairs(fs.getDirectoryItems(path)) do + removeTree(path .. "/" .. child) + end + end + fs.remove(path) +end + +-- installZip(source) -> true, id | nil, errString +-- source is an external path or a love DroppedFile. The archive is validated +-- BEFORE anything is copied; every path unmounts and clears the staged temp +-- file, and a failed copy rolls its partial tree back. A dropped file outside +-- the save dir is staged into a save-dir temp first, because +-- love.filesystem.mount only reaches a save-directory-relative path. +function LauncherMods.installZip(source) + if not (love and love.filesystem) then + return nil, "mod install needs LOVE" + end + local fs = love.filesystem + local data, readErr = readArchive(source) + if not data then return nil, readErr end + + -- stage into a save-dir temp so mount can reach it + local tmp = ("mod_import_%d_%d.zip"):format(os.time(), math.random(0, 999999)) + local ok, writeErr = fs.write(tmp, data) + if not ok then + return nil, "could not stage the .zip: " .. tostring(writeErr) + end + local mount = "mod_import_mount" + if not fs.mount(tmp, mount) then + fs.remove(tmp) + return nil, "that .zip could not be opened" + end + local function cleanup() + pcall(fs.unmount, tmp) + fs.remove(tmp) + end + + local prefix, rootErr = LauncherMods.locateRoot(topLevelPaths(mount)) + if not prefix then + cleanup() + return nil, rootErr + end + local root = prefix == "" and mount or (mount .. "/" .. prefix) + + local raw = fs.read(root .. "/manifest.json") + if not raw then + cleanup() + return nil, "the .zip has no readable manifest.json" + end + local manifest, manifestErr = decodeManifest(raw, root) + if not manifest then + cleanup() + return nil, "invalid mod manifest: " .. tostring(manifestErr) + end + + -- reject a duplicate before touching the mods tree + local dest = "mods/" .. manifest.id + if fs.getInfo(dest) then + cleanup() + return nil, "a mod named '" .. manifest.id .. "' is already installed" + end + + fs.createDirectory("mods") + local copied, copyErr = copyTree(root, dest) + if not copied then + removeTree(dest) + cleanup() + return nil, copyErr or "could not copy the mod files" + end + cleanup() + return true, manifest.id +end + +return LauncherMods diff --git a/src/mods/Schemas.lua b/src/mods/Schemas.lua index fb24ab50..f692c4e2 100644 --- a/src/mods/Schemas.lua +++ b/src/mods/Schemas.lua @@ -40,6 +40,19 @@ function f.int(min, max) end } end +-- a bounded float; like f.int but keeps the fractional part (scales, +-- gains). Rejects out-of-range with the "expected number a..b" message. +function f.numRange(min, max) + local desc = "number" + if min and max then desc = ("number %s..%s"):format(min, max) + elseif min then desc = ("number >= %s"):format(min) end + return { kind = "num", min = min, max = max, desc = desc, + check = function(v) + return type(v) == "number" + and (min == nil or v >= min) and (max == nil or v <= max) + end } +end + function f.enum(values) local set = {} for _, value in ipairs(values) do set[value] = true end @@ -420,6 +433,12 @@ R.pokemon = { frames = f.opt(f.int(1)) } }), cry = f.opt(f.id("cries")), palette = f.opt(f.id("palettes")), trueColor = f.opt(f.bool), + -- battle-pic scale overrides for this species' own pics: front is the + -- enemy pic (default 1x), back is the player pic (default 2x). An + -- image-level battle_sprite_scales entry for the same path beats these. + -- The pic stays grounded (feet pinned) at any scale; see docs/modding.md. + battleScaleFront = f.opt(f.numRange(0.25, 4.0)), + battleScaleBack = f.opt(f.numRange(0.25, 4.0)), }, example = 'mod.content.pokemon:patch("MEW", { baseStats = { attack = 120 } })', } @@ -791,6 +810,118 @@ R.transitions = { example = 'mod.content.transitions:register("dissolve", { frames = 30, draw = fn })', } +-- ------- rendering pipelines +-- +-- A pipeline is a display mode that owns part of the frame: it may replace +-- the overworld's world pass with geometry of its own (drawWorld) and/or +-- post-process the finished composite (present). Everything around that -- +-- the OFF/1/2/3 ladder, its options row, its hotkey, persistence in +-- save.options.pipelines and the gating that keeps it out of battles and +-- menus -- is engine plumbing driven from this record, so a renderer mod +-- declares what it is and writes only the two draw functions. +-- +-- Both callbacks are optional and independent: a present-only pipeline is a +-- post-process (bloom, tilt-shift, a CRT curve) that leaves whatever +-- rendered the frame alone, and a drawWorld-only pipeline is a world +-- renderer that composites straight. See src/render/Pipelines.lua for the +-- ctx each receives and docs/modding.md for the worked example. +R.render_pipelines = { + semantics = "record", target = "render_pipelines", + fields = { + -- shown in the options menu; the ladder labels default to OFF/ON + label = f.str, + levels = f.opt(f.list(f.str)), + -- keyboard key that cycles the ladder, checked after the engine's own + -- display hotkeys so a pipeline can never shadow one + hotkey = f.opt(f.str), + -- higher wins when two world pipelines are somehow active at once; + -- also the options-row order, so a mode and its post-process sort + -- together instead of by id + priority = f.opt(f.num), + -- hardware/driver gate, checked every frame: false keeps the vanilla + -- 2D path, which is what a headless run and a driver with no depth + -- canvas both get + available = f.opt(f.fn), + -- (top, overworld) -> boolean: whether the player may CHANGE the mode + -- right now. Defaults to the survey-zoom gate (free-roam overworld + -- only), which keeps a hotkey press from switching modes mid-warp or + -- mid-cutscene. It has no say over whether an already-on mode draws: + -- a mode that stopped rendering during a warp would flash the flat 2D + -- world every time the player walked through a door. + gate = f.opt(f.fn), + -- (dt, level): presentational tweens, ticked on real frame time + update = f.opt(f.fn), + -- (ctx) -> canvas | nil: render the world. nil falls back to the + -- vanilla flat/tilt draw for this frame. + drawWorld = f.opt(f.fn), + -- (canvas, ctx) -> canvas: post-process the WORLD image, before the UI + -- composites over it -- a depth-of-field or colour grade that must not + -- touch the dialog boxes and menus sitting on top. Only runs when some + -- pipeline rendered the world, since the vanilla world pass has no + -- single finished image to hand over. + worldPresent = f.opt(f.fn), + -- (canvas, ctx) -> canvas: post-process the whole finished composite, + -- world and UI alike (a CRT curve, a full-screen grade). Must return a + -- canvas; the input unchanged is the correct answer when the effect is + -- off. + present = f.opt(f.fn), + -- drop GPU objects (window resize, hot reload, mode switch) + invalidate = f.opt(f.fn), + }, + -- a pipeline that does neither half is dead weight and would silently + -- occupy an options row and a hotkey + extra = function(_, value) + if value.drawWorld == nil and value.present == nil + and value.worldPresent == nil then + return "a render pipeline needs drawWorld, worldPresent or present" + end + end, + -- A pipeline callback fails at play time, long after the load phase has + -- handed its report to the mod manager, so the merge leaves behind who + -- wrote each record for Pipelines to name in the failure -- the same + -- provenance trick the audio registries use (Loader.stampAudioOwners). + -- Placement is otherwise the default record merge. + write = function(target, registry) + local owners, tombstones = {}, {} + for id in pairs(registry.ops) do + local value = registry:get(id) + if value == nil then + tombstones[#tombstones + 1] = id + else + target[id] = value + local owner = registry.owners[id] + if owner and owner ~= Schemas.ENGINE then owners[id] = owner end + end + end + for _, id in ipairs(tombstones) do target[id] = nil end + target._owners = owners + end, + example = 'mod.content.render_pipelines:register("voxel", ' .. + '{ label = "VOXEL", levels = { "OFF", "15", "35", "50" }, drawWorld = fn })', +} + +-- ------- battle sprite scales +-- +-- Per-image battle-pic scale overrides, keyed by record id and consulted +-- by asset path at draw time. Where a species' battleScaleFront / +-- battleScaleBack scales its own front/back pic, this scales ANY battle +-- pic by the path it is drawn from -- the only handle on the non-species +-- pics like the player's trainer back sprite. Image-level beats +-- species-level; both compose with the send-out grow and keep the sprite +-- grounded (feet pinned) at whatever scale. See docs/modding.md. +R.battle_sprite_scales = { + semantics = "record", target = "battle_sprite_scales", + fields = { + -- the asset path exactly as data references it, e.g. + -- "assets/generated/battle/back/abrab.png" + path = f.path, + -- 1 = native pixels; the drawn size relative to the pic's own pixels + scale = f.numRange(0.25, 4.0), + }, + example = 'mod.content.battle_sprite_scales:register("abra_back", ' .. + '{ path = "assets/generated/battle/back/abrab.png", scale = 1.5 })', +} + -- ------- progression R.evolution_methods = { diff --git a/src/pokemon/Pokemon.lua b/src/pokemon/Pokemon.lua index 23017407..8330ed8b 100644 --- a/src/pokemon/Pokemon.lua +++ b/src/pokemon/Pokemon.lua @@ -29,6 +29,34 @@ function Pokemon.movesAtLevel(speciesDef, level) return moves end +-- Day Care retrieve (pokered WriteMonMoves + wLearningMovesFromDayCare): +-- grant learnset moves with startLevel < moveLevel <= newLevel, shifting +-- the oldest slot out when full. Silent — no LearnMove prompts. +function Pokemon.learnMovesFromDayCare(data, mon, speciesDef, startLevel, newLevel) + if not (speciesDef and speciesDef.learnset and mon) then return end + mon.moves = mon.moves or {} + for _, entry in ipairs(speciesDef.learnset) do + local moveLevel = entry.level + if moveLevel > newLevel then break end + if moveLevel > startLevel then + local known = false + for _, mv in ipairs(mon.moves) do + if mv.id == entry.move then known = true break end + end + if not known then + local mdef = data.moves[entry.move] + local slot = { id = entry.move, pp = mdef and mdef.pp or 0 } + if #mon.moves < 4 then + mon.moves[#mon.moves + 1] = slot + else + table.remove(mon.moves, 1) + mon.moves[#mon.moves + 1] = slot + end + end + end + end +end + function Pokemon.new(data, species, level, rng) local def = data.pokemon[species] assert(def, "unknown species " .. tostring(species)) diff --git a/src/render/Pipelines.lua b/src/render/Pipelines.lua new file mode 100644 index 00000000..1aa579ab --- /dev/null +++ b/src/render/Pipelines.lua @@ -0,0 +1,423 @@ +-- Rendering pipelines: the engine side of the render_pipelines registry. +-- +-- A pipeline is a display mode a mod owns. It may replace the overworld's +-- world pass with geometry of its own (drawWorld) and/or post-process the +-- finished composite (present). Everything else about being a display mode +-- -- the OFF/1/2/3 ladder, the options row, the hotkey, persistence, the +-- free-roam gate, and never letting a mod's error take the frame down -- is +-- engine plumbing and lives here, so a renderer mod writes the two draw +-- functions and declares the rest. +-- +-- The two halves compose independently and in priority order: the highest +-- priority eligible drawWorld renders the world, then every eligible +-- present folds over whatever came out (the world pipeline's canvas, or the +-- vanilla flat/tilt composite when none ran). A present that is switched +-- off returns its input, so a full ladder of them costs nothing at level 0. +-- +-- Nothing here reaches collision, movement, triggers or scripts: like +-- survey zoom and tilt, a pipeline is purely presentational, which is why +-- its level rides in save.options rather than the save proper. +-- +-- Spec: docs/modding.md (rendering pipelines) + +local Data = require("src.core.Data") +local Logger = require("src.core.Logger") +local Runtime = require("src.mods.Runtime") +local Zoom = require("src.render.Zoom") + +local Pipelines = {} + +-- id -> level. Levels live here rather than on the records because the +-- records are merged content: frozen after load, and shared with whatever +-- else reads Data. +local levels = {} + +-- ids whose callbacks have already thrown, so a pipeline that fails every +-- frame reports once instead of filling the log at 60Hz +local broken = {} + +Pipelines.DEFAULT_LEVELS = { "OFF", "ON" } + +-- The merged dataset the records live in. The boot singleton is the +-- default -- Game.data is that very table -- and install() lets a headless +-- caller (the SDK harness, a tool) point this at a dataset of its own. +local source = Data + +function Pipelines.install(data) + source = data or Data + Pipelines.reset() +end + +-- ------- catalog + +-- Every registered pipeline as { id = ..., def = ... }, ordered by priority +-- (descending, ties by id) so selection, the options rows and the present +-- fold all walk the same sequence. +-- +-- Memoized on the namespace table's identity. Content freezes at the merge +-- boundary, so the answer cannot change for a given table -- and this is +-- read several times per frame by update(), worldPipeline() and the +-- endFrame present check, which is no place to allocate and sort. An empty +-- list is cached too, so a mod-free boot pays one table for the process. +local listCache, listSource = nil, nil + +function Pipelines.list() + local defs = source and source.render_pipelines + if defs == listSource and listCache then return listCache end + local out = {} + if type(defs) == "table" then + for id, def in pairs(defs) do + -- the merge writes provenance under _owners; skip the bookkeeping + -- keys rather than treating them as pipelines + if type(id) == "string" and id:sub(1, 1) ~= "_" and type(def) == "table" then + out[#out + 1] = { id = id, def = def } + end + end + table.sort(out, function(a, b) + local pa, pb = a.def.priority or 0, b.def.priority or 0 + if pa ~= pb then return pa > pb end + return a.id < b.id + end) + end + listCache, listSource = out, defs + return out +end + +function Pipelines.get(id) + local defs = source and source.render_pipelines + local def = type(defs) == "table" and defs[id] or nil + return type(def) == "table" and def or nil +end + +-- the mod that registered a pipeline, so a runtime failure lands in the +-- feed the mod manager shows instead of only in the console +local function ownerOf(id) + local defs = source and source.render_pipelines + local owners = type(defs) == "table" and defs._owners or nil + return owners and owners[id] or nil +end + +-- Run one of a pipeline's callbacks under pcall. A mod that throws mid- +-- frame must not take the frame with it: the pipeline is marked broken, +-- attributed once, and treated as absent from then on -- which degrades to +-- the vanilla 2D path rather than a black screen. +local function guard(id, fn, ...) + if broken[id] then return nil end + local ok, result = pcall(fn, ...) + if ok then return result end + broken[id] = true + Logger.error("render pipeline %s failed: %s -- disabled for this session", + id, tostring(result)) + Runtime.reportError(ownerOf(id), "render pipeline failed: " .. tostring(result)) + return nil +end + +-- Whether a callback's return is a real Canvas we can composite. A mod that +-- forgets a return, or hands back a shade string / flag / number, must be +-- ignored rather than trusted -- draw() on a non-canvas takes the frame down. +-- Real LOVE canvases are userdata answering typeOf("Canvas"); the headless +-- test stub (tests/love_stub) fakes them as tables carrying the Canvas method +-- shape (love.graphics.newCanvas), so accept either and nothing else. +local function isCanvas(v) + if type(v) == "userdata" then + return type(v.typeOf) == "function" and v:typeOf("Canvas") == true + end + if type(v) == "table" then + return type(v.getWidth) == "function" and type(v.getHeight) == "function" + end + return false +end + +-- Dispatch a mod render callback with its GPU state fenced off: push("all") +-- before and pop() after, so a callback that returns cleanly but leaves a +-- shader bound, the canvas redirected, or blend/colour changed cannot corrupt +-- the engine composite that follows. guard() catches a callback that throws; +-- this catches one that dirties state. A pipeline already retired skips the +-- push/pop entirely, so the stack stays balanced. +local function guardRender(id, fn, ...) + if broken[id] then return nil end + love.graphics.push("all") + local out = guard(id, fn, ...) + love.graphics.pop() + return out +end + +-- ------- levels + +function Pipelines.levelLabels(id) + local def = Pipelines.get(id) + local labels = def and def.levels + if type(labels) ~= "table" or labels[1] == nil then + return Pipelines.DEFAULT_LEVELS + end + return labels +end + +-- highest selectable level: one less than the label count, so a two-label +-- ladder is a plain OFF/ON toggle +function Pipelines.maxLevel(id) + return #Pipelines.levelLabels(id) - 1 +end + +function Pipelines.level(id) + return levels[id] or 0 +end + +function Pipelines.levelLabel(id, level) + local labels = Pipelines.levelLabels(id) + return labels[(level or Pipelines.level(id)) + 1] or labels[1] or "OFF" +end + +-- A world pipeline and the engine's own tilt mode are two answers to the +-- same question, so switching one on switches the other off -- the rule +-- tilt and survey zoom already follow between themselves. Present-only +-- pipelines (post-processes) compose with tilt and are left alone. +local function excludeTilt(id, level) + local def = Pipelines.get(id) + if not (def and def.drawWorld) or level <= 0 then return end + local Tilt = require("src.render.Tilt") + if Tilt.level > 0 then Tilt.setLevel(0) end + -- one world pipeline at a time, for the same reason + for _, entry in ipairs(Pipelines.list()) do + if entry.id ~= id and entry.def.drawWorld and Pipelines.level(entry.id) > 0 then + levels[entry.id] = 0 + end + end +end + +function Pipelines.setLevel(id, level) + if not Pipelines.get(id) then return 0 end + level = math.floor(tonumber(level) or 0) + if level < 0 then level = 0 end + local max = Pipelines.maxLevel(id) + if level > max then level = max end + levels[id] = level + excludeTilt(id, level) + return level +end + +-- Advance the ladder and wrap to OFF, the shape every display hotkey walks. +function Pipelines.cycle(id, dir) + local max = Pipelines.maxLevel(id) + if max < 1 then return 0 end + local span = max + 1 + local target = (Pipelines.level(id) + (dir or 1)) % span + if target < 0 then target = target + span end + return Pipelines.setLevel(id, target) +end + +-- Turning a world pipeline on must switch tilt off in the save too, not +-- just in the live module, or the next boot restores both. Call sites hand +-- over the options table so this stays the one place that rule lives. +function Pipelines.syncOptions(opts) + if type(opts) ~= "table" then return end + local bucket = opts.pipelines + if type(bucket) ~= "table" then + bucket = {} + opts.pipelines = bucket + end + for _, entry in ipairs(Pipelines.list()) do + bucket[entry.id] = Pipelines.level(entry.id) + if entry.def.drawWorld and Pipelines.level(entry.id) > 0 then + opts.tilt = 0 + end + end +end + +-- Restore levels from a loaded options table. A pipeline whose mod is gone +-- keeps its stored level untouched in the bucket (so re-enabling the mod +-- restores the mode) but contributes nothing while absent. +function Pipelines.applyOptions(opts) + local bucket = type(opts) == "table" and opts.pipelines or nil + levels = {} + broken = {} + local world = nil + for _, entry in ipairs(Pipelines.list()) do + local stored = type(bucket) == "table" and bucket[entry.id] or 0 + local level = math.floor(tonumber(stored) or 0) + if level < 0 then level = 0 end + local max = Pipelines.maxLevel(entry.id) + if level > max then level = max end + -- list() is priority order, so the first world pipeline with a stored + -- level is the one that wins; the rest restore to OFF rather than + -- sitting on a level that can never render + if entry.def.drawWorld and level > 0 then + if world then level = 0 else world = entry.id end + end + levels[entry.id] = level + end + -- a restored world pipeline and tilt are two answers to the same + -- question; the pipeline wins, as it does at every place that sets one + if world then require("src.render.Tilt").setLevel(0) end +end + +function Pipelines.reset() + levels = {} + broken = {} +end + +-- ------- per-frame + +-- Presentational tweens run on real frame time, like Tilt's. Every +-- pipeline ticks, not just the active ones: a mode easing back OUT still +-- has an angle to retire. +function Pipelines.update(dt) + for _, entry in ipairs(Pipelines.list()) do + if entry.def.update then + guardRender(entry.id, entry.def.update, dt, Pipelines.level(entry.id)) + end + end +end + +-- A pipeline may run this frame when it is switched on, has not thrown, and +-- its hardware gate says yes. `available` is consulted every frame rather +-- than cached: a driver that loses its depth canvas on a resize has to be +-- able to change its mind. +-- +-- Deliberately NOT gated on the state stack. `gate` governs whether the +-- player may CHANGE the mode, never whether it draws -- a display mode that +-- stopped rendering during a warp, a scripted cutscene or an open menu +-- would flash the flat 2D world for those frames every time the player +-- walked through a door. Once a mode is on it renders until it is off. +function Pipelines.eligible(id) + local def = Pipelines.get(id) + if not def or broken[id] then return false end + if Pipelines.level(id) <= 0 then return false end + if def.available and guard(id, def.available) ~= true then return false end + return true +end + +-- Whether the player may cycle this mode right now: the free-roam gate, +-- which keeps a hotkey press from switching modes mid-warp or mid-cutscene. +-- Input only -- see eligible() for why the draw path does not consult it. +function Pipelines.canToggle(id, top, overworld) + local def = Pipelines.get(id) + if not def then return false end + local gate = def.gate or Zoom.gateOK + return guard(id, gate, top, overworld) == true +end + +-- The pipeline that owns the world pass right now, or nil for the vanilla +-- flat/tilt draw. Highest priority wins; the exclusion rules above mean +-- there is normally only one candidate anyway. +function Pipelines.worldPipeline() + for _, entry in ipairs(Pipelines.list()) do + if entry.def.drawWorld and Pipelines.eligible(entry.id) then + return entry.id, entry.def + end + end + return nil +end + +-- Render the world through `id`. Returns the canvas to composite, or nil +-- when the pipeline declined this frame (nothing to draw, a transient +-- failure), which the caller treats as "fall back to the 2D path". +function Pipelines.drawWorld(id, ctx) + local def = Pipelines.get(id) + if not (def and def.drawWorld) then return nil end + return guardRender(id, def.drawWorld, ctx) +end + +-- Fold every eligible world post-process over a pipeline's world image, +-- before the UI composites on top. This is where a depth-of-field or a +-- colour grade belongs when it must leave the dialog boxes and menus crisp; +-- `present` below is the whole-frame counterpart. Only reachable once some +-- pipeline rendered the world, so it is gated on the overworld state the +-- same way drawWorld is. +function Pipelines.worldPresent(canvas, ctx) + if canvas == nil then return nil end + for _, entry in ipairs(Pipelines.list()) do + if entry.def.worldPresent and Pipelines.eligible(entry.id) then + local out = guardRender(entry.id, entry.def.worldPresent, canvas, ctx) + -- accept only a real Canvas: a pass that returns a non-canvas (a + -- forgotten return, a shade string) is ignored, not folded in + if isCanvas(out) then canvas = out end + end + end + return canvas +end + +-- Fold every eligible post-process over the finished frame. Present +-- pipelines are not gated on the overworld state -- a CRT curve or a colour +-- grade applies to menus and battles too -- so eligibility here is just +-- "switched on and available". A pass that returns a non-canvas is +-- ignored rather than trusted, so a mod cannot blank the screen by +-- forgetting a return. +function Pipelines.present(canvas, ctx) + if canvas == nil then return nil end + for _, entry in ipairs(Pipelines.list()) do + if entry.def.present and Pipelines.eligible(entry.id) then + local out = guardRender(entry.id, entry.def.present, canvas, ctx) + -- accept only a real Canvas: a pass that returns a non-canvas is + -- ignored (docstring above), so a mod cannot blank or crash the frame + -- by forgetting a return or handing back a truthy non-canvas + if isCanvas(out) then canvas = out end + end + end + return canvas +end + +-- true when any present-only pass wants to run, so the composite path can +-- skip allocating a target it would not use +function Pipelines.wantsPresent() + for _, entry in ipairs(Pipelines.list()) do + if entry.def.present and Pipelines.eligible(entry.id) then return true end + end + return false +end + +-- ------- input and UI + +-- Cycle whichever pipeline claims `key`. Returns the id when one did, so +-- the caller knows the key was consumed. Checked after the engine's own +-- display hotkeys, so a mod can never shadow one. +function Pipelines.hotkey(key, top, overworld) + for _, entry in ipairs(Pipelines.list()) do + if entry.def.hotkey == key then + -- the gate belongs here and nowhere else: it stops the player + -- flipping modes mid-warp or mid-cutscene, and has no say over + -- whether an already-on mode draws + if Pipelines.canToggle(entry.id, top, overworld) then + Pipelines.cycle(entry.id) + return entry.id + end + return nil + end + end + return nil +end + +-- Options rows for every registered pipeline, in the same priority order, +-- in the descriptor shape src/ui/OptionRows.lua renders. +function Pipelines.rows(game) + local rows = {} + for _, entry in ipairs(Pipelines.list()) do + local id = entry.id + rows[#rows + 1] = { + id = "pipeline:" .. id, + label = entry.def.label or id:upper(), + value = function() return Pipelines.levelLabel(id) end, + step = function(g, dir) + Pipelines.cycle(id, dir) + local opts = g and g.save and g.save.options + if opts then + Pipelines.syncOptions(opts) + -- the exclusion above may have switched tilt off; keep the live + -- module in step with the option it just wrote + require("src.render.Tilt").setLevel(opts.tilt or 0) + end + return true + end, + } + end + return rows +end + +-- Drop every pipeline's GPU objects (window resize, hot reload). +function Pipelines.invalidate() + for _, entry in ipairs(Pipelines.list()) do + if entry.def.invalidate then guardRender(entry.id, entry.def.invalidate) end + end +end + +return Pipelines diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index 9ff968e8..2b9b6dd0 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -9,12 +9,28 @@ local Zoom = require("src.render.Zoom") local Tilt = require("src.render.Tilt") local PaletteFX = require("src.render.PaletteFX") +local Pipelines = require("src.render.Pipelines") local Renderer = {} Renderer.WIDTH = 160 Renderer.HEIGHT = 144 +-- Whether a value is a real Canvas we can composite. Real LOVE canvases are +-- userdata answering typeOf("Canvas"); the headless test stub fakes them as +-- tables carrying the Canvas method shape. A mod pipeline handing back a +-- non-canvas must be rejected before it reaches love.graphics.draw, which +-- would otherwise take the frame down with it. +local function isCanvas(v) + if type(v) == "userdata" then + return type(v.typeOf) == "function" and v:typeOf("Canvas") == true + end + if type(v) == "table" then + return type(v.getWidth) == "function" and type(v.getHeight) == "function" + end + return false +end + -- Tilt mode: the upright billboard canvas is grown by this many world -- pixels on every side beyond the ground world view, so a structure or -- sprite standing near a view edge still draws in full instead of being @@ -57,6 +73,22 @@ function Renderer:init() -- the projected ground in endFrame; never touched while tilt is off. self.uprightCanvas = nil self.uprightActive = false + -- a render pipeline's finished world image, already at window resolution + -- (see src/render/Pipelines.lua). nil is "no pipeline rendered this + -- frame", which is every vanilla frame. + self.worldOverride = nil +end + +-- Hand endFrame a pipeline's world image to composite instead of the world +-- canvas. Cleared every frame, so a pipeline that declines one frame falls +-- straight back to the 2D path rather than showing a stale image. +function Renderer:setWorldOverride(canvas) + -- Defensive: a pipeline that hands back a non-canvas (forgotten return, a + -- truthy sentinel) must not reach the worldOverride blit in endFrame, where + -- love.graphics.draw on it would crash the frame. Reject it and fall back + -- to the 2D path rather than trust it. + if canvas ~= nil and not isCanvas(canvas) then canvas = nil end + self.worldOverride = canvas end -- Integer framebuffer pixels per GB pixel that fit the window. Zoom / @@ -91,6 +123,7 @@ end function Renderer:beginFrame(transparent) self.worldActive = false self.uprightActive = false + self.worldOverride = nil -- warp-fade overlay from Transition (issue #121); cleared each frame so -- a popped transition cannot leave a sticky black veil self.worldFadeAlpha = nil @@ -380,7 +413,11 @@ function Renderer:endFrame(zones, worldZones) zones = withTrueColor(zones, "ui") worldZones = withTrueColor(worldZones, "world") - local needPresent = GBCFX.active() + -- A post-process pipeline needs the whole composite in a canvas for the + -- same reason GBC FX does, so either one alone is enough to take the + -- present path; with neither, the frame draws straight to the screen + -- exactly as it always did. + local needPresent = GBCFX.active() or Pipelines.wantsPresent() local present = nil if needPresent then if not self.presentCanvas or self.presentCanvas:getWidth() ~= ww @@ -441,7 +478,27 @@ function Renderer:endFrame(zones, worldZones) love.graphics.setShader() end - if self.worldActive then + if self.worldOverride then + -- A render pipeline already produced the whole world -- terrain, + -- characters and its own FX overlay -- as one window-resolution image, + -- so it composites with a straight 1:1 blit and the world canvas is + -- 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) + love.graphics.setScissor(0, 0, ww, wh) + love.graphics.draw(self.worldOverride, 0, 0, 0, 1 / dpi, 1 / dpi) + love.graphics.setScissor() + -- the screen-space overlays the flat path draws over its composite + local fade = self.worldFadeAlpha + if fade and fade > 0 then + love.graphics.setColor(0, 0, 0, fade) + love.graphics.rectangle("fill", 0, 0, ww, wh) + love.graphics.setColor(1, 1, 1, 1) + end + if self.battleCascadeProg then + self:drawBattleCascade(self.battleCascadeProg, ww, wh, ox, oy, vpw, vph, S) + end + elseif self.worldActive then local sp = Zoom.scale(Sp) local s = sp / dpi local wvw = self.worldCanvas:getWidth() @@ -526,11 +583,26 @@ function Renderer:endFrame(zones, worldZones) if present then love.graphics.setCanvas() - -- shader grid/shadow math is in framebuffer pixels - GBCFX.present(present, Sp) + -- 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 + -- this returns `present` unchanged and the frame is byte-identical. + local composed = Pipelines.present(present, + { width = ww, height = wh, scale = Sp, dpi = dpi }) or present + if GBCFX.active() then + -- shader grid/shadow math is in framebuffer pixels + GBCFX.present(composed, Sp) + 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 + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(composed, 0, 0) + end end self.worldActive = false self.uprightActive = false + self.worldOverride = nil PaletteFX.setPass(nil) end diff --git a/src/render/SpriteRenderer.lua b/src/render/SpriteRenderer.lua index 3b94a728..09f43bfb 100644 --- a/src/render/SpriteRenderer.lua +++ b/src/render/SpriteRenderer.lua @@ -65,8 +65,12 @@ end Assets.register(SpriteRenderer.invalidate) +-- exported: a render pipeline's own sprite geometry picks frames by the +-- same tables, so a 3D pose can never drift from the 2D one local STAND = { down = 0, up = 1, left = 2, right = 2 } local WALK = { down = 3, up = 4, left = 5, right = 5 } +SpriteRenderer.STAND = STAND +SpriteRenderer.WALK = WALK -- seed: any stable per-instance value (e.g. an NPC's `id`) used to resolve -- RED++'s per-instance "random" OBP sentinel (PaletteFX.spriteObp) @@ -83,6 +87,29 @@ function SpriteRenderer.new(spriteDef, seed) return self end +-- The image this sprite would draw from right now: the plain sheet, or the +-- OBP-recolored bake of it. Exposed so a render pipeline can texture its +-- own geometry from the very same image -- the geometry carries sheet pixel +-- coordinates rather than baked colors, so sharing this one resolver is +-- what makes palette modes and sprite-replacing mods apply to 2D and 3D +-- alike. +-- +-- Deliberately free of draw's bookkeeping: markTrueColor and +-- markSpriteRedraw exist to patch up the screen-space zone shader, and a +-- pipeline that renders into its own canvas never runs through it. For the +-- same reason the OG-RED bake is returned unconditionally here rather than +-- only during a redraw pass -- there is no later pass to restore it. +function SpriteRenderer:resolveImage() + if self.def.trueColor then return self.image end + if PaletteFX.usesGbcPack() then + local colors, group = PaletteFX.spriteObp(self.def, self.seed) + if colors then return getObpImage(self.def.image, colors, group) end + elseif PaletteFX.usesSpriteObp() then + return getObpImage(self.def.image, PaletteFX.GBC_OBJ, "gbcobj") + end + return self.image +end + -- facing: down/up/left/right; walkPhase: 0 stand, 1 walk; flip: alternate -- steps mirror the walk frame for up/down (GB uses OAM flip for this). local function blitFrame(image, quad, x, y, flip, redraw) diff --git a/src/render/TextBox.lua b/src/render/TextBox.lua index eeabd45b..08f80f84 100644 --- a/src/render/TextBox.lua +++ b/src/render/TextBox.lua @@ -50,6 +50,7 @@ function TextBox.new(game, text, onDone, opts) self.charIndex = 0 self.shown = {} -- visible lines (max 2), each a list of glyph codes self.waiting = false + self.contAdvance = false self.done = false self.blink = 0 self:beginLine() @@ -65,7 +66,11 @@ TextBox.TOKENS = { PLAYER = function(game) return game.save.player.name or "RED" end, RIVAL = function(game) return game.save.player.rival or "BLUE" end, RAM = function(game, arg) - return arg == "wStringBuffer" and game.stringBuffer or nil + if arg == "wStringBuffer" then return game.stringBuffer end + if arg == "wBoxNumString" then return game.boxNumString end + -- SendNewMonToBox / _SentToBoxText reads the deposited nick here + if arg == "wBoxMonNicks" then return game.boxMonNicks end + return nil end, } @@ -83,31 +88,55 @@ end -- Split marked-up text into pages of lines. \v-scrolled lines become -- additional lines on the same page (the box scrolls them). +-- pages.contBefore[p][i] is true when line i was preceded by \v (cont): +-- pokered ContText waits for A/B + ▼ before scrolling that line in. function TextBox.paginate(text, maxCols) maxCols = maxCols or (Theme.textBox and Theme.textBox.maxCols) or MAX_COLS local pages = {} + local contBefore = {} + local function pushLine(lines, conts, line, wait) + while #line > maxCols do + local cut = maxCols + for i = maxCols, 1, -1 do + if line:sub(i, i) == " " then cut = i break end + end + table.insert(lines, line:sub(1, cut)) + table.insert(conts, wait) + wait = false + line = line:sub(cut + 1) + end + table.insert(lines, line) + table.insert(conts, wait) + end for pageText in (text .. "\f"):gmatch("(.-)\f") do if pageText ~= "" then - local lines = {} - for chunk in (pageText .. "\n"):gmatch("(.-)[\n\v]") do - local line = chunk - -- wrap long lines defensively (the source rarely needs it) - while #line > maxCols do - local cut = maxCols - for i = maxCols, 1, -1 do - if line:sub(i, i) == " " then cut = i break end - end - table.insert(lines, line:sub(1, cut)) - line = line:sub(cut + 1) + local lines, conts = {}, {} + local pos, waitNext = 1, false + while true do + local npos = pageText:find("[\n\v]", pos) + if not npos then + pushLine(lines, conts, pageText:sub(pos), waitNext) + break end - table.insert(lines, line) + pushLine(lines, conts, pageText:sub(pos, npos - 1), waitNext) + waitNext = pageText:sub(npos, npos) == "\v" + pos = npos + 1 + end + if lines[#lines] == "" then + table.remove(lines) + table.remove(conts) + end + if #lines > 0 then + table.insert(pages, lines) + table.insert(contBefore, conts) end - -- drop trailing empty line from the final gmatch round - if lines[#lines] == "" then table.remove(lines) end - if #lines > 0 then table.insert(pages, lines) end end end - if #pages == 0 then pages = { { "" } } end + if #pages == 0 then + pages = { { "" } } + contBefore = { { false } } + end + pages.contBefore = contBefore return pages end @@ -179,10 +208,17 @@ function TextBox:update(dt) if input:wasPressed("a") or input:wasPressed("b") then require("src.core.Sound").play(self.game.data, "Press_AB") self.waiting = false - self.shown = {} - self.pageIndex = self.pageIndex + 1 - self.lineIndex = 1 - self:beginLine() + if self.contAdvance then + -- ContText / ManualTextScroll: keep the box, scroll one line + self.contAdvance = false + self.lineIndex = self.lineIndex + 1 + self:beginLine() + else + self.shown = {} + self.pageIndex = self.pageIndex + 1 + self.lineIndex = 1 + self:beginLine() + end end return end @@ -203,10 +239,19 @@ function TextBox:update(dt) -- line finished local page = self.pages[self.pageIndex] if self.lineIndex < #page then - self.lineIndex = self.lineIndex + 1 - self:beginLine() + local nextIdx = self.lineIndex + 1 + local conts = self.pages.contBefore and self.pages.contBefore[self.pageIndex] + if conts and conts[nextIdx] then + -- pokered : ▼ + WaitForTextScrollButtonPress before scroll + self.waiting = true + self.contAdvance = true + else + self.lineIndex = nextIdx + self:beginLine() + end elseif self.pageIndex < #self.pages then self.waiting = true + self.contAdvance = false else self.done = true end diff --git a/src/render/TileRenderer.lua b/src/render/TileRenderer.lua index 54534cb9..232af1f0 100644 --- a/src/render/TileRenderer.lua +++ b/src/render/TileRenderer.lua @@ -179,6 +179,11 @@ local function recolorSample(r, g, b, a, colors) return col[1] / 255, col[2] / 255, col[3] / 255, a end +-- exported: a render pipeline bakes a map's palette into its own texture +-- atlas the same way, and has to land on the identical colors as the 2D +-- tiles it is standing in for +TileRenderer.recolorSample = recolorSample + -- the 8 shifted variants of one tile (built once per sheet + tile id [+ -- gbcKey, when `colors` recolors it for RED++ -- see buildAnim]) local shiftVariants = {} diff --git a/src/save_convert/GenSave.lua b/src/save_convert/GenSave.lua new file mode 100644 index 00000000..7387a840 --- /dev/null +++ b/src/save_convert/GenSave.lua @@ -0,0 +1,813 @@ +-- Vanilla Gen1 (Red/Blue, international) raw SRAM save (32768 bytes) <-> +-- this project's save.lua shape (src/core/SaveData.lua / SaveData.newGame). +-- +-- Pure Lua, no love.* dependency -- runs under plain luajit for the CLI +-- (tools/save_convert/convert.lua) and headless tests alike. +-- +-- Every offset below was derived mechanically from the authoritative +-- source (../pokered/ram/wram.asm, ram/sram.asm, macros/ram.asm) and +-- cross-checked against three independently well-known Gen1 save +-- addresses: money @ 0x25F3, badges @ 0x2602, party data @ 0x2F2C -- +-- all three fall out exactly right from the single sPlayerName anchor +-- below, strong triangulated confirmation the whole chain (SRAM bank 1 +-- layout, wMainData field order, party_struct/box_struct sizes) is right. +-- +-- SRAM layout (32768 bytes = 4 banks x 8192): bank 0 is sprite buffers + +-- Hall of Fame (not modeled -- see "explicitly out of scope" in the +-- save-converter plan); bank 1 is "Save Data" (sPlayerName through +-- sMainDataCheckSum); banks 2/3 are the 12 PC boxes (6 each) + checksums. +-- +-- Fields with no equivalent in save.lua (current sprite/animation state, +-- connection-header cache, Day Care, Safari Zone, HOF roster) are +-- intentionally not modeled: on export, encode() starts from the +-- ORIGINAL imported bytes as a template when available (GenSave.decode +-- stashes them) so that scratch state round-trips untouched instead of +-- being invented; with no template (a save that originated in this +-- project) those bytes stay zero-filled, which is safe because the real +-- game regenerates all of it from wCurMap on the next map load anyway. + +local bit = require("bit") + +local GenSave = {} + +-- ------------------------------------------------------------------ +-- Absolute byte offsets (0-based, matching a raw 32768-byte .sav file) +-- ------------------------------------------------------------------ + +local NAME_LENGTH = 11 +local PARTY_LENGTH = 6 +local MONS_PER_BOX = 20 +local NUM_BADGES = 8 +local BOX_STRUCT_SIZE = 33 -- Species,HP,Level,Status,Type1,Type2,CatchRate, + -- Moves x4,OTID,Exp x3,HPExp,AtkExp,DefExp, + -- SpdExp,SpcExp,DVs,PP x4 (macros/ram.asm box_struct) +local PARTY_STRUCT_SIZE = 44 -- box_struct + Level + Stats x5 (party_struct) +local BOX_REGION_SIZE = 1 + (MONS_PER_BOX + 1) + MONS_PER_BOX * BOX_STRUCT_SIZE + + MONS_PER_BOX * NAME_LENGTH + MONS_PER_BOX * NAME_LENGTH -- 1122 + +local O = {} +O.playerName = 9624 -- sPlayerName (11B) = 0x2598 +O.mainData = O.playerName + NAME_LENGTH -- sMainData (wMainDataStart mirror) +O.pokedexOwned = O.mainData + 0 -- 19B (flag_array 151) +O.pokedexSeen = O.mainData + 19 -- 19B +O.numBagItems = O.mainData + 38 -- 1B +O.bagItems = O.mainData + 39 -- 41B (20 x (id,qty) + $FF term) +O.money = O.mainData + 80 -- 3B BCD = 0x25F3 +O.rivalName = O.mainData + 83 -- 11B +O.options = O.mainData + 94 -- 1B +O.badges = O.mainData + 95 -- 1B = 0x2602 +O.playerId = O.mainData + 98 -- 2B (big-endian) +O.curMap = O.mainData + 103 -- 1B +O.yCoord = O.mainData + 106 -- 1B +O.xCoord = O.mainData + 107 -- 1B +O.lastMap = O.mainData + 110 -- 1B +O.numPcItems = O.mainData + 579 -- 1B +O.pcItems = O.mainData + 580 -- 101B (50 x (id,qty) + $FF term) +O.currentBoxNum = O.mainData + 681 -- 1B (bits 0-6: box 0-11, bit 7: unused here) +O.coins = O.mainData + 685 -- 2B BCD +O.eventFlags = O.mainData + 1104 -- 320B (flag_array NUM_EVENTS = 2560 bits) +-- Play time (wPlayTimeHours/Maxed/Minutes/Seconds/Frames) lives INSIDE the +-- sMainData window (wMainDataStart..wMainDataEnd is copied verbatim into +-- SRAM), 1866 bytes past wMainDataStart -- reached from the checksum-verified +-- wEventFlags anchor: 320 (event flag_array) + 293 (the wGrassRate/enemy-party +-- battle UNION) + 66 + 66 (wEnemyMonOT/Nicks, 6 x NAME_LENGTH each; the +-- rgbds FOR n,1,PARTY_LENGTH+1 loop is end-exclusive => 6 mons, not 7) + 2 +-- (wTrainerHeaderPtr) + 6 (ds) + 1 (wOpponentAfterWrongAnswer) + 1 +-- (wCurMapScript) + 7 (ds) = 762. Confirmed on the real fixture: those five +-- bytes read 201h 30m 07s, a sane completed-save clock. +O.playTimeHours = O.mainData + 1866 -- 1B +O.playTimeMaxed = O.mainData + 1867 -- 1B (set once past 255h) +O.playTimeMinutes = O.mainData + 1868 -- 1B (0-59) +O.playTimeSeconds = O.mainData + 1869 -- 1B (0-59) +O.playTimeFrames = O.mainData + 1870 -- 1B (0-59, 1/60s ticks) +O.mainDataSize = 1929 -- wMainDataEnd - wMainDataStart + +O.spriteData = O.mainData + O.mainDataSize +O.spriteDataSize = 512 -- 2 x 16 sprites x 16B + +O.partyData = O.spriteData + O.spriteDataSize -- = 0x2F2C +O.partyCount = O.partyData +O.partySpecies = O.partyData + 1 -- 7B (PARTY_LENGTH+1) +O.partyMons = O.partyData + 8 -- 6 x 44B +O.partyMonOT = O.partyData + 8 + PARTY_LENGTH * PARTY_STRUCT_SIZE +O.partyMonNicks = O.partyMonOT + PARTY_LENGTH * NAME_LENGTH +O.partyDataSize = 1 + (PARTY_LENGTH + 1) + PARTY_LENGTH * PARTY_STRUCT_SIZE + + PARTY_LENGTH * NAME_LENGTH + PARTY_LENGTH * NAME_LENGTH -- 404 + +O.curBoxData = O.partyData + O.partyDataSize +O.boxCount = O.curBoxData +O.boxSpecies = O.curBoxData + 1 -- 21B +O.boxMons = O.curBoxData + 22 -- 20 x 33B +O.boxMonOT = O.curBoxData + 22 + MONS_PER_BOX * BOX_STRUCT_SIZE +O.boxMonNicks = O.boxMonOT + MONS_PER_BOX * NAME_LENGTH + +O.checksumStart = O.playerName +O.checksumEnd = O.curBoxData + BOX_REGION_SIZE + 1 -- + sTileAnimations (1B) +O.mainChecksum = O.checksumEnd -- 1B + +O.box1 = 16384 -- bank 2 start +O.boxBank2Checksum = O.box1 + 6 * BOX_REGION_SIZE +O.boxBank2IndividualChecksums = O.boxBank2Checksum + 1 -- 6B +O.box7 = 24576 -- bank 3 start +O.boxBank3Checksum = O.box7 + 6 * BOX_REGION_SIZE +O.boxBank3IndividualChecksums = O.boxBank3Checksum + 1 -- 6B + +GenSave.OFFSETS = O +GenSave.BOX_REGION_SIZE = BOX_REGION_SIZE +GenSave.SAVE_SIZE = 32768 + +-- ------------------------------------------------------------------ +-- Byte-level helpers. `bytes` is a 32768-byte Lua string (1-based +-- indexing, so byte offset N is string position N+1); `buf` for writing +-- is a 32768-entry array of 1-char strings, joined at the end. +-- ------------------------------------------------------------------ + +local function u8(bytes, off) return bytes:byte(off + 1) end +local function u16be(bytes, off) return u8(bytes, off) * 256 + u8(bytes, off + 1) end +local function u24be(bytes, off) + return u8(bytes, off) * 65536 + u8(bytes, off + 1) * 256 + u8(bytes, off + 2) +end + +local function setByte(buf, off, v) + buf[off + 1] = string.char(bit.band(v, 0xFF)) +end +local function setU16be(buf, off, v) + setByte(buf, off, bit.band(bit.rshift(v, 8), 0xFF)) + setByte(buf, off + 1, bit.band(v, 0xFF)) +end +local function setU24be(buf, off, v) + setByte(buf, off, bit.band(bit.rshift(v, 16), 0xFF)) + setByte(buf, off + 1, bit.band(bit.rshift(v, 8), 0xFF)) + setByte(buf, off + 2, bit.band(v, 0xFF)) +end +local function setBcd(buf, off, nbytes, v) + for i = nbytes - 1, 0, -1 do + local d = v % 100 + v = math.floor(v / 100) + setByte(buf, off + i, math.floor(d / 10) * 16 + (d % 10)) + end +end +local function readBcd(bytes, off, nbytes) + local n = 0 + for i = 0, nbytes - 1 do + local b = u8(bytes, off + i) + n = n * 100 + math.floor(b / 16) * 10 + (b % 16) + end + return n +end + +-- CalcCheckSum (engine/menus/save.asm): complement of the additive sum +local function checksum(bytes, from, to) + local sum = 0 + for i = from, to - 1 do sum = bit.band(sum + u8(bytes, i), 0xFF) end + return bit.band(bit.bnot(sum), 0xFF) +end + +-- flag_array packs LSB-first within each byte (bit 0 of byte 0 = index 0). +-- This is pokered's runtime FlagAction convention (home/predef macros): it +-- takes flag number N, addresses byte N/8, and builds the mask by rotating +-- a 1 left N%8 times starting from bit 0 -- i.e. flag N%8==0 is the LSB. +-- Same convention PKHeX uses for Gen1 dex/event flags (FlagUtil.GetFlag: +-- data[ofs + bit/8] >> (bit%8) & 1). Cross-validated against the real save: +-- ZAPDOS (dex 145) is physically boxed there, so its owned/seen flag must be +-- set; only the LSB reading returns it set (MSB-first spuriously drops +-- exactly that one bit at the byte-18 boundary), yielding a complete 151/151 +-- dex. The prior MSB-first code round-tripped self-consistently but decoded +-- every flag_array (pokedex AND event flags) to the wrong bit. +local function bitGet(bytes, base, index) + local byteOff = base + math.floor(index / 8) + local b = u8(bytes, byteOff) + return bit.band(bit.rshift(b, index % 8), 1) == 1 +end + +-- Set one bit directly in `buf` (0-based flag index into a flag_array +-- starting at `base`), preserving every other bit already in that byte -- +-- template bytes (see encode()'s header note) survive for bits this pass +-- never explicitly touches, e.g. event-flag bits with no known name +-- sharing a byte with ones that do. +local function bitSet(buf, base, index, value) + local byteOff = base + math.floor(index / 8) + local bitIdx = index % 8 + local cur = buf[byteOff + 1] and buf[byteOff + 1]:byte() or 0 + local mask = bit.lshift(1, bitIdx) + setByte(buf, byteOff, value and bit.bor(cur, mask) or bit.band(cur, bit.bnot(mask))) +end + +-- ------------------------------------------------------------------ +-- Text (fixed-length name fields: charmap-encoded, "@" ($50) terminated, +-- $50-padded after the terminator). setCharmap(cm) must be called once +-- before decode/encode (src/save_convert/data/charmap.lua's shape). +-- ------------------------------------------------------------------ + +local charmap + +function GenSave.setCharmap(cm) charmap = cm end + +local function decodeName(bytes, off, len) + local out = {} + for i = 0, len - 1 do + local b = u8(bytes, off + i) + if b == 0x50 then break end + out[#out + 1] = charmap.byByte[b] or "?" + end + return table.concat(out) +end + +local function encodeName(buf, off, len, text) + local i, pos = 0, 1 + while i < len - 1 and pos <= #text do + -- a bracketed control token (e.g. "", from decodeName reading a + -- byte with no plain-glyph mapping) is ONE game character despite + -- being several text bytes here; match it as a whole unit first, or + -- it would fall through to per-byte matching and turn into "?" x5 + local bracket = text:match("^(<[^<>]*>)", pos) + local ch, clen + if bracket and charmap.byToken[bracket] then + ch, clen = bracket, #bracket + else + local b0 = text:byte(pos) + clen = (b0 < 0x80 and 1) or (b0 < 0xE0 and 2) or (b0 < 0xF0 and 3) or 4 + ch = text:sub(pos, pos + clen - 1) + end + setByte(buf, off + i, charmap.byToken[ch] or charmap.byToken["?"] or 0x50) + i, pos = i + 1, pos + clen + end + -- Write exactly ONE $50 terminator and then STOP. The bytes after it are + -- left untouched: when encoding over a template they stay as the original + -- save's post-terminator padding (so an unchanged name round-trips + -- byte-identical), and on a templateless export they stay zero-filled. The + -- game reads a name only up to the first $50, so whatever follows is inert. + if i < len then setByte(buf, off + i, 0x50) end +end + +-- ------------------------------------------------------------------ +-- DV / PP packing (box_struct DVs, PP) +-- ------------------------------------------------------------------ + +-- DVs:: dw, packed as byte0=(Attack<<4)|Defense, byte1=(Speed<<4)|Special; +-- HP DV is derived, not stored, from each stat DV's low bit. +local function decodeDVs(bytes, off) + local b0, b1 = u8(bytes, off), u8(bytes, off + 1) + local atk, def = bit.rshift(b0, 4), bit.band(b0, 0xF) + local spe, spc = bit.rshift(b1, 4), bit.band(b1, 0xF) + local hp = bit.bor(bit.lshift(bit.band(atk, 1), 3), bit.lshift(bit.band(def, 1), 2), + bit.lshift(bit.band(spe, 1), 1), bit.band(spc, 1)) + return { hp = hp, attack = atk, defense = def, speed = spe, special = spc } +end + +local function encodeDVs(buf, off, dvs) + setByte(buf, off, bit.bor(bit.lshift(bit.band(dvs.attack or 0, 0xF), 4), bit.band(dvs.defense or 0, 0xF))) + setByte(buf, off + 1, bit.bor(bit.lshift(bit.band(dvs.speed or 0, 0xF), 4), bit.band(dvs.special or 0, 0xF))) +end + +-- PP byte: top 2 bits = PP Up count (0-3), bottom 6 bits = current PP +local function decodePPByte(b) return bit.band(b, 0x3F), bit.rshift(b, 6) end +local function encodePPByte(pp, ppUps) return bit.bor(bit.lshift(bit.band(ppUps or 0, 3), 6), bit.band(pp or 0, 0x3F)) end + +-- ------------------------------------------------------------------ +-- Crosswalks (built once from `data` = {pokemon=,moves=,items=,maps=}) +-- ------------------------------------------------------------------ + +-- pokered constants/type_constants.asm PHYSICAL/SPECIAL block; stable, +-- not worth a dedicated extractor for 15 names. +local TYPE_BY_INDEX = { + [0] = "NORMAL", [1] = "FIGHTING", [2] = "FLYING", [3] = "POISON", + [4] = "GROUND", [5] = "ROCK", [6] = "BIRD", [7] = "BUG", [8] = "GHOST", + [20] = "FIRE", [21] = "WATER", [22] = "GRASS", [23] = "ELECTRIC", + [24] = "PSYCHIC_TYPE", [25] = "ICE", [26] = "DRAGON", +} +local TYPE_INDEX = {} +for i, name in pairs(TYPE_BY_INDEX) do TYPE_INDEX[name] = i end + +-- Badge bit order (constants/ram_constants.asm BIT_BOULDERBADGE=0 .. +-- BIT_EARTHBADGE=7); this project stores badges as truthy +-- save.inventory[id] entries, not a flag or a separate bitmask +-- (src/inventory/Badges.lua Badges.list's VANILLA order matches exactly). +local BADGE_BY_BIT = { + [0] = "BOULDERBADGE", [1] = "CASCADEBADGE", [2] = "THUNDERBADGE", + [3] = "RAINBOWBADGE", [4] = "SOULBADGE", [5] = "MARSHBADGE", + [6] = "VOLCANOBADGE", [7] = "EARTHBADGE", +} +local BADGE_BY_BIT_SET = {} +for _, name in pairs(BADGE_BY_BIT) do BADGE_BY_BIT_SET[name] = true end + +-- STATUS_* bits (constants/battle_constants.asm): 0-2 sleep-turns-left, +-- 3 PSN, 4 BRN, 5 FRZ, 6 PAR +local STATUS_BIT = { PSN = 3, BRN = 4, FRZ = 5, PAR = 6 } +local function decodeStatus(b) + if bit.band(b, 7) > 0 then return "SLP" end + for name, bitIdx in pairs(STATUS_BIT) do + if bit.band(b, bit.lshift(1, bitIdx)) ~= 0 then return name end + end + return nil +end +local function encodeStatus(status) + if status == "SLP" then return 7 end + if status and STATUS_BIT[status] then return bit.lshift(1, STATUS_BIT[status]) end + return 0 +end + +local function buildIndexCrosswalk(defs) + local byIndex, byId = {}, {} + for id, def in pairs(defs or {}) do + if def.index ~= nil then + byIndex[def.index] = id + byId[id] = def.index + end + end + return byIndex, byId +end + +-- Pokedex bit position: NATIONAL DEX NUMBER (1-151), NOT the internal ROM +-- species byte (`def.index`, used for party/box mon structs) -- these are +-- two completely different Gen1 numbering schemes (the whole "MissingNo" +-- phenomenon is dex-number vs internal-index mismatches). This project's +-- generated data has no dedicated dex-number field, but every pokemon.lua +-- entry's `source` documents its extraction origin as "ROM:BaseStats[N]", +-- and BaseStats is declared in dex order in the disassembly -- verified +-- directly against 5 species (BULBASAUR->[1], CHARMANDER->[4], +-- SQUIRTLE->[7], PIKACHU->[25], MEWTWO->[150], all exactly their real +-- national dex numbers) before relying on it here. +local function buildDexCrosswalk(defs) + local byDex, dexOf = {}, {} + for id, def in pairs(defs or {}) do + local n = def.source and tonumber(def.source:match("BaseStats%[(%d+)%]")) + if n then + byDex[n] = id + dexOf[id] = n + end + end + return byDex, dexOf +end + +-- TM/HM item entries carry no `index` (data/generated/items.lua extracts +-- them by move/slot, not by their place in the raw item-constant table), +-- so buildIndexCrosswalk alone would silently drop every TM/HM from the +-- bag/PC on encode. Their real item ids ARE derivable: pokered's +-- constants/item_constants.asm declares "HM_\1: the item id, starting at +-- $C4" and "TM_\1: the item id, starting at $C9" for slot 1, incrementing +-- per slot -- i.e. HM01=196+.. , TM01=201+(number-1). +local function addMachineIndices(defs, byIndex, byId) + for id, def in pairs(defs or {}) do + if byId[id] == nil and def.machine and def.machine.number then + local base = def.machine.kind == "HM" and 195 or 200 + local idx = base + def.machine.number + byIndex[idx] = id + byId[id] = idx + end + end +end + +function GenSave.crosswalks(data) + local pokemonByIndex, pokemonIndex = buildIndexCrosswalk(data.pokemon) + local movesByIndex, movesIndex = buildIndexCrosswalk(data.moves) + local itemsByIndex, itemsIndex = buildIndexCrosswalk(data.items) + addMachineIndices(data.items, itemsByIndex, itemsIndex) + local mapsByIndex, mapsIndex = buildIndexCrosswalk(data.maps) + local pokemonByDex, pokemonDex = buildDexCrosswalk(data.pokemon) + return { + pokemonByIndex = pokemonByIndex, pokemonIndex = pokemonIndex, + pokemonByDex = pokemonByDex, pokemonDex = pokemonDex, + movesByIndex = movesByIndex, movesIndex = movesIndex, + itemsByIndex = itemsByIndex, itemsIndex = itemsIndex, + mapsByIndex = mapsByIndex, mapsIndex = mapsIndex, + speciesDefs = data.pokemon or {}, + } +end + +-- ------------------------------------------------------------------ +-- Mon struct (box_struct is a byte-for-byte prefix of party_struct; +-- decodeMon reads the box_struct fields, then Level+Stats if isParty). +-- Type1/Type2 are read for nothing (this project derives type from +-- species) but re-derived from data.pokemon[species].types on encode. +-- ------------------------------------------------------------------ + +local function decodeMon(bytes, off, isParty, cw) + local speciesIdx = u8(bytes, off) + if speciesIdx == 0 then return nil end -- empty slot + local species = cw.pokemonByIndex[speciesIdx] + local hp = u16be(bytes, off + 1) + local boxLevel = u8(bytes, off + 3) + local status = decodeStatus(u8(bytes, off + 4)) + local catchRate = u8(bytes, off + 7) + local moves = {} + for i = 0, 3 do + local moveIdx = u8(bytes, off + 8 + i) + if moveIdx > 0 then + local pp, ppUps = decodePPByte(u8(bytes, off + 29 + i)) + moves[#moves + 1] = { id = cw.movesByIndex[moveIdx], pp = pp, ppUps = ppUps } + end + end + local otId = u16be(bytes, off + 12) + local exp = u24be(bytes, off + 14) + local statExp = { + hp = u16be(bytes, off + 17), attack = u16be(bytes, off + 19), + defense = u16be(bytes, off + 21), speed = u16be(bytes, off + 23), + special = u16be(bytes, off + 25), + } + local dvs = decodeDVs(bytes, off + 27) + local mon = { + species = species, exp = exp, dvs = dvs, statExp = statExp, + hp = hp, status = status, moves = moves, otId = otId, + catchRate = catchRate, level = boxLevel, + -- Type1/Type2 as physically stored. This project derives type from species + -- for gameplay, but the raw bytes are captured so encode() can reproduce + -- them verbatim: some real saves (traded/tampered mons) carry type values + -- that do not match the ROM base stats, and re-deriving would corrupt them. + typeBytes = { u8(bytes, off + 5), u8(bytes, off + 6) }, + } + if isParty then + mon.level = u8(bytes, off + 33) + mon.stats = { + hp = u16be(bytes, off + 34), attack = u16be(bytes, off + 36), + defense = u16be(bytes, off + 38), speed = u16be(bytes, off + 40), + special = u16be(bytes, off + 42), + } + end + return mon +end + +local function encodeMon(buf, off, mon, isParty, cw) + if not mon then + setByte(buf, off, 0) + return + end + setByte(buf, off, cw.pokemonIndex[mon.species] or 0) + setU16be(buf, off + 1, mon.hp or 0) + setByte(buf, off + 3, mon.level or 1) + setByte(buf, off + 4, encodeStatus(mon.status)) + local def = cw.speciesDefs[mon.species] + if mon.typeBytes then + -- reproduce the exact stored type bytes captured on decode (faithful + -- byte round-trip); fresh, engine-built mons have none and derive below. + setByte(buf, off + 5, mon.typeBytes[1] or 0) + setByte(buf, off + 6, mon.typeBytes[2] or 0) + else + local t = (def and def.types) or {} + setByte(buf, off + 5, TYPE_INDEX[t[1]] or 0) + setByte(buf, off + 6, TYPE_INDEX[t[2] or t[1]] or 0) + end + setByte(buf, off + 7, mon.catchRate or (def and def.catchRate) or 0) + for i = 0, 3 do + local mv = mon.moves and mon.moves[i + 1] + setByte(buf, off + 8 + i, mv and (cw.movesIndex[mv.id] or 0) or 0) + setByte(buf, off + 29 + i, mv and encodePPByte(mv.pp, mv.ppUps) or 0) + end + setU16be(buf, off + 12, mon.otId or 0) + setU24be(buf, off + 14, mon.exp or 0) + local se = mon.statExp or {} + setU16be(buf, off + 17, se.hp or 0) + setU16be(buf, off + 19, se.attack or 0) + setU16be(buf, off + 21, se.defense or 0) + setU16be(buf, off + 23, se.speed or 0) + setU16be(buf, off + 25, se.special or 0) + encodeDVs(buf, off + 27, mon.dvs or {}) + if isParty then + setByte(buf, off + 33, mon.level or 1) + local st = mon.stats or {} + setU16be(buf, off + 34, st.hp or 0) + setU16be(buf, off + 36, st.attack or 0) + setU16be(buf, off + 38, st.defense or 0) + setU16be(buf, off + 40, st.speed or 0) + setU16be(buf, off + 42, st.special or 0) + end +end + +-- ------------------------------------------------------------------ +-- Bag / PC items: (id, qty) byte pairs, $FF-terminated +-- ------------------------------------------------------------------ + +local function decodeItemList(bytes, off, capacity, cw) + local inventory, order = {}, {} + for i = 0, capacity - 1 do + local idByte = u8(bytes, off + i * 2) + if idByte == 0xFF then break end + local qty = u8(bytes, off + i * 2 + 1) + local id = cw.itemsByIndex[idByte] + if id then + inventory[id] = qty + order[#order + 1] = id + end + end + return inventory, order +end + +local function encodeItemList(buf, off, capacity, inventory, order, cw) + local i = 0 + local seen = {} + local function put(id, qty) + if i >= capacity or not qty or qty <= 0 then return end + local idByte = cw.itemsIndex[id] + if not idByte then return end + setByte(buf, off + i * 2, idByte) + setByte(buf, off + i * 2 + 1, math.min(qty, 99)) + i = i + 1 + seen[id] = true + end + for _, id in ipairs(order or {}) do + if inventory[id] and not seen[id] then put(id, inventory[id]) end + end + for id, qty in pairs(inventory or {}) do + if not seen[id] then put(id, qty) end + end + setByte(buf, off + i * 2, 0xFF) + return i -- count actually written (badges etc. in `inventory` that + -- aren't real items are silently skipped by put(), so this + -- can be less than #inventory -- see the wNumBagItems caller) +end + +-- ------------------------------------------------------------------ +-- decode: raw 32768-byte SRAM string -> save.lua-shaped table +-- ------------------------------------------------------------------ + +function GenSave.decode(bytes, data, opts) + assert(#bytes == GenSave.SAVE_SIZE, "expected a 32768-byte save") + local cw = GenSave.crosswalks(data) + local warnings = {} + local function warn(msg) warnings[#warnings + 1] = msg end + + if checksum(bytes, O.checksumStart, O.checksumEnd) ~= u8(bytes, O.mainChecksum) then + warn("main data checksum mismatch (importing anyway)") + end + + local save = { + meta = { format = "gen1_import" }, + player = { + name = decodeName(bytes, O.playerName, NAME_LENGTH), + rival = decodeName(bytes, O.rivalName, NAME_LENGTH), + id = u16be(bytes, O.playerId), + }, + money = readBcd(bytes, O.money, 3), + coins = readBcd(bytes, O.coins, 2), + inventory = {}, + pcItems = {}, + pokedex = { seen = {}, owned = {} }, + flags = {}, + party = {}, + boxes = {}, + currentBox = 1, + } + + -- pokedex (own/seen, flag_array NUM_POKEMON: bit 0 = dex #1) + for dex, species in pairs(cw.pokemonByDex) do + local bitIdx = dex - 1 + if bitGet(bytes, O.pokedexOwned, bitIdx) then save.pokedex.owned[species] = true end + if bitGet(bytes, O.pokedexSeen, bitIdx) then save.pokedex.seen[species] = true end + end + + save.inventory, save.bagOrder = decodeItemList(bytes, O.bagItems, 20, cw) + save.pcItems, save.pcOrder = decodeItemList(bytes, O.pcItems, 50, cw) + + -- badges: truthy save.inventory[id] entries (src/inventory/Badges.lua), + -- set AFTER decodeItemList since that call replaces save.inventory + local badgesByte = u8(bytes, O.badges) + for i = 0, NUM_BADGES - 1 do + if bit.band(badgesByte, bit.lshift(1, i)) ~= 0 then + save.inventory[BADGE_BY_BIT[i]] = 1 + end + end + + -- party + local partyCount = u8(bytes, O.partyCount) + for i = 0, math.min(partyCount, PARTY_LENGTH) - 1 do + local mon = decodeMon(bytes, O.partyMons + i * PARTY_STRUCT_SIZE, true, cw) + if mon then + mon.ot = decodeName(bytes, O.partyMonOT + i * NAME_LENGTH, NAME_LENGTH) + mon.nickname = decodeName(bytes, O.partyMonNicks + i * NAME_LENGTH, NAME_LENGTH) + save.party[#save.party + 1] = mon + end + end + + -- current box (bank 1) + the 11 stored boxes (banks 2/3) + for i = 1, 12 do save.boxes[i] = {} end + local function decodeBoxRegion(base, boxNum) + local count = u8(bytes, base) + for i = 0, math.min(count, MONS_PER_BOX) - 1 do + local mon = decodeMon(bytes, base + 22 + i * BOX_STRUCT_SIZE, false, cw) + if mon then + mon.ot = decodeName(bytes, base + 22 + MONS_PER_BOX * BOX_STRUCT_SIZE + i * NAME_LENGTH, NAME_LENGTH) + mon.nickname = decodeName(bytes, base + 22 + MONS_PER_BOX * (BOX_STRUCT_SIZE + NAME_LENGTH) + i * NAME_LENGTH, NAME_LENGTH) + table.insert(save.boxes[boxNum], mon) + end + end + end + local curBoxNum = bit.band(u8(bytes, O.currentBoxNum), 0x7F) -- 0-based box index + curBoxNum = math.max(1, math.min(12, curBoxNum + 1)) + decodeBoxRegion(O.curBoxData, curBoxNum) + for b = 1, 6 do + if b + 0 ~= curBoxNum then decodeBoxRegion(O.box1 + (b - 1) * BOX_REGION_SIZE, b) end + end + for b = 7, 12 do + if b ~= curBoxNum then decodeBoxRegion(O.box7 + (b - 7) * BOX_REGION_SIZE, b) end + end + save.currentBox = curBoxNum + + -- event flags (only bits with a known name are decoded) + local events = data.eventFlags + if events then + for bitIdx, name in pairs(events.byBit) do + if bitGet(bytes, O.eventFlags, bitIdx) then save.flags[name] = true end + end + end + + -- map + position + local mapIdx = u8(bytes, O.curMap) + local mapId = cw.mapsByIndex[mapIdx] + local y, x = u8(bytes, O.yCoord), u8(bytes, O.xCoord) + if mapId then + save.player.map, save.player.x, save.player.y = mapId, x, y + else + warn(("unknown map index %d, defaulting spawn"):format(mapIdx)) + end + local lastMapIdx = u8(bytes, O.lastMap) + local lastMapId = cw.mapsByIndex[lastMapIdx] + if lastMapId then save.lastOutdoor = { id = lastMapId } end + + -- play time: this project stores save.playTime as a single float of + -- SECONDS (src/core/Game.lua accumulates dt each frame; StartMenu / + -- TrainerCard / TitleState render it H:MM via t/3600 and (t/60)%60). + -- Fold the Gen1 H/M/S/F fields into that one number; frames are 1/60s + -- sub-second ticks, kept as a fraction so an export recovers them exactly. + save.playTime = u8(bytes, O.playTimeHours) * 3600 + + u8(bytes, O.playTimeMinutes) * 60 + + u8(bytes, O.playTimeSeconds) + + u8(bytes, O.playTimeFrames) / 60 + + save.warnings = warnings + save.rawImport = bytes -- template for a later encode(); see file header + return save +end + +-- ------------------------------------------------------------------ +-- encode: save.lua-shaped table -> raw 32768-byte SRAM string +-- ------------------------------------------------------------------ + +function GenSave.encode(save, data, template) + local cw = GenSave.crosswalks(data) + local src = template or save.rawImport + local buf = {} + if src then + for i = 1, GenSave.SAVE_SIZE do buf[i] = src:sub(i, i) end + else + local zero = string.char(0) + for i = 1, GenSave.SAVE_SIZE do buf[i] = zero end + end + + encodeName(buf, O.playerName, NAME_LENGTH, (save.player and save.player.name) or "RED") + encodeName(buf, O.rivalName, NAME_LENGTH, (save.player and save.player.rival) or "BLUE") + setU16be(buf, O.playerId, (save.player and save.player.id) or 0) + setBcd(buf, O.money, 3, math.min(save.money or 0, 999999)) + setBcd(buf, O.coins, 2, math.min(save.coins or 0, 9999)) + + local badgesByte = 0 + for bitIdx, name in pairs(BADGE_BY_BIT) do + if save.inventory and save.inventory[name] then + badgesByte = bit.bor(badgesByte, bit.lshift(1, bitIdx)) + end + end + setByte(buf, O.badges, badgesByte) + + for dex, species in pairs(cw.pokemonByDex) do + local bitIdx = dex - 1 + bitSet(buf, O.pokedexOwned, bitIdx, + (save.pokedex and save.pokedex.owned and save.pokedex.owned[species]) and true or false) + bitSet(buf, O.pokedexSeen, bitIdx, + (save.pokedex and save.pokedex.seen and save.pokedex.seen[species]) and true or false) + end + + -- Badges occupy real item IDs in data/generated/items.lua (Gen1's item + -- ID space includes them, $01-$08, for the "got the BOULDERBADGE!" + -- text display), but this project's save.lua stores them as truthy + -- save.inventory[id] entries alongside actual bag items (see the badge + -- block above and src/inventory/Badges.lua) -- a real save NEVER + -- writes them into wBagItems (they only ever live in wObtainedBadges, + -- already encoded above), so they must be filtered out here or they'd + -- corrupt the bag with bogus "badge items". + local bagInventory = {} + for id, qty in pairs(save.inventory or {}) do + if not BADGE_BY_BIT_SET[id] then bagInventory[id] = qty end + end + local bagN = encodeItemList(buf, O.bagItems, 20, bagInventory, save.bagOrder, cw) + setByte(buf, O.numBagItems, bagN) + local pcN = encodeItemList(buf, O.pcItems, 50, save.pcItems or {}, save.pcOrder, cw) + setByte(buf, O.numPcItems, pcN) + + local events = data.eventFlags + if events and save.flags then + for name in pairs(save.flags) do + local bitIdx = events.byName[name] + if bitIdx then bitSet(buf, O.eventFlags, bitIdx, true) end + end + end + + -- party + local party = save.party or {} + local partyN = math.min(#party, PARTY_LENGTH) + setByte(buf, O.partyCount, partyN) + for i = 0, partyN - 1 do + local mon = party[i + 1] + encodeMon(buf, O.partyMons + i * PARTY_STRUCT_SIZE, mon, true, cw) + setByte(buf, O.partySpecies + i, cw.pokemonIndex[mon.species] or 0) + encodeName(buf, O.partyMonOT + i * NAME_LENGTH, NAME_LENGTH, + mon.ot or (save.player and save.player.name) or "RED") + encodeName(buf, O.partyMonNicks + i * NAME_LENGTH, NAME_LENGTH, + mon.nickname or mon.species or "") + end + -- $FF-terminate the species index list right after the last real mon. The + -- struct, OT-name and nickname bytes of the empty slots past partyN are left + -- exactly as the template holds them (original stale data -> byte-identical + -- round-trip) or zero on a fresh export -- the game never reads past the + -- count, so this matches how it leaves those bytes itself. + setByte(buf, O.partySpecies + partyN, 0xFF) + + -- boxes: current box mirrors save.currentBox into sCurBoxData; all 12 + -- also get written into their bank-2/3 slot (sCurBoxData is a working + -- copy the real game keeps in sync on every PC visit, so keeping both + -- copies consistent here matches that invariant) + local function encodeBoxRegion(base, mons) + local n = math.min(#mons, MONS_PER_BOX) + setByte(buf, base, n) + for i = 0, n - 1 do + local mon = mons[i + 1] + encodeMon(buf, base + 22 + i * BOX_STRUCT_SIZE, mon, false, cw) + setByte(buf, base + 1 + i, cw.pokemonIndex[mon.species] or 0) + encodeName(buf, base + 22 + MONS_PER_BOX * BOX_STRUCT_SIZE + i * NAME_LENGTH, NAME_LENGTH, + mon.ot or (save.player and save.player.name) or "RED") + encodeName(buf, base + 22 + MONS_PER_BOX * (BOX_STRUCT_SIZE + NAME_LENGTH) + i * NAME_LENGTH, NAME_LENGTH, + mon.nickname or mon.species or "") + end + -- $FF-terminate the species list after the last real mon; empty slots past + -- n keep their template bytes (byte-identical round-trip) or zero (fresh + -- export), just as the game leaves stale box data untouched past the count. + setByte(buf, base + 1 + n, 0xFF) + end + local boxes = save.boxes or {} + local curBoxNum = math.max(1, math.min(12, save.currentBox or 1)) + encodeBoxRegion(O.curBoxData, boxes[curBoxNum] or {}) + -- bit 7 of wCurBoxNum is the "box system initialized" flag, not part of the + -- 0-11 index; preserve it from the template, or set it on a templateless + -- export (any save we emit has an initialized box system). + local prevBoxByte = buf[O.currentBoxNum + 1] + local boxHiBit = (src and prevBoxByte) and bit.band(prevBoxByte:byte(), 0x80) or 0x80 + setByte(buf, O.currentBoxNum, bit.bor(bit.band(curBoxNum - 1, 0x7F), boxHiBit)) + for b = 1, 6 do encodeBoxRegion(O.box1 + (b - 1) * BOX_REGION_SIZE, boxes[b] or {}) end + for b = 7, 12 do encodeBoxRegion(O.box7 + (b - 7) * BOX_REGION_SIZE, boxes[b] or {}) end + + -- map + position + if save.player and save.player.map then + setByte(buf, O.curMap, cw.mapsIndex[save.player.map] or 0) + setByte(buf, O.yCoord, save.player.y or 0) + setByte(buf, O.xCoord, save.player.x or 0) + end + if save.lastOutdoor and save.lastOutdoor.id then + setByte(buf, O.lastMap, cw.mapsIndex[save.lastOutdoor.id] or 0) + end + + -- play time: split save.playTime (seconds) back into H/M/S/F. The real + -- game freezes the clock at 255h and sets wPlayTimeMaxed once past it, so + -- mirror that cap rather than letting hours overflow a single byte. + local totalFrames = math.floor((save.playTime or 0) * 60 + 0.5) + local hours = math.floor(totalFrames / 216000) -- 3600s * 60 frames + if hours > 255 then + setByte(buf, O.playTimeHours, 255) + setByte(buf, O.playTimeMaxed, 1) + setByte(buf, O.playTimeMinutes, 59) + setByte(buf, O.playTimeSeconds, 59) + setByte(buf, O.playTimeFrames, 59) + else + local rem = totalFrames - hours * 216000 + local mins = math.floor(rem / 3600); rem = rem - mins * 3600 + local secs = math.floor(rem / 60) + setByte(buf, O.playTimeHours, hours) + setByte(buf, O.playTimeMaxed, 0) + setByte(buf, O.playTimeMinutes, mins) + setByte(buf, O.playTimeSeconds, secs) + setByte(buf, O.playTimeFrames, rem - secs * 60) + end + + local out = table.concat(buf) + -- checksums, computed last over the now-final bytes + local outBuf = {} + for i = 1, #out do outBuf[i] = out:sub(i, i) end + setByte(outBuf, O.mainChecksum, checksum(out, O.checksumStart, O.checksumEnd)) + -- Per pokered (engine/menus/save.asm SaveSAVtoSRAM / CalcCheckSum): each box + -- gets its own checksum, and the bank aggregate is CalcCheckSum over the + -- ENTIRE six-box region (6 x 1122 bytes), not a sum of the six box sums. + local function boxChecksum(base) return checksum(out, base, base + BOX_REGION_SIZE) end + for b = 0, 5 do + setByte(outBuf, O.boxBank2IndividualChecksums + b, + boxChecksum(O.box1 + b * BOX_REGION_SIZE)) + end + setByte(outBuf, O.boxBank2Checksum, + checksum(out, O.box1, O.box1 + 6 * BOX_REGION_SIZE)) + for b = 0, 5 do + setByte(outBuf, O.boxBank3IndividualChecksums + b, + boxChecksum(O.box7 + b * BOX_REGION_SIZE)) + end + setByte(outBuf, O.boxBank3Checksum, + checksum(out, O.box7, O.box7 + 6 * BOX_REGION_SIZE)) + + return table.concat(outBuf) +end + +return GenSave diff --git a/src/save_convert/SaveConvert.lua b/src/save_convert/SaveConvert.lua new file mode 100644 index 00000000..3136f389 --- /dev/null +++ b/src/save_convert/SaveConvert.lua @@ -0,0 +1,189 @@ +-- SaveConvert -- the runtime-facing entry point the launcher UI calls to +-- turn a vanilla Gen1 (Red/Blue, international) battery save into this +-- project's in-memory save table, and back out to a raw .sav image. +-- +-- This is the ONE place the engine, the tests and the CLI +-- (tools/save_convert/convert.lua) share: the GenSave codec, the crosswalk +-- data loading, the merge over new-game defaults, and the version tag all +-- live here so every consumer behaves identically. +-- +-- Pure Lua, no love.* dependency at require time: GenSave and the crosswalk +-- tables load through `require`, exactly how src/core/Data.lua pulls the +-- generated modules -- which resolves under both plain luajit (package.path +-- "./?.lua") for the headless CLI/tests and love.filesystem for a fused +-- build, with an OS-path fallback for odd working directories. The only +-- place `love` is referenced is inside a guarded fallback, so running under +-- stock Lua never touches it. + +local GenSave = require("src.save_convert.GenSave") + +local SaveConvert = {} + +SaveConvert.SAVE_SIZE = GenSave.SAVE_SIZE + +-- ------------------------------------------------------------------ +-- Crosswalk data loading (cached). Mirrors src/core/Data.lua: prefer +-- `require` (works headless via package.path and fused via love's package +-- searcher); fall back to love.filesystem.load, then a plain dofile, for +-- the rare case a host has an unusual cwd or module path. +-- ------------------------------------------------------------------ + +-- { require-module-path, os-relative-file-path } for each table the codec +-- needs. pokemon/moves/items/maps come from the shared generated data; +-- charmap/event_flags are the save-convert-specific crosswalks. +local DATA_MODULES = { + pokemon = { "data.generated.pokemon", "data/generated/pokemon.lua" }, + moves = { "data.generated.moves", "data/generated/moves.lua" }, + items = { "data.generated.items", "data/generated/items.lua" }, + maps = { "data.generated.maps", "data/generated/maps.lua" }, + charmap = { "src.save_convert.data.charmap", "src/save_convert/data/charmap.lua" }, + eventFlags = { "src.save_convert.data.event_flags", "src/save_convert/data/event_flags.lua" }, +} + +local function loadTable(requirePath, filePath) + local ok, mod = pcall(require, requirePath) + if ok and type(mod) == "table" then return mod end + -- fused build with an unexpected module path: read straight off the + -- mounted filesystem (love is a global here, only ever touched when it + -- actually exists -- stock Lua never reaches this branch) + if love and love.filesystem and love.filesystem.getInfo + and love.filesystem.getInfo(filePath) then + local chunk = love.filesystem.load(filePath) + if chunk then + local m = chunk() + if type(m) == "table" then return m end + end + end + local chunk = loadfile(filePath) + if chunk then + local m = chunk() + if type(m) == "table" then return m end + end + return nil, ("cannot load save-convert data module %q (tried require %q and file %q)") + :format(requirePath, requirePath, filePath) +end + +local crosswalk -- { pokemon=, moves=, items=, maps=, eventFlags= } +local charmapReady + +local function ensureData() + if not crosswalk then + local data = {} + for key, spec in pairs(DATA_MODULES) do + if key ~= "charmap" then + local mod, e = loadTable(spec[1], spec[2]) + if not mod then return nil, e end + data[key] = mod + end + end + crosswalk = data + end + if not charmapReady then + local cm, err = loadTable(DATA_MODULES.charmap[1], DATA_MODULES.charmap[2]) + if not cm then return nil, err end + GenSave.setCharmap(cm) + charmapReady = true + end + return crosswalk +end + +-- Exposed for the CLI/tests so they can share the exact data set the codec +-- uses (and so a caller can pre-warm the cache). Returns data, err. +function SaveConvert.loadData() + return ensureData() +end + +-- ------------------------------------------------------------------ +-- new-game default skeleton the decoded fields merge on top of. Carried +-- verbatim from tools/save_convert/convert.lua so the CLI and the runtime +-- produce a byte-identical save table for the same input. +-- ------------------------------------------------------------------ + +local function defaultsSave() + return { + meta = { format = "gen1_import", mods = {} }, + defeatedTrainers = {}, + repelSteps = 0, + modData = {}, + options = { + textSpeed = 3, animations = true, battleStyle = "shift", + ruleset = "gen1_faithful", musicVol = 7, sfxVol = 7, musicFilter = 0, + speed = 1, colors = "gbc", tilt = 0, gbcfx = 0, + videoMode = "windowed", mods = {}, + }, + } +end + +-- Merge a GenSave.decode() result over the new-game defaults, exactly the +-- way convert.lua did, then stamp the requested version. The 32768-byte +-- import template GenSave stashes as `rawImport` and the decode `warnings` +-- are dropped here: neither belongs in a serialized slot file (a fresh +-- export always starts zero-filled -- see GenSave.lua's header). +local function mergeDefaults(decoded, version) + decoded.warnings = nil + decoded.rawImport = nil + local save = defaultsSave() + for k, v in pairs(decoded) do save[k] = v end + save.lastHeal = { map = save.player.map, x = save.player.x, y = save.player.y } + save.lastOutdoor = save.lastOutdoor or { id = save.player.map } + if version ~= nil then + save.meta = save.meta or {} + save.meta.version = version + end + return save +end +SaveConvert.mergeDefaults = mergeDefaults + +-- ------------------------------------------------------------------ +-- Public API +-- ------------------------------------------------------------------ + +-- importSav(bytes, version) -> saveTable, err +-- bytes: the raw 32768-byte SRAM string. Validates size and the main-data +-- checksum, decodes through GenSave, and returns a save table fully merged +-- over the new-game defaults and tagged with `version`, ready to hand to +-- SaveSerializer.encode for a slot file. On any failure returns nil + a +-- message (never raises). +function SaveConvert.importSav(bytes, version) + if type(bytes) ~= "string" then + return nil, "expected raw save bytes as a string" + end + if #bytes ~= GenSave.SAVE_SIZE then + return nil, ("save must be %d bytes, got %d"):format(GenSave.SAVE_SIZE, #bytes) + end + local data, derr = ensureData() + if not data then return nil, derr end + + local ok, decoded = pcall(GenSave.decode, bytes, data) + if not ok then return nil, "decode failed: " .. tostring(decoded) end + + -- checksum validation: GenSave.decode records a warning rather than + -- throwing (so it can still read a foreign/corrupt save), but for the + -- runtime import path a bad main-data checksum means the file is not a + -- trustworthy save, so reject it. + for _, w in ipairs(decoded.warnings or {}) do + if tostring(w):find("checksum") then + return nil, "save data checksum invalid (" .. tostring(w) .. ")" + end + end + + return mergeDefaults(decoded, version) +end + +-- exportSav(saveTable) -> bytes, err +-- Encodes a save table back to a raw 32768-byte SRAM image. Template-aware: +-- if the table still carries the stashed import template (saveTable.rawImport) +-- GenSave reproduces every unmodeled region from it; otherwise those regions +-- are zero-filled. On failure returns nil + a message (never raises). +function SaveConvert.exportSav(saveTable) + if type(saveTable) ~= "table" then + return nil, "expected a save table" + end + local data, derr = ensureData() + if not data then return nil, derr end + local ok, bytes = pcall(GenSave.encode, saveTable, data, nil) + if not ok then return nil, "encode failed: " .. tostring(bytes) end + return bytes +end + +return SaveConvert diff --git a/src/save_convert/data/charmap.lua b/src/save_convert/data/charmap.lua new file mode 100644 index 00000000..f5ddce87 --- /dev/null +++ b/src/save_convert/data/charmap.lua @@ -0,0 +1,579 @@ +-- Generated by tools/build_data.py. DO NOT EDIT. +-- Gen1 text byte <-> glyph/token charmap (fixed-length name +-- fields: player/rival/OT names, nicknames -- box/party mon +-- names are NUL-free, '@' ($50) terminated, space-padded). +-- byToken's key is the literal glyph for ordinary characters +-- ("A", "é", ...) or a bracketed control token +-- ("", "@") for terminators/substitutions -- only +-- the plain single-glyph entries are meaningful inside a name. +return { + byByte = { + [0] = "", + [5] = "ガ", + [6] = "ギ", + [7] = "グ", + [8] = "ゲ", + [9] = "ゴ", + [10] = "ザ", + [11] = "ジ", + [12] = "ズ", + [13] = "ゼ", + [14] = "ゾ", + [15] = "ダ", + [16] = "ヂ", + [17] = "ヅ", + [18] = "デ", + [19] = "ド", + [25] = "バ", + [26] = "ビ", + [27] = "ブ", + [28] = "ボ", + [38] = "が", + [39] = "ぎ", + [40] = "ぐ", + [41] = "げ", + [42] = "ご", + [43] = "ざ", + [44] = "じ", + [45] = "ず", + [46] = "ぜ", + [47] = "ぞ", + [48] = "だ", + [49] = "ぢ", + [50] = "づ", + [51] = "で", + [52] = "ど", + [58] = "ば", + [59] = "び", + [60] = "ぶ", + [61] = "べ", + [62] = "ぼ", + [64] = "パ", + [65] = "ピ", + [66] = "プ", + [67] = "ポ", + [68] = "ぱ", + [69] = "ぴ", + [70] = "ぷ", + [71] = "ぺ", + [72] = "ぽ", + [73] = "", + [74] = "", + [75] = "<_CONT>", + [76] = "", + [78] = "", + [79] = "", + [80] = "@", + [81] = "", + [82] = "", + [83] = "", + [84] = "#", + [85] = "", + [86] = "<……>", + [87] = "", + [88] = "", + [89] = "", + [90] = "", + [91] = "", + [92] = "", + [93] = "", + [94] = "", + [95] = "", + [96] = "", + [97] = "", + [98] = "", + [99] = "", + [100] = "", + [101] = "", + [102] = "", + [103] = "", + [104] = "", + [105] = "", + [106] = "", + [107] = "", + [108] = "", + [109] = "", + [110] = "ぃ", + [111] = "ぅ", + [112] = "‘", + [113] = "’", + [114] = "“", + [115] = "”", + [116] = "·", + [117] = "…", + [118] = "ぁ", + [119] = "ぇ", + [120] = "ぉ", + [121] = "┌", + [122] = "─", + [123] = "┐", + [124] = "│", + [125] = "└", + [126] = "┘", + [127] = " ", + [128] = "A", + [129] = "B", + [130] = "C", + [131] = "D", + [132] = "E", + [133] = "F", + [134] = "G", + [135] = "H", + [136] = "I", + [137] = "J", + [138] = "K", + [139] = "L", + [140] = "M", + [141] = "N", + [142] = "O", + [143] = "P", + [144] = "Q", + [145] = "R", + [146] = "S", + [147] = "T", + [148] = "U", + [149] = "V", + [150] = "W", + [151] = "X", + [152] = "Y", + [153] = "Z", + [154] = "(", + [155] = ")", + [156] = ":", + [157] = ";", + [158] = "[", + [159] = "]", + [160] = "a", + [161] = "b", + [162] = "c", + [163] = "d", + [164] = "e", + [165] = "f", + [166] = "g", + [167] = "h", + [168] = "i", + [169] = "j", + [170] = "k", + [171] = "l", + [172] = "m", + [173] = "n", + [174] = "o", + [175] = "p", + [176] = "q", + [177] = "r", + [178] = "s", + [179] = "t", + [180] = "u", + [181] = "v", + [182] = "w", + [183] = "x", + [184] = "y", + [185] = "z", + [186] = "é", + [187] = "'d", + [188] = "'l", + [189] = "'s", + [190] = "'t", + [191] = "'v", + [192] = "た", + [193] = "ち", + [194] = "つ", + [195] = "て", + [196] = "と", + [197] = "な", + [198] = "に", + [199] = "ぬ", + [200] = "ね", + [201] = "の", + [202] = "は", + [203] = "ひ", + [204] = "ふ", + [205] = "へ", + [206] = "ほ", + [207] = "ま", + [208] = "み", + [209] = "む", + [210] = "め", + [211] = "も", + [212] = "や", + [213] = "ゆ", + [214] = "よ", + [215] = "ら", + [216] = "り", + [217] = "る", + [218] = "れ", + [219] = "ろ", + [220] = "わ", + [221] = "を", + [222] = "ん", + [223] = "っ", + [224] = "'", + [225] = "", + [226] = "", + [227] = "-", + [228] = "'r", + [229] = "'m", + [230] = "?", + [231] = "!", + [232] = ".", + [233] = "ァ", + [234] = "ゥ", + [235] = "ェ", + [236] = "▷", + [237] = "▲", + [238] = "▼", + [239] = "♂", + [240] = "", + [241] = "×", + [242] = "", + [243] = "/", + [244] = ",", + [245] = "♀", + [246] = "0", + [247] = "1", + [248] = "2", + [249] = "3", + [250] = "4", + [251] = "5", + [252] = "6", + [253] = "7", + [254] = "8", + [255] = "9", + }, + byToken = { + [" "] = 127, + ["!"] = 231, + ["#"] = 84, + ["'"] = 224, + ["'d"] = 187, + ["'l"] = 188, + ["'m"] = 229, + ["'r"] = 228, + ["'s"] = 189, + ["'t"] = 190, + ["'v"] = 191, + ["("] = 154, + [")"] = 155, + [","] = 244, + ["-"] = 227, + ["."] = 232, + ["/"] = 243, + ["0"] = 246, + ["1"] = 247, + ["2"] = 248, + ["3"] = 249, + ["4"] = 250, + ["5"] = 251, + ["6"] = 252, + ["7"] = 253, + ["8"] = 254, + ["9"] = 255, + [":"] = 156, + [";"] = 157, + [""] = 96, + [""] = 97, + [""] = 98, + [""] = 99, + [""] = 100, + [""] = 101, + [""] = 102, + [""] = 103, + [""] = 104, + [""] = 107, + [""] = 108, + [""] = 114, + [""] = 106, + [""] = 105, + [""] = 109, + [""] = 85, + [""] = 95, + [""] = 87, + [""] = 242, + [""] = 240, + [""] = 115, + [""] = 79, + [""] = 110, + [""] = 226, + [""] = 78, + [""] = 0, + [""] = 73, + [""] = 81, + [""] = 91, + [""] = 225, + [""] = 74, + [""] = 82, + [""] = 88, + [""] = 83, + [""] = 94, + [""] = 76, + [""] = 89, + [""] = 92, + [""] = 93, + [""] = 90, + ["<_CONT>"] = 75, + [""] = 112, + ["<……>"] = 86, + ["?"] = 230, + ["@"] = 80, + A = 128, + B = 129, + C = 130, + D = 131, + E = 132, + F = 133, + G = 134, + H = 135, + I = 136, + J = 137, + K = 138, + L = 139, + M = 140, + N = 141, + O = 142, + P = 143, + Q = 144, + R = 145, + S = 146, + T = 147, + U = 148, + V = 149, + W = 150, + X = 151, + Y = 152, + Z = 153, + ["["] = 158, + ["]"] = 159, + a = 160, + b = 161, + c = 162, + d = 163, + e = 164, + f = 165, + g = 166, + h = 167, + i = 168, + j = 169, + k = 170, + l = 171, + m = 172, + n = 173, + o = 174, + p = 175, + q = 176, + r = 177, + s = 178, + t = 179, + u = 180, + v = 181, + w = 182, + x = 183, + y = 184, + z = 185, + ["¥"] = 240, + ["·"] = 116, + ["×"] = 241, + ["é"] = 186, + ["‘"] = 112, + ["’"] = 113, + ["“"] = 114, + ["”"] = 115, + ["…"] = 117, + ["′"] = 96, + ["″"] = 97, + ["№"] = 116, + ["⋯"] = 117, + ["─"] = 122, + ["│"] = 124, + ["┌"] = 121, + ["┐"] = 123, + ["└"] = 125, + ["┘"] = 126, + ["▲"] = 237, + ["▶"] = 237, + ["▷"] = 236, + ["▼"] = 238, + ["♀"] = 245, + ["♂"] = 239, + [" "] = 127, + ["。"] = 232, + ["「"] = 112, + ["」"] = 113, + ["『"] = 114, + ["』"] = 115, + ["ぁ"] = 118, + ["あ"] = 177, + ["ぃ"] = 110, + ["い"] = 178, + ["ぅ"] = 111, + ["う"] = 179, + ["ぇ"] = 119, + ["え"] = 180, + ["ぉ"] = 120, + ["お"] = 181, + ["か"] = 182, + ["が"] = 38, + ["き"] = 183, + ["ぎ"] = 39, + ["く"] = 184, + ["ぐ"] = 40, + ["け"] = 185, + ["げ"] = 41, + ["こ"] = 186, + ["ご"] = 42, + ["さ"] = 187, + ["ざ"] = 43, + ["し"] = 188, + ["じ"] = 44, + ["す"] = 189, + ["ず"] = 45, + ["せ"] = 190, + ["ぜ"] = 46, + ["そ"] = 191, + ["ぞ"] = 47, + ["た"] = 192, + ["だ"] = 48, + ["ち"] = 193, + ["ぢ"] = 49, + ["っ"] = 223, + ["つ"] = 194, + ["づ"] = 50, + ["て"] = 195, + ["で"] = 51, + ["と"] = 196, + ["ど"] = 52, + ["な"] = 197, + ["に"] = 198, + ["ぬ"] = 199, + ["ね"] = 200, + ["の"] = 201, + ["は"] = 202, + ["ば"] = 58, + ["ぱ"] = 68, + ["ひ"] = 203, + ["び"] = 59, + ["ぴ"] = 69, + ["ふ"] = 204, + ["ぶ"] = 60, + ["ぷ"] = 70, + ["へ"] = 205, + ["べ"] = 61, + ["ぺ"] = 71, + ["ほ"] = 206, + ["ぼ"] = 62, + ["ぽ"] = 72, + ["ま"] = 207, + ["み"] = 208, + ["む"] = 209, + ["め"] = 210, + ["も"] = 211, + ["ゃ"] = 224, + ["や"] = 212, + ["ゅ"] = 225, + ["ゆ"] = 213, + ["ょ"] = 226, + ["よ"] = 214, + ["ら"] = 215, + ["り"] = 216, + ["る"] = 217, + ["れ"] = 218, + ["ろ"] = 219, + ["わ"] = 220, + ["を"] = 221, + ["ん"] = 222, + ["ァ"] = 233, + ["ア"] = 128, + ["ィ"] = 176, + ["イ"] = 129, + ["ゥ"] = 234, + ["ウ"] = 130, + ["ェ"] = 235, + ["エ"] = 131, + ["ォ"] = 244, + ["オ"] = 132, + ["カ"] = 133, + ["ガ"] = 5, + ["キ"] = 134, + ["ギ"] = 6, + ["ク"] = 135, + ["グ"] = 7, + ["ケ"] = 136, + ["ゲ"] = 8, + ["コ"] = 137, + ["ゴ"] = 9, + ["サ"] = 138, + ["ザ"] = 10, + ["シ"] = 139, + ["ジ"] = 11, + ["ス"] = 140, + ["ズ"] = 12, + ["セ"] = 141, + ["ゼ"] = 13, + ["ソ"] = 142, + ["ゾ"] = 14, + ["タ"] = 143, + ["ダ"] = 15, + ["チ"] = 144, + ["ヂ"] = 16, + ["ッ"] = 172, + ["ツ"] = 145, + ["ヅ"] = 17, + ["テ"] = 146, + ["デ"] = 18, + ["ト"] = 147, + ["ド"] = 19, + ["ナ"] = 148, + ["ニ"] = 149, + ["ヌ"] = 150, + ["ネ"] = 151, + ["ノ"] = 152, + ["ハ"] = 153, + ["バ"] = 25, + ["パ"] = 64, + ["ヒ"] = 154, + ["ビ"] = 26, + ["ピ"] = 65, + ["フ"] = 155, + ["ブ"] = 27, + ["プ"] = 66, + ["ホ"] = 156, + ["ボ"] = 28, + ["ポ"] = 67, + ["マ"] = 157, + ["ミ"] = 158, + ["ム"] = 159, + ["メ"] = 160, + ["モ"] = 161, + ["ャ"] = 173, + ["ヤ"] = 162, + ["ュ"] = 174, + ["ユ"] = 163, + ["ョ"] = 175, + ["ヨ"] = 164, + ["ラ"] = 165, + ["ル"] = 166, + ["レ"] = 167, + ["ロ"] = 168, + ["ワ"] = 169, + ["ヲ"] = 170, + ["ン"] = 171, + ["ー"] = 227, + ["円"] = 240, + ["!"] = 231, + ["."] = 242, + ["/"] = 243, + ["0"] = 246, + ["1"] = 247, + ["2"] = 248, + ["3"] = 249, + ["4"] = 250, + ["5"] = 251, + ["6"] = 252, + ["7"] = 253, + ["8"] = 254, + ["9"] = 255, + ["?"] = 230, + ["゙"] = 229, + ["゚"] = 228, + }, + source = "pokered constants/charmap.asm", +} diff --git a/src/save_convert/data/event_flags.lua b/src/save_convert/data/event_flags.lua new file mode 100644 index 00000000..18295d3d --- /dev/null +++ b/src/save_convert/data/event_flags.lua @@ -0,0 +1,1027 @@ +-- Generated by tools/build_data.py. DO NOT EDIT. +-- wEventFlags bit index <-> EVENT_* name (see ram/wram.asm +-- wEventFlags, a flat NUM_EVENTS-bit / (NUM_EVENTS/8)-byte +-- array). byBit only has entries for bits with a name -- +-- reserved/padding bits are intentionally absent. +return { + byBit = { + [0] = "EVENT_FOLLOWED_OAK_INTO_LAB", + [3] = "EVENT_HALL_OF_FAME_DEX_RATING", + [6] = "EVENT_PALLET_AFTER_GETTING_POKEBALLS", + [24] = "EVENT_GOT_TOWN_MAP", + [25] = "EVENT_ENTERED_BLUES_HOUSE", + [26] = "EVENT_DAISY_WALKING", + [32] = "EVENT_FOLLOWED_OAK_INTO_LAB_2", + [33] = "EVENT_OAK_ASKED_TO_CHOOSE_MON", + [34] = "EVENT_GOT_STARTER", + [35] = "EVENT_BATTLED_RIVAL_IN_OAKS_LAB", + [36] = "EVENT_GOT_POKEBALLS_FROM_OAK", + [37] = "EVENT_GOT_POKEDEX", + [38] = "EVENT_PALLET_AFTER_GETTING_POKEBALLS_2", + [39] = "EVENT_OAK_APPEARED_IN_PALLET", + [40] = "EVENT_VIRIDIAN_GYM_OPEN", + [41] = "EVENT_GOT_TM42", + [56] = "EVENT_OAK_GOT_PARCEL", + [57] = "EVENT_GOT_OAKS_PARCEL", + [80] = "EVENT_GOT_TM27", + [81] = "EVENT_BEAT_VIRIDIAN_GYM_GIOVANNI", + [82] = "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_0", + [83] = "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_1", + [84] = "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_2", + [85] = "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_3", + [86] = "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_4", + [87] = "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_5", + [88] = "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_6", + [89] = "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_7", + [104] = "EVENT_BOUGHT_MUSEUM_TICKET", + [105] = "EVENT_GOT_OLD_AMBER", + [114] = "EVENT_BEAT_PEWTER_GYM_TRAINER_0", + [118] = "EVENT_GOT_TM34", + [119] = "EVENT_BEAT_BROCK", + [152] = "EVENT_BEAT_CERULEAN_RIVAL", + [167] = "EVENT_BEAT_CERULEAN_ROCKET_THIEF", + [186] = "EVENT_BEAT_CERULEAN_GYM_TRAINER_0", + [187] = "EVENT_BEAT_CERULEAN_GYM_TRAINER_1", + [190] = "EVENT_GOT_TM11", + [191] = "EVENT_BEAT_MISTY", + [192] = "EVENT_GOT_BICYCLE", + [238] = "EVENT_POKEMON_TOWER_RIVAL_ON_LEFT", + [239] = "EVENT_BEAT_POKEMON_TOWER_RIVAL", + [241] = "EVENT_BEAT_POKEMONTOWER_3_TRAINER_0", + [242] = "EVENT_BEAT_POKEMONTOWER_3_TRAINER_1", + [243] = "EVENT_BEAT_POKEMONTOWER_3_TRAINER_2", + [249] = "EVENT_BEAT_POKEMONTOWER_4_TRAINER_0", + [250] = "EVENT_BEAT_POKEMONTOWER_4_TRAINER_1", + [251] = "EVENT_BEAT_POKEMONTOWER_4_TRAINER_2", + [258] = "EVENT_BEAT_POKEMONTOWER_5_TRAINER_0", + [259] = "EVENT_BEAT_POKEMONTOWER_5_TRAINER_1", + [260] = "EVENT_BEAT_POKEMONTOWER_5_TRAINER_2", + [261] = "EVENT_BEAT_POKEMONTOWER_5_TRAINER_3", + [263] = "EVENT_IN_PURIFIED_ZONE", + [265] = "EVENT_BEAT_POKEMONTOWER_6_TRAINER_0", + [266] = "EVENT_BEAT_POKEMONTOWER_6_TRAINER_1", + [267] = "EVENT_BEAT_POKEMONTOWER_6_TRAINER_2", + [271] = "EVENT_BEAT_GHOST_MAROWAK", + [273] = "EVENT_BEAT_POKEMONTOWER_7_TRAINER_0", + [274] = "EVENT_BEAT_POKEMONTOWER_7_TRAINER_1", + [275] = "EVENT_BEAT_POKEMONTOWER_7_TRAINER_2", + [279] = "EVENT_RESCUED_MR_FUJI_2", + [296] = "EVENT_GOT_POKE_FLUTE", + [337] = "EVENT_GOT_BIKE_VOUCHER", + [342] = "EVENT_SEEL_FAN_BOAST", + [343] = "EVENT_PIKACHU_FAN_BOAST", + [352] = "EVENT_2ND_LOCK_OPENED", + [353] = "EVENT_1ST_LOCK_OPENED", + [354] = "EVENT_BEAT_VERMILION_GYM_TRAINER_0", + [355] = "EVENT_BEAT_VERMILION_GYM_TRAINER_1", + [356] = "EVENT_BEAT_VERMILION_GYM_TRAINER_2", + [358] = "EVENT_GOT_TM24", + [359] = "EVENT_BEAT_LT_SURGE", + [384] = "EVENT_GOT_TM41", + [396] = "EVENT_GOT_TM13", + [397] = "EVENT_GOT_TM48", + [398] = "EVENT_GOT_TM49", + [399] = "EVENT_GOT_TM18", + [424] = "EVENT_GOT_TM21", + [425] = "EVENT_BEAT_ERIKA", + [426] = "EVENT_BEAT_CELADON_GYM_TRAINER_0", + [427] = "EVENT_BEAT_CELADON_GYM_TRAINER_1", + [428] = "EVENT_BEAT_CELADON_GYM_TRAINER_2", + [429] = "EVENT_BEAT_CELADON_GYM_TRAINER_3", + [430] = "EVENT_BEAT_CELADON_GYM_TRAINER_4", + [431] = "EVENT_BEAT_CELADON_GYM_TRAINER_5", + [432] = "EVENT_BEAT_CELADON_GYM_TRAINER_6", + [440] = "EVENT_1B8", + [441] = "EVENT_FOUND_ROCKET_HIDEOUT", + [442] = "EVENT_GOT_10_COINS", + [443] = "EVENT_GOT_20_COINS", + [444] = "EVENT_GOT_20_COINS_2", + [447] = "EVENT_1BF", + [480] = "EVENT_GOT_COIN_CASE", + [568] = "EVENT_GOT_HM04", + [569] = "EVENT_GAVE_GOLD_TEETH", + [590] = "EVENT_SAFARI_GAME_OVER", + [591] = "EVENT_IN_SAFARI_ZONE", + [600] = "EVENT_GOT_TM06", + [601] = "EVENT_BEAT_KOGA", + [602] = "EVENT_BEAT_FUCHSIA_GYM_TRAINER_0", + [603] = "EVENT_BEAT_FUCHSIA_GYM_TRAINER_1", + [604] = "EVENT_BEAT_FUCHSIA_GYM_TRAINER_2", + [605] = "EVENT_BEAT_FUCHSIA_GYM_TRAINER_3", + [606] = "EVENT_BEAT_FUCHSIA_GYM_TRAINER_4", + [607] = "EVENT_BEAT_FUCHSIA_GYM_TRAINER_5", + [632] = "EVENT_MANSION_SWITCH_ON", + [649] = "EVENT_BEAT_MANSION_1_TRAINER_0", + [664] = "EVENT_GOT_TM38", + [665] = "EVENT_BEAT_BLAINE", + [666] = "EVENT_BEAT_CINNABAR_GYM_TRAINER_0", + [667] = "EVENT_BEAT_CINNABAR_GYM_TRAINER_1", + [668] = "EVENT_BEAT_CINNABAR_GYM_TRAINER_2", + [669] = "EVENT_BEAT_CINNABAR_GYM_TRAINER_3", + [670] = "EVENT_BEAT_CINNABAR_GYM_TRAINER_4", + [671] = "EVENT_BEAT_CINNABAR_GYM_TRAINER_5", + [672] = "EVENT_BEAT_CINNABAR_GYM_TRAINER_6", + [679] = "EVENT_2A7", + [680] = "EVENT_CINNABAR_GYM_GATE0_UNLOCKED", + [681] = "EVENT_CINNABAR_GYM_GATE1_UNLOCKED", + [682] = "EVENT_CINNABAR_GYM_GATE2_UNLOCKED", + [683] = "EVENT_CINNABAR_GYM_GATE3_UNLOCKED", + [684] = "EVENT_CINNABAR_GYM_GATE4_UNLOCKED", + [685] = "EVENT_CINNABAR_GYM_GATE5_UNLOCKED", + [686] = "EVENT_CINNABAR_GYM_GATE6_UNLOCKED", + [727] = "EVENT_GOT_TM35", + [736] = "EVENT_GAVE_FOSSIL_TO_LAB", + [737] = "EVENT_LAB_STILL_REVIVING_FOSSIL", + [738] = "EVENT_LAB_HANDING_OVER_FOSSIL_MON", + [832] = "EVENT_GOT_TM31", + [848] = "EVENT_DEFEATED_FIGHTING_DOJO", + [849] = "EVENT_BEAT_KARATE_MASTER", + [850] = "EVENT_BEAT_FIGHTING_DOJO_TRAINER_0", + [851] = "EVENT_BEAT_FIGHTING_DOJO_TRAINER_1", + [852] = "EVENT_BEAT_FIGHTING_DOJO_TRAINER_2", + [853] = "EVENT_BEAT_FIGHTING_DOJO_TRAINER_3", + [854] = "EVENT_GOT_HITMONLEE", + [855] = "EVENT_GOT_HITMONCHAN", + [864] = "EVENT_GOT_TM46", + [865] = "EVENT_BEAT_SABRINA", + [866] = "EVENT_BEAT_SAFFRON_GYM_TRAINER_0", + [867] = "EVENT_BEAT_SAFFRON_GYM_TRAINER_1", + [868] = "EVENT_BEAT_SAFFRON_GYM_TRAINER_2", + [869] = "EVENT_BEAT_SAFFRON_GYM_TRAINER_3", + [870] = "EVENT_BEAT_SAFFRON_GYM_TRAINER_4", + [871] = "EVENT_BEAT_SAFFRON_GYM_TRAINER_5", + [872] = "EVENT_BEAT_SAFFRON_GYM_TRAINER_6", + [919] = "EVENT_SILPH_CO_RECEPTIONIST_AT_DESK", + [944] = "EVENT_GOT_TM29", + [960] = "EVENT_GOT_POTION_SAMPLE", + [984] = "EVENT_GOT_HM05", + [994] = "EVENT_BEAT_ROUTE_3_TRAINER_0", + [995] = "EVENT_BEAT_ROUTE_3_TRAINER_1", + [996] = "EVENT_BEAT_ROUTE_3_TRAINER_2", + [997] = "EVENT_BEAT_ROUTE_3_TRAINER_3", + [998] = "EVENT_BEAT_ROUTE_3_TRAINER_4", + [999] = "EVENT_BEAT_ROUTE_3_TRAINER_5", + [1000] = "EVENT_BEAT_ROUTE_3_TRAINER_6", + [1001] = "EVENT_BEAT_ROUTE_3_TRAINER_7", + [1010] = "EVENT_BEAT_ROUTE_4_TRAINER_0", + [1023] = "EVENT_BOUGHT_MAGIKARP", + [1041] = "EVENT_BEAT_ROUTE_6_TRAINER_0", + [1042] = "EVENT_BEAT_ROUTE_6_TRAINER_1", + [1043] = "EVENT_BEAT_ROUTE_6_TRAINER_2", + [1044] = "EVENT_BEAT_ROUTE_6_TRAINER_3", + [1045] = "EVENT_BEAT_ROUTE_6_TRAINER_4", + [1046] = "EVENT_BEAT_ROUTE_6_TRAINER_5", + [1073] = "EVENT_BEAT_ROUTE_8_TRAINER_0", + [1074] = "EVENT_BEAT_ROUTE_8_TRAINER_1", + [1075] = "EVENT_BEAT_ROUTE_8_TRAINER_2", + [1076] = "EVENT_BEAT_ROUTE_8_TRAINER_3", + [1077] = "EVENT_BEAT_ROUTE_8_TRAINER_4", + [1078] = "EVENT_BEAT_ROUTE_8_TRAINER_5", + [1079] = "EVENT_BEAT_ROUTE_8_TRAINER_6", + [1080] = "EVENT_BEAT_ROUTE_8_TRAINER_7", + [1081] = "EVENT_BEAT_ROUTE_8_TRAINER_8", + [1089] = "EVENT_BEAT_ROUTE_9_TRAINER_0", + [1090] = "EVENT_BEAT_ROUTE_9_TRAINER_1", + [1091] = "EVENT_BEAT_ROUTE_9_TRAINER_2", + [1092] = "EVENT_BEAT_ROUTE_9_TRAINER_3", + [1093] = "EVENT_BEAT_ROUTE_9_TRAINER_4", + [1094] = "EVENT_BEAT_ROUTE_9_TRAINER_5", + [1095] = "EVENT_BEAT_ROUTE_9_TRAINER_6", + [1096] = "EVENT_BEAT_ROUTE_9_TRAINER_7", + [1097] = "EVENT_BEAT_ROUTE_9_TRAINER_8", + [1105] = "EVENT_BEAT_ROUTE_10_TRAINER_0", + [1106] = "EVENT_BEAT_ROUTE_10_TRAINER_1", + [1107] = "EVENT_BEAT_ROUTE_10_TRAINER_2", + [1108] = "EVENT_BEAT_ROUTE_10_TRAINER_3", + [1109] = "EVENT_BEAT_ROUTE_10_TRAINER_4", + [1110] = "EVENT_BEAT_ROUTE_10_TRAINER_5", + [1113] = "EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_0", + [1114] = "EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_1", + [1115] = "EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_2", + [1116] = "EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_3", + [1117] = "EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_4", + [1118] = "EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_5", + [1119] = "EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_6", + [1121] = "EVENT_BEAT_POWER_PLANT_VOLTORB_0", + [1122] = "EVENT_BEAT_POWER_PLANT_VOLTORB_1", + [1123] = "EVENT_BEAT_POWER_PLANT_VOLTORB_2", + [1124] = "EVENT_BEAT_POWER_PLANT_VOLTORB_3", + [1125] = "EVENT_BEAT_POWER_PLANT_VOLTORB_4", + [1126] = "EVENT_BEAT_POWER_PLANT_VOLTORB_5", + [1127] = "EVENT_BEAT_POWER_PLANT_VOLTORB_6", + [1128] = "EVENT_BEAT_POWER_PLANT_VOLTORB_7", + [1129] = "EVENT_BEAT_ZAPDOS", + [1137] = "EVENT_BEAT_ROUTE_11_TRAINER_0", + [1138] = "EVENT_BEAT_ROUTE_11_TRAINER_1", + [1139] = "EVENT_BEAT_ROUTE_11_TRAINER_2", + [1140] = "EVENT_BEAT_ROUTE_11_TRAINER_3", + [1141] = "EVENT_BEAT_ROUTE_11_TRAINER_4", + [1142] = "EVENT_BEAT_ROUTE_11_TRAINER_5", + [1143] = "EVENT_BEAT_ROUTE_11_TRAINER_6", + [1144] = "EVENT_BEAT_ROUTE_11_TRAINER_7", + [1145] = "EVENT_BEAT_ROUTE_11_TRAINER_8", + [1146] = "EVENT_BEAT_ROUTE_11_TRAINER_9", + [1151] = "EVENT_GOT_ITEMFINDER", + [1152] = "EVENT_GOT_TM39", + [1154] = "EVENT_BEAT_ROUTE_12_TRAINER_0", + [1155] = "EVENT_BEAT_ROUTE_12_TRAINER_1", + [1156] = "EVENT_BEAT_ROUTE_12_TRAINER_2", + [1157] = "EVENT_BEAT_ROUTE_12_TRAINER_3", + [1158] = "EVENT_BEAT_ROUTE_12_TRAINER_4", + [1159] = "EVENT_BEAT_ROUTE_12_TRAINER_5", + [1160] = "EVENT_BEAT_ROUTE_12_TRAINER_6", + [1166] = "EVENT_FIGHT_ROUTE12_SNORLAX", + [1167] = "EVENT_BEAT_ROUTE12_SNORLAX", + [1169] = "EVENT_BEAT_ROUTE_13_TRAINER_0", + [1170] = "EVENT_BEAT_ROUTE_13_TRAINER_1", + [1171] = "EVENT_BEAT_ROUTE_13_TRAINER_2", + [1172] = "EVENT_BEAT_ROUTE_13_TRAINER_3", + [1173] = "EVENT_BEAT_ROUTE_13_TRAINER_4", + [1174] = "EVENT_BEAT_ROUTE_13_TRAINER_5", + [1175] = "EVENT_BEAT_ROUTE_13_TRAINER_6", + [1176] = "EVENT_BEAT_ROUTE_13_TRAINER_7", + [1177] = "EVENT_BEAT_ROUTE_13_TRAINER_8", + [1178] = "EVENT_BEAT_ROUTE_13_TRAINER_9", + [1185] = "EVENT_BEAT_ROUTE_14_TRAINER_0", + [1186] = "EVENT_BEAT_ROUTE_14_TRAINER_1", + [1187] = "EVENT_BEAT_ROUTE_14_TRAINER_2", + [1188] = "EVENT_BEAT_ROUTE_14_TRAINER_3", + [1189] = "EVENT_BEAT_ROUTE_14_TRAINER_4", + [1190] = "EVENT_BEAT_ROUTE_14_TRAINER_5", + [1191] = "EVENT_BEAT_ROUTE_14_TRAINER_6", + [1192] = "EVENT_BEAT_ROUTE_14_TRAINER_7", + [1193] = "EVENT_BEAT_ROUTE_14_TRAINER_8", + [1194] = "EVENT_BEAT_ROUTE_14_TRAINER_9", + [1200] = "EVENT_GOT_EXP_ALL", + [1201] = "EVENT_BEAT_ROUTE_15_TRAINER_0", + [1202] = "EVENT_BEAT_ROUTE_15_TRAINER_1", + [1203] = "EVENT_BEAT_ROUTE_15_TRAINER_2", + [1204] = "EVENT_BEAT_ROUTE_15_TRAINER_3", + [1205] = "EVENT_BEAT_ROUTE_15_TRAINER_4", + [1206] = "EVENT_BEAT_ROUTE_15_TRAINER_5", + [1207] = "EVENT_BEAT_ROUTE_15_TRAINER_6", + [1208] = "EVENT_BEAT_ROUTE_15_TRAINER_7", + [1209] = "EVENT_BEAT_ROUTE_15_TRAINER_8", + [1210] = "EVENT_BEAT_ROUTE_15_TRAINER_9", + [1217] = "EVENT_BEAT_ROUTE_16_TRAINER_0", + [1218] = "EVENT_BEAT_ROUTE_16_TRAINER_1", + [1219] = "EVENT_BEAT_ROUTE_16_TRAINER_2", + [1220] = "EVENT_BEAT_ROUTE_16_TRAINER_3", + [1221] = "EVENT_BEAT_ROUTE_16_TRAINER_4", + [1222] = "EVENT_BEAT_ROUTE_16_TRAINER_5", + [1224] = "EVENT_FIGHT_ROUTE16_SNORLAX", + [1225] = "EVENT_BEAT_ROUTE16_SNORLAX", + [1230] = "EVENT_GOT_HM02", + [1231] = "EVENT_RESCUED_MR_FUJI", + [1233] = "EVENT_BEAT_ROUTE_17_TRAINER_0", + [1234] = "EVENT_BEAT_ROUTE_17_TRAINER_1", + [1235] = "EVENT_BEAT_ROUTE_17_TRAINER_2", + [1236] = "EVENT_BEAT_ROUTE_17_TRAINER_3", + [1237] = "EVENT_BEAT_ROUTE_17_TRAINER_4", + [1238] = "EVENT_BEAT_ROUTE_17_TRAINER_5", + [1239] = "EVENT_BEAT_ROUTE_17_TRAINER_6", + [1240] = "EVENT_BEAT_ROUTE_17_TRAINER_7", + [1241] = "EVENT_BEAT_ROUTE_17_TRAINER_8", + [1242] = "EVENT_BEAT_ROUTE_17_TRAINER_9", + [1249] = "EVENT_BEAT_ROUTE_18_TRAINER_0", + [1250] = "EVENT_BEAT_ROUTE_18_TRAINER_1", + [1251] = "EVENT_BEAT_ROUTE_18_TRAINER_2", + [1265] = "EVENT_BEAT_ROUTE_19_TRAINER_0", + [1266] = "EVENT_BEAT_ROUTE_19_TRAINER_1", + [1267] = "EVENT_BEAT_ROUTE_19_TRAINER_2", + [1268] = "EVENT_BEAT_ROUTE_19_TRAINER_3", + [1269] = "EVENT_BEAT_ROUTE_19_TRAINER_4", + [1270] = "EVENT_BEAT_ROUTE_19_TRAINER_5", + [1271] = "EVENT_BEAT_ROUTE_19_TRAINER_6", + [1272] = "EVENT_BEAT_ROUTE_19_TRAINER_7", + [1273] = "EVENT_BEAT_ROUTE_19_TRAINER_8", + [1274] = "EVENT_BEAT_ROUTE_19_TRAINER_9", + [1280] = "EVENT_IN_SEAFOAM_ISLANDS", + [1281] = "EVENT_BEAT_ROUTE_20_TRAINER_0", + [1282] = "EVENT_BEAT_ROUTE_20_TRAINER_1", + [1283] = "EVENT_BEAT_ROUTE_20_TRAINER_2", + [1284] = "EVENT_BEAT_ROUTE_20_TRAINER_3", + [1285] = "EVENT_BEAT_ROUTE_20_TRAINER_4", + [1286] = "EVENT_BEAT_ROUTE_20_TRAINER_5", + [1287] = "EVENT_BEAT_ROUTE_20_TRAINER_6", + [1288] = "EVENT_BEAT_ROUTE_20_TRAINER_7", + [1289] = "EVENT_BEAT_ROUTE_20_TRAINER_8", + [1290] = "EVENT_BEAT_ROUTE_20_TRAINER_9", + [1294] = "EVENT_SEAFOAM1_BOULDER1_DOWN_HOLE", + [1295] = "EVENT_SEAFOAM1_BOULDER2_DOWN_HOLE", + [1297] = "EVENT_BEAT_ROUTE_21_TRAINER_0", + [1298] = "EVENT_BEAT_ROUTE_21_TRAINER_1", + [1299] = "EVENT_BEAT_ROUTE_21_TRAINER_2", + [1300] = "EVENT_BEAT_ROUTE_21_TRAINER_3", + [1301] = "EVENT_BEAT_ROUTE_21_TRAINER_4", + [1302] = "EVENT_BEAT_ROUTE_21_TRAINER_5", + [1303] = "EVENT_BEAT_ROUTE_21_TRAINER_6", + [1304] = "EVENT_BEAT_ROUTE_21_TRAINER_7", + [1305] = "EVENT_BEAT_ROUTE_21_TRAINER_8", + [1312] = "EVENT_1ST_ROUTE22_RIVAL_BATTLE", + [1313] = "EVENT_2ND_ROUTE22_RIVAL_BATTLE", + [1317] = "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE", + [1318] = "EVENT_BEAT_ROUTE22_RIVAL_2ND_BATTLE", + [1319] = "EVENT_ROUTE22_RIVAL_WANTS_BATTLE", + [1328] = "EVENT_PASSED_CASCADEBADGE_CHECK", + [1329] = "EVENT_PASSED_THUNDERBADGE_CHECK", + [1330] = "EVENT_PASSED_RAINBOWBADGE_CHECK", + [1331] = "EVENT_PASSED_SOULBADGE_CHECK", + [1332] = "EVENT_PASSED_MARSHBADGE_CHECK", + [1333] = "EVENT_PASSED_VOLCANOBADGE_CHECK", + [1334] = "EVENT_PASSED_EARTHBADGE_CHECK", + [1336] = "EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH1", + [1337] = "EVENT_BEAT_VICTORY_ROAD_2_TRAINER_0", + [1338] = "EVENT_BEAT_VICTORY_ROAD_2_TRAINER_1", + [1339] = "EVENT_BEAT_VICTORY_ROAD_2_TRAINER_2", + [1340] = "EVENT_BEAT_VICTORY_ROAD_2_TRAINER_3", + [1341] = "EVENT_BEAT_VICTORY_ROAD_2_TRAINER_4", + [1342] = "EVENT_BEAT_MOLTRES", + [1343] = "EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH2", + [1344] = "EVENT_GOT_NUGGET", + [1345] = "EVENT_BEAT_ROUTE24_ROCKET", + [1346] = "EVENT_BEAT_ROUTE_24_TRAINER_0", + [1347] = "EVENT_BEAT_ROUTE_24_TRAINER_1", + [1348] = "EVENT_BEAT_ROUTE_24_TRAINER_2", + [1349] = "EVENT_BEAT_ROUTE_24_TRAINER_3", + [1350] = "EVENT_BEAT_ROUTE_24_TRAINER_4", + [1351] = "EVENT_BEAT_ROUTE_24_TRAINER_5", + [1353] = "EVENT_NUGGET_REWARD_AVAILABLE", + [1360] = "EVENT_MET_BILL", + [1361] = "EVENT_BEAT_ROUTE_25_TRAINER_0", + [1362] = "EVENT_BEAT_ROUTE_25_TRAINER_1", + [1363] = "EVENT_BEAT_ROUTE_25_TRAINER_2", + [1364] = "EVENT_BEAT_ROUTE_25_TRAINER_3", + [1365] = "EVENT_BEAT_ROUTE_25_TRAINER_4", + [1366] = "EVENT_BEAT_ROUTE_25_TRAINER_5", + [1367] = "EVENT_BEAT_ROUTE_25_TRAINER_6", + [1368] = "EVENT_BEAT_ROUTE_25_TRAINER_7", + [1369] = "EVENT_BEAT_ROUTE_25_TRAINER_8", + [1371] = "EVENT_USED_CELL_SEPARATOR_ON_BILL", + [1372] = "EVENT_GOT_SS_TICKET", + [1373] = "EVENT_MET_BILL_2", + [1374] = "EVENT_BILL_SAID_USE_CELL_SEPARATOR", + [1375] = "EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING", + [1378] = "EVENT_BEAT_VIRIDIAN_FOREST_TRAINER_0", + [1379] = "EVENT_BEAT_VIRIDIAN_FOREST_TRAINER_1", + [1380] = "EVENT_BEAT_VIRIDIAN_FOREST_TRAINER_2", + [1393] = "EVENT_BEAT_MT_MOON_1_TRAINER_0", + [1394] = "EVENT_BEAT_MT_MOON_1_TRAINER_1", + [1395] = "EVENT_BEAT_MT_MOON_1_TRAINER_2", + [1396] = "EVENT_BEAT_MT_MOON_1_TRAINER_3", + [1397] = "EVENT_BEAT_MT_MOON_1_TRAINER_4", + [1398] = "EVENT_BEAT_MT_MOON_1_TRAINER_5", + [1399] = "EVENT_BEAT_MT_MOON_1_TRAINER_6", + [1401] = "EVENT_BEAT_MT_MOON_EXIT_SUPER_NERD", + [1402] = "EVENT_BEAT_MT_MOON_3_TRAINER_0", + [1403] = "EVENT_BEAT_MT_MOON_3_TRAINER_1", + [1404] = "EVENT_BEAT_MT_MOON_3_TRAINER_2", + [1405] = "EVENT_BEAT_MT_MOON_3_TRAINER_3", + [1406] = "EVENT_GOT_DOME_FOSSIL", + [1407] = "EVENT_GOT_HELIX_FOSSIL", + [1476] = "EVENT_BEAT_SS_ANNE_5_TRAINER_0", + [1477] = "EVENT_BEAT_SS_ANNE_5_TRAINER_1", + [1504] = "EVENT_GOT_HM01", + [1505] = "EVENT_RUBBED_CAPTAINS_BACK", + [1506] = "EVENT_SS_ANNE_LEFT", + [1507] = "EVENT_WALKED_PAST_GUARD_AFTER_SS_ANNE_LEFT", + [1508] = "EVENT_STARTED_WALKING_OUT_OF_DOCK", + [1509] = "EVENT_WALKED_OUT_OF_DOCK", + [1521] = "EVENT_BEAT_SS_ANNE_8_TRAINER_0", + [1522] = "EVENT_BEAT_SS_ANNE_8_TRAINER_1", + [1523] = "EVENT_BEAT_SS_ANNE_8_TRAINER_2", + [1524] = "EVENT_BEAT_SS_ANNE_8_TRAINER_3", + [1537] = "EVENT_BEAT_SS_ANNE_9_TRAINER_0", + [1538] = "EVENT_BEAT_SS_ANNE_9_TRAINER_1", + [1539] = "EVENT_BEAT_SS_ANNE_9_TRAINER_2", + [1540] = "EVENT_BEAT_SS_ANNE_9_TRAINER_3", + [1553] = "EVENT_BEAT_SS_ANNE_10_TRAINER_0", + [1554] = "EVENT_BEAT_SS_ANNE_10_TRAINER_1", + [1555] = "EVENT_BEAT_SS_ANNE_10_TRAINER_2", + [1556] = "EVENT_BEAT_SS_ANNE_10_TRAINER_3", + [1557] = "EVENT_BEAT_SS_ANNE_10_TRAINER_4", + [1558] = "EVENT_BEAT_SS_ANNE_10_TRAINER_5", + [1632] = "EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH1", + [1633] = "EVENT_BEAT_VICTORY_ROAD_3_TRAINER_0", + [1634] = "EVENT_BEAT_VICTORY_ROAD_3_TRAINER_1", + [1635] = "EVENT_BEAT_VICTORY_ROAD_3_TRAINER_2", + [1636] = "EVENT_BEAT_VICTORY_ROAD_3_TRAINER_3", + [1638] = "EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH2", + [1649] = "EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_0", + [1650] = "EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_1", + [1651] = "EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_2", + [1652] = "EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_3", + [1653] = "EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_4", + [1655] = "EVENT_ENTERED_ROCKET_HIDEOUT", + [1663] = "EVENT_67F", + [1665] = "EVENT_BEAT_ROCKET_HIDEOUT_2_TRAINER_0", + [1681] = "EVENT_BEAT_ROCKET_HIDEOUT_3_TRAINER_0", + [1682] = "EVENT_BEAT_ROCKET_HIDEOUT_3_TRAINER_1", + [1698] = "EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_0", + [1699] = "EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_1", + [1700] = "EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_2", + [1701] = "EVENT_ROCKET_HIDEOUT_4_DOOR_UNLOCKED", + [1702] = "EVENT_ROCKET_DROPPED_LIFT_KEY", + [1703] = "EVENT_BEAT_ROCKET_HIDEOUT_GIOVANNI", + [1778] = "EVENT_BEAT_SILPH_CO_2F_TRAINER_0", + [1779] = "EVENT_BEAT_SILPH_CO_2F_TRAINER_1", + [1780] = "EVENT_BEAT_SILPH_CO_2F_TRAINER_2", + [1781] = "EVENT_BEAT_SILPH_CO_2F_TRAINER_3", + [1789] = "EVENT_SILPH_CO_2_UNLOCKED_DOOR1", + [1790] = "EVENT_SILPH_CO_2_UNLOCKED_DOOR2", + [1791] = "EVENT_GOT_TM36", + [1794] = "EVENT_BEAT_SILPH_CO_3F_TRAINER_0", + [1795] = "EVENT_BEAT_SILPH_CO_3F_TRAINER_1", + [1800] = "EVENT_SILPH_CO_3_UNLOCKED_DOOR1", + [1801] = "EVENT_SILPH_CO_3_UNLOCKED_DOOR2", + [1810] = "EVENT_BEAT_SILPH_CO_4F_TRAINER_0", + [1811] = "EVENT_BEAT_SILPH_CO_4F_TRAINER_1", + [1812] = "EVENT_BEAT_SILPH_CO_4F_TRAINER_2", + [1816] = "EVENT_SILPH_CO_4_UNLOCKED_DOOR1", + [1817] = "EVENT_SILPH_CO_4_UNLOCKED_DOOR2", + [1826] = "EVENT_BEAT_SILPH_CO_5F_TRAINER_0", + [1827] = "EVENT_BEAT_SILPH_CO_5F_TRAINER_1", + [1828] = "EVENT_BEAT_SILPH_CO_5F_TRAINER_2", + [1829] = "EVENT_BEAT_SILPH_CO_5F_TRAINER_3", + [1832] = "EVENT_SILPH_CO_5_UNLOCKED_DOOR1", + [1833] = "EVENT_SILPH_CO_5_UNLOCKED_DOOR2", + [1834] = "EVENT_SILPH_CO_5_UNLOCKED_DOOR3", + [1846] = "EVENT_BEAT_SILPH_CO_6F_TRAINER_0", + [1847] = "EVENT_BEAT_SILPH_CO_6F_TRAINER_1", + [1848] = "EVENT_BEAT_SILPH_CO_6F_TRAINER_2", + [1855] = "EVENT_SILPH_CO_6_UNLOCKED_DOOR", + [1856] = "EVENT_BEAT_SILPH_CO_RIVAL", + [1861] = "EVENT_BEAT_SILPH_CO_7F_TRAINER_0", + [1862] = "EVENT_BEAT_SILPH_CO_7F_TRAINER_1", + [1863] = "EVENT_BEAT_SILPH_CO_7F_TRAINER_2", + [1864] = "EVENT_BEAT_SILPH_CO_7F_TRAINER_3", + [1868] = "EVENT_SILPH_CO_7_UNLOCKED_DOOR1", + [1869] = "EVENT_SILPH_CO_7_UNLOCKED_DOOR2", + [1870] = "EVENT_SILPH_CO_7_UNLOCKED_DOOR3", + [1874] = "EVENT_BEAT_SILPH_CO_8F_TRAINER_0", + [1875] = "EVENT_BEAT_SILPH_CO_8F_TRAINER_1", + [1876] = "EVENT_BEAT_SILPH_CO_8F_TRAINER_2", + [1880] = "EVENT_SILPH_CO_8_UNLOCKED_DOOR", + [1890] = "EVENT_BEAT_SILPH_CO_9F_TRAINER_0", + [1891] = "EVENT_BEAT_SILPH_CO_9F_TRAINER_1", + [1892] = "EVENT_BEAT_SILPH_CO_9F_TRAINER_2", + [1896] = "EVENT_SILPH_CO_9_UNLOCKED_DOOR1", + [1897] = "EVENT_SILPH_CO_9_UNLOCKED_DOOR2", + [1898] = "EVENT_SILPH_CO_9_UNLOCKED_DOOR3", + [1899] = "EVENT_SILPH_CO_9_UNLOCKED_DOOR4", + [1905] = "EVENT_BEAT_SILPH_CO_10F_TRAINER_0", + [1906] = "EVENT_BEAT_SILPH_CO_10F_TRAINER_1", + [1912] = "EVENT_SILPH_CO_10_UNLOCKED_DOOR", + [1924] = "EVENT_BEAT_SILPH_CO_11F_TRAINER_0", + [1925] = "EVENT_BEAT_SILPH_CO_11F_TRAINER_1", + [1928] = "EVENT_SILPH_CO_11_UNLOCKED_DOOR", + [1933] = "EVENT_GOT_MASTER_BALL", + [1935] = "EVENT_BEAT_SILPH_CO_GIOVANNI", + [2049] = "EVENT_BEAT_MANSION_2_TRAINER_0", + [2065] = "EVENT_BEAT_MANSION_3_TRAINER_0", + [2066] = "EVENT_BEAT_MANSION_3_TRAINER_1", + [2081] = "EVENT_BEAT_MANSION_4_TRAINER_0", + [2082] = "EVENT_BEAT_MANSION_4_TRAINER_1", + [2176] = "EVENT_GOT_HM03", + [2241] = "EVENT_BEAT_MEWTWO", + [2273] = "EVENT_BEAT_LORELEIS_ROOM_TRAINER_0", + [2278] = "EVENT_AUTOWALKED_INTO_LORELEIS_ROOM", + [2281] = "EVENT_BEAT_BRUNOS_ROOM_TRAINER_0", + [2286] = "EVENT_AUTOWALKED_INTO_BRUNOS_ROOM", + [2289] = "EVENT_BEAT_AGATHAS_ROOM_TRAINER_0", + [2294] = "EVENT_AUTOWALKED_INTO_AGATHAS_ROOM", + [2297] = "EVENT_BEAT_LANCES_ROOM_TRAINER_0", + [2302] = "EVENT_BEAT_LANCE", + [2303] = "EVENT_LANCES_ROOM_LOCK_DOOR", + [2305] = "EVENT_BEAT_CHAMPION_RIVAL", + [2321] = "EVENT_BEAT_VICTORY_ROAD_1_TRAINER_0", + [2322] = "EVENT_BEAT_VICTORY_ROAD_1_TRAINER_1", + [2327] = "EVENT_VICTORY_ROAD_1_BOULDER_ON_SWITCH", + [2481] = "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_0", + [2482] = "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_1", + [2483] = "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_2", + [2484] = "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_3", + [2485] = "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_4", + [2486] = "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_5", + [2487] = "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_6", + [2488] = "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_7", + [2496] = "EVENT_SEAFOAM2_BOULDER1_DOWN_HOLE", + [2497] = "EVENT_SEAFOAM2_BOULDER2_DOWN_HOLE", + [2504] = "EVENT_SEAFOAM3_BOULDER1_DOWN_HOLE", + [2505] = "EVENT_SEAFOAM3_BOULDER2_DOWN_HOLE", + [2512] = "EVENT_SEAFOAM4_BOULDER1_DOWN_HOLE", + [2513] = "EVENT_SEAFOAM4_BOULDER2_DOWN_HOLE", + [2522] = "EVENT_BEAT_ARTICUNO", + }, + byName = { + EVENT_1B8 = 440, + EVENT_1BF = 447, + EVENT_1ST_LOCK_OPENED = 353, + EVENT_1ST_ROUTE22_RIVAL_BATTLE = 1312, + EVENT_2A7 = 679, + EVENT_2ND_LOCK_OPENED = 352, + EVENT_2ND_ROUTE22_RIVAL_BATTLE = 1313, + EVENT_67F = 1663, + EVENT_AUTOWALKED_INTO_AGATHAS_ROOM = 2294, + EVENT_AUTOWALKED_INTO_BRUNOS_ROOM = 2286, + EVENT_AUTOWALKED_INTO_LORELEIS_ROOM = 2278, + EVENT_BATTLED_RIVAL_IN_OAKS_LAB = 35, + EVENT_BEAT_AGATHAS_ROOM_TRAINER_0 = 2289, + EVENT_BEAT_ARTICUNO = 2522, + EVENT_BEAT_BLAINE = 665, + EVENT_BEAT_BROCK = 119, + EVENT_BEAT_BRUNOS_ROOM_TRAINER_0 = 2281, + EVENT_BEAT_CELADON_GYM_TRAINER_0 = 426, + EVENT_BEAT_CELADON_GYM_TRAINER_1 = 427, + EVENT_BEAT_CELADON_GYM_TRAINER_2 = 428, + EVENT_BEAT_CELADON_GYM_TRAINER_3 = 429, + EVENT_BEAT_CELADON_GYM_TRAINER_4 = 430, + EVENT_BEAT_CELADON_GYM_TRAINER_5 = 431, + EVENT_BEAT_CELADON_GYM_TRAINER_6 = 432, + EVENT_BEAT_CERULEAN_GYM_TRAINER_0 = 186, + EVENT_BEAT_CERULEAN_GYM_TRAINER_1 = 187, + EVENT_BEAT_CERULEAN_RIVAL = 152, + EVENT_BEAT_CERULEAN_ROCKET_THIEF = 167, + EVENT_BEAT_CHAMPION_RIVAL = 2305, + EVENT_BEAT_CINNABAR_GYM_TRAINER_0 = 666, + EVENT_BEAT_CINNABAR_GYM_TRAINER_1 = 667, + EVENT_BEAT_CINNABAR_GYM_TRAINER_2 = 668, + EVENT_BEAT_CINNABAR_GYM_TRAINER_3 = 669, + EVENT_BEAT_CINNABAR_GYM_TRAINER_4 = 670, + EVENT_BEAT_CINNABAR_GYM_TRAINER_5 = 671, + EVENT_BEAT_CINNABAR_GYM_TRAINER_6 = 672, + EVENT_BEAT_ERIKA = 425, + EVENT_BEAT_FIGHTING_DOJO_TRAINER_0 = 850, + EVENT_BEAT_FIGHTING_DOJO_TRAINER_1 = 851, + EVENT_BEAT_FIGHTING_DOJO_TRAINER_2 = 852, + EVENT_BEAT_FIGHTING_DOJO_TRAINER_3 = 853, + EVENT_BEAT_FUCHSIA_GYM_TRAINER_0 = 602, + EVENT_BEAT_FUCHSIA_GYM_TRAINER_1 = 603, + EVENT_BEAT_FUCHSIA_GYM_TRAINER_2 = 604, + EVENT_BEAT_FUCHSIA_GYM_TRAINER_3 = 605, + EVENT_BEAT_FUCHSIA_GYM_TRAINER_4 = 606, + EVENT_BEAT_FUCHSIA_GYM_TRAINER_5 = 607, + EVENT_BEAT_GHOST_MAROWAK = 271, + EVENT_BEAT_KARATE_MASTER = 849, + EVENT_BEAT_KOGA = 601, + EVENT_BEAT_LANCE = 2302, + EVENT_BEAT_LANCES_ROOM_TRAINER_0 = 2297, + EVENT_BEAT_LORELEIS_ROOM_TRAINER_0 = 2273, + EVENT_BEAT_LT_SURGE = 359, + EVENT_BEAT_MANSION_1_TRAINER_0 = 649, + EVENT_BEAT_MANSION_2_TRAINER_0 = 2049, + EVENT_BEAT_MANSION_3_TRAINER_0 = 2065, + EVENT_BEAT_MANSION_3_TRAINER_1 = 2066, + EVENT_BEAT_MANSION_4_TRAINER_0 = 2081, + EVENT_BEAT_MANSION_4_TRAINER_1 = 2082, + EVENT_BEAT_MEWTWO = 2241, + EVENT_BEAT_MISTY = 191, + EVENT_BEAT_MOLTRES = 1342, + EVENT_BEAT_MT_MOON_1_TRAINER_0 = 1393, + EVENT_BEAT_MT_MOON_1_TRAINER_1 = 1394, + EVENT_BEAT_MT_MOON_1_TRAINER_2 = 1395, + EVENT_BEAT_MT_MOON_1_TRAINER_3 = 1396, + EVENT_BEAT_MT_MOON_1_TRAINER_4 = 1397, + EVENT_BEAT_MT_MOON_1_TRAINER_5 = 1398, + EVENT_BEAT_MT_MOON_1_TRAINER_6 = 1399, + EVENT_BEAT_MT_MOON_3_TRAINER_0 = 1402, + EVENT_BEAT_MT_MOON_3_TRAINER_1 = 1403, + EVENT_BEAT_MT_MOON_3_TRAINER_2 = 1404, + EVENT_BEAT_MT_MOON_3_TRAINER_3 = 1405, + EVENT_BEAT_MT_MOON_EXIT_SUPER_NERD = 1401, + EVENT_BEAT_PEWTER_GYM_TRAINER_0 = 114, + EVENT_BEAT_POKEMONTOWER_3_TRAINER_0 = 241, + EVENT_BEAT_POKEMONTOWER_3_TRAINER_1 = 242, + EVENT_BEAT_POKEMONTOWER_3_TRAINER_2 = 243, + EVENT_BEAT_POKEMONTOWER_4_TRAINER_0 = 249, + EVENT_BEAT_POKEMONTOWER_4_TRAINER_1 = 250, + EVENT_BEAT_POKEMONTOWER_4_TRAINER_2 = 251, + EVENT_BEAT_POKEMONTOWER_5_TRAINER_0 = 258, + EVENT_BEAT_POKEMONTOWER_5_TRAINER_1 = 259, + EVENT_BEAT_POKEMONTOWER_5_TRAINER_2 = 260, + EVENT_BEAT_POKEMONTOWER_5_TRAINER_3 = 261, + EVENT_BEAT_POKEMONTOWER_6_TRAINER_0 = 265, + EVENT_BEAT_POKEMONTOWER_6_TRAINER_1 = 266, + EVENT_BEAT_POKEMONTOWER_6_TRAINER_2 = 267, + EVENT_BEAT_POKEMONTOWER_7_TRAINER_0 = 273, + EVENT_BEAT_POKEMONTOWER_7_TRAINER_1 = 274, + EVENT_BEAT_POKEMONTOWER_7_TRAINER_2 = 275, + EVENT_BEAT_POKEMON_TOWER_RIVAL = 239, + EVENT_BEAT_POWER_PLANT_VOLTORB_0 = 1121, + EVENT_BEAT_POWER_PLANT_VOLTORB_1 = 1122, + EVENT_BEAT_POWER_PLANT_VOLTORB_2 = 1123, + EVENT_BEAT_POWER_PLANT_VOLTORB_3 = 1124, + EVENT_BEAT_POWER_PLANT_VOLTORB_4 = 1125, + EVENT_BEAT_POWER_PLANT_VOLTORB_5 = 1126, + EVENT_BEAT_POWER_PLANT_VOLTORB_6 = 1127, + EVENT_BEAT_POWER_PLANT_VOLTORB_7 = 1128, + EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_0 = 1649, + EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_1 = 1650, + EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_2 = 1651, + EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_3 = 1652, + EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_4 = 1653, + EVENT_BEAT_ROCKET_HIDEOUT_2_TRAINER_0 = 1665, + EVENT_BEAT_ROCKET_HIDEOUT_3_TRAINER_0 = 1681, + EVENT_BEAT_ROCKET_HIDEOUT_3_TRAINER_1 = 1682, + EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_0 = 1698, + EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_1 = 1699, + EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_2 = 1700, + EVENT_BEAT_ROCKET_HIDEOUT_GIOVANNI = 1703, + EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_0 = 1113, + EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_1 = 1114, + EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_2 = 1115, + EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_3 = 1116, + EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_4 = 1117, + EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_5 = 1118, + EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_6 = 1119, + EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_0 = 2481, + EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_1 = 2482, + EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_2 = 2483, + EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_3 = 2484, + EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_4 = 2485, + EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_5 = 2486, + EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_6 = 2487, + EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_7 = 2488, + EVENT_BEAT_ROUTE12_SNORLAX = 1167, + EVENT_BEAT_ROUTE16_SNORLAX = 1225, + EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE = 1317, + EVENT_BEAT_ROUTE22_RIVAL_2ND_BATTLE = 1318, + EVENT_BEAT_ROUTE24_ROCKET = 1345, + EVENT_BEAT_ROUTE_10_TRAINER_0 = 1105, + EVENT_BEAT_ROUTE_10_TRAINER_1 = 1106, + EVENT_BEAT_ROUTE_10_TRAINER_2 = 1107, + EVENT_BEAT_ROUTE_10_TRAINER_3 = 1108, + EVENT_BEAT_ROUTE_10_TRAINER_4 = 1109, + EVENT_BEAT_ROUTE_10_TRAINER_5 = 1110, + EVENT_BEAT_ROUTE_11_TRAINER_0 = 1137, + EVENT_BEAT_ROUTE_11_TRAINER_1 = 1138, + EVENT_BEAT_ROUTE_11_TRAINER_2 = 1139, + EVENT_BEAT_ROUTE_11_TRAINER_3 = 1140, + EVENT_BEAT_ROUTE_11_TRAINER_4 = 1141, + EVENT_BEAT_ROUTE_11_TRAINER_5 = 1142, + EVENT_BEAT_ROUTE_11_TRAINER_6 = 1143, + EVENT_BEAT_ROUTE_11_TRAINER_7 = 1144, + EVENT_BEAT_ROUTE_11_TRAINER_8 = 1145, + EVENT_BEAT_ROUTE_11_TRAINER_9 = 1146, + EVENT_BEAT_ROUTE_12_TRAINER_0 = 1154, + EVENT_BEAT_ROUTE_12_TRAINER_1 = 1155, + EVENT_BEAT_ROUTE_12_TRAINER_2 = 1156, + EVENT_BEAT_ROUTE_12_TRAINER_3 = 1157, + EVENT_BEAT_ROUTE_12_TRAINER_4 = 1158, + EVENT_BEAT_ROUTE_12_TRAINER_5 = 1159, + EVENT_BEAT_ROUTE_12_TRAINER_6 = 1160, + EVENT_BEAT_ROUTE_13_TRAINER_0 = 1169, + EVENT_BEAT_ROUTE_13_TRAINER_1 = 1170, + EVENT_BEAT_ROUTE_13_TRAINER_2 = 1171, + EVENT_BEAT_ROUTE_13_TRAINER_3 = 1172, + EVENT_BEAT_ROUTE_13_TRAINER_4 = 1173, + EVENT_BEAT_ROUTE_13_TRAINER_5 = 1174, + EVENT_BEAT_ROUTE_13_TRAINER_6 = 1175, + EVENT_BEAT_ROUTE_13_TRAINER_7 = 1176, + EVENT_BEAT_ROUTE_13_TRAINER_8 = 1177, + EVENT_BEAT_ROUTE_13_TRAINER_9 = 1178, + EVENT_BEAT_ROUTE_14_TRAINER_0 = 1185, + EVENT_BEAT_ROUTE_14_TRAINER_1 = 1186, + EVENT_BEAT_ROUTE_14_TRAINER_2 = 1187, + EVENT_BEAT_ROUTE_14_TRAINER_3 = 1188, + EVENT_BEAT_ROUTE_14_TRAINER_4 = 1189, + EVENT_BEAT_ROUTE_14_TRAINER_5 = 1190, + EVENT_BEAT_ROUTE_14_TRAINER_6 = 1191, + EVENT_BEAT_ROUTE_14_TRAINER_7 = 1192, + EVENT_BEAT_ROUTE_14_TRAINER_8 = 1193, + EVENT_BEAT_ROUTE_14_TRAINER_9 = 1194, + EVENT_BEAT_ROUTE_15_TRAINER_0 = 1201, + EVENT_BEAT_ROUTE_15_TRAINER_1 = 1202, + EVENT_BEAT_ROUTE_15_TRAINER_2 = 1203, + EVENT_BEAT_ROUTE_15_TRAINER_3 = 1204, + EVENT_BEAT_ROUTE_15_TRAINER_4 = 1205, + EVENT_BEAT_ROUTE_15_TRAINER_5 = 1206, + EVENT_BEAT_ROUTE_15_TRAINER_6 = 1207, + EVENT_BEAT_ROUTE_15_TRAINER_7 = 1208, + EVENT_BEAT_ROUTE_15_TRAINER_8 = 1209, + EVENT_BEAT_ROUTE_15_TRAINER_9 = 1210, + EVENT_BEAT_ROUTE_16_TRAINER_0 = 1217, + EVENT_BEAT_ROUTE_16_TRAINER_1 = 1218, + EVENT_BEAT_ROUTE_16_TRAINER_2 = 1219, + EVENT_BEAT_ROUTE_16_TRAINER_3 = 1220, + EVENT_BEAT_ROUTE_16_TRAINER_4 = 1221, + EVENT_BEAT_ROUTE_16_TRAINER_5 = 1222, + EVENT_BEAT_ROUTE_17_TRAINER_0 = 1233, + EVENT_BEAT_ROUTE_17_TRAINER_1 = 1234, + EVENT_BEAT_ROUTE_17_TRAINER_2 = 1235, + EVENT_BEAT_ROUTE_17_TRAINER_3 = 1236, + EVENT_BEAT_ROUTE_17_TRAINER_4 = 1237, + EVENT_BEAT_ROUTE_17_TRAINER_5 = 1238, + EVENT_BEAT_ROUTE_17_TRAINER_6 = 1239, + EVENT_BEAT_ROUTE_17_TRAINER_7 = 1240, + EVENT_BEAT_ROUTE_17_TRAINER_8 = 1241, + EVENT_BEAT_ROUTE_17_TRAINER_9 = 1242, + EVENT_BEAT_ROUTE_18_TRAINER_0 = 1249, + EVENT_BEAT_ROUTE_18_TRAINER_1 = 1250, + EVENT_BEAT_ROUTE_18_TRAINER_2 = 1251, + EVENT_BEAT_ROUTE_19_TRAINER_0 = 1265, + EVENT_BEAT_ROUTE_19_TRAINER_1 = 1266, + EVENT_BEAT_ROUTE_19_TRAINER_2 = 1267, + EVENT_BEAT_ROUTE_19_TRAINER_3 = 1268, + EVENT_BEAT_ROUTE_19_TRAINER_4 = 1269, + EVENT_BEAT_ROUTE_19_TRAINER_5 = 1270, + EVENT_BEAT_ROUTE_19_TRAINER_6 = 1271, + EVENT_BEAT_ROUTE_19_TRAINER_7 = 1272, + EVENT_BEAT_ROUTE_19_TRAINER_8 = 1273, + EVENT_BEAT_ROUTE_19_TRAINER_9 = 1274, + EVENT_BEAT_ROUTE_20_TRAINER_0 = 1281, + EVENT_BEAT_ROUTE_20_TRAINER_1 = 1282, + EVENT_BEAT_ROUTE_20_TRAINER_2 = 1283, + EVENT_BEAT_ROUTE_20_TRAINER_3 = 1284, + EVENT_BEAT_ROUTE_20_TRAINER_4 = 1285, + EVENT_BEAT_ROUTE_20_TRAINER_5 = 1286, + EVENT_BEAT_ROUTE_20_TRAINER_6 = 1287, + EVENT_BEAT_ROUTE_20_TRAINER_7 = 1288, + EVENT_BEAT_ROUTE_20_TRAINER_8 = 1289, + EVENT_BEAT_ROUTE_20_TRAINER_9 = 1290, + EVENT_BEAT_ROUTE_21_TRAINER_0 = 1297, + EVENT_BEAT_ROUTE_21_TRAINER_1 = 1298, + EVENT_BEAT_ROUTE_21_TRAINER_2 = 1299, + EVENT_BEAT_ROUTE_21_TRAINER_3 = 1300, + EVENT_BEAT_ROUTE_21_TRAINER_4 = 1301, + EVENT_BEAT_ROUTE_21_TRAINER_5 = 1302, + EVENT_BEAT_ROUTE_21_TRAINER_6 = 1303, + EVENT_BEAT_ROUTE_21_TRAINER_7 = 1304, + EVENT_BEAT_ROUTE_21_TRAINER_8 = 1305, + EVENT_BEAT_ROUTE_24_TRAINER_0 = 1346, + EVENT_BEAT_ROUTE_24_TRAINER_1 = 1347, + EVENT_BEAT_ROUTE_24_TRAINER_2 = 1348, + EVENT_BEAT_ROUTE_24_TRAINER_3 = 1349, + EVENT_BEAT_ROUTE_24_TRAINER_4 = 1350, + EVENT_BEAT_ROUTE_24_TRAINER_5 = 1351, + EVENT_BEAT_ROUTE_25_TRAINER_0 = 1361, + EVENT_BEAT_ROUTE_25_TRAINER_1 = 1362, + EVENT_BEAT_ROUTE_25_TRAINER_2 = 1363, + EVENT_BEAT_ROUTE_25_TRAINER_3 = 1364, + EVENT_BEAT_ROUTE_25_TRAINER_4 = 1365, + EVENT_BEAT_ROUTE_25_TRAINER_5 = 1366, + EVENT_BEAT_ROUTE_25_TRAINER_6 = 1367, + EVENT_BEAT_ROUTE_25_TRAINER_7 = 1368, + EVENT_BEAT_ROUTE_25_TRAINER_8 = 1369, + EVENT_BEAT_ROUTE_3_TRAINER_0 = 994, + EVENT_BEAT_ROUTE_3_TRAINER_1 = 995, + EVENT_BEAT_ROUTE_3_TRAINER_2 = 996, + EVENT_BEAT_ROUTE_3_TRAINER_3 = 997, + EVENT_BEAT_ROUTE_3_TRAINER_4 = 998, + EVENT_BEAT_ROUTE_3_TRAINER_5 = 999, + EVENT_BEAT_ROUTE_3_TRAINER_6 = 1000, + EVENT_BEAT_ROUTE_3_TRAINER_7 = 1001, + EVENT_BEAT_ROUTE_4_TRAINER_0 = 1010, + EVENT_BEAT_ROUTE_6_TRAINER_0 = 1041, + EVENT_BEAT_ROUTE_6_TRAINER_1 = 1042, + EVENT_BEAT_ROUTE_6_TRAINER_2 = 1043, + EVENT_BEAT_ROUTE_6_TRAINER_3 = 1044, + EVENT_BEAT_ROUTE_6_TRAINER_4 = 1045, + EVENT_BEAT_ROUTE_6_TRAINER_5 = 1046, + EVENT_BEAT_ROUTE_8_TRAINER_0 = 1073, + EVENT_BEAT_ROUTE_8_TRAINER_1 = 1074, + EVENT_BEAT_ROUTE_8_TRAINER_2 = 1075, + EVENT_BEAT_ROUTE_8_TRAINER_3 = 1076, + EVENT_BEAT_ROUTE_8_TRAINER_4 = 1077, + EVENT_BEAT_ROUTE_8_TRAINER_5 = 1078, + EVENT_BEAT_ROUTE_8_TRAINER_6 = 1079, + EVENT_BEAT_ROUTE_8_TRAINER_7 = 1080, + EVENT_BEAT_ROUTE_8_TRAINER_8 = 1081, + EVENT_BEAT_ROUTE_9_TRAINER_0 = 1089, + EVENT_BEAT_ROUTE_9_TRAINER_1 = 1090, + EVENT_BEAT_ROUTE_9_TRAINER_2 = 1091, + EVENT_BEAT_ROUTE_9_TRAINER_3 = 1092, + EVENT_BEAT_ROUTE_9_TRAINER_4 = 1093, + EVENT_BEAT_ROUTE_9_TRAINER_5 = 1094, + EVENT_BEAT_ROUTE_9_TRAINER_6 = 1095, + EVENT_BEAT_ROUTE_9_TRAINER_7 = 1096, + EVENT_BEAT_ROUTE_9_TRAINER_8 = 1097, + EVENT_BEAT_SABRINA = 865, + EVENT_BEAT_SAFFRON_GYM_TRAINER_0 = 866, + EVENT_BEAT_SAFFRON_GYM_TRAINER_1 = 867, + EVENT_BEAT_SAFFRON_GYM_TRAINER_2 = 868, + EVENT_BEAT_SAFFRON_GYM_TRAINER_3 = 869, + EVENT_BEAT_SAFFRON_GYM_TRAINER_4 = 870, + EVENT_BEAT_SAFFRON_GYM_TRAINER_5 = 871, + EVENT_BEAT_SAFFRON_GYM_TRAINER_6 = 872, + EVENT_BEAT_SILPH_CO_10F_TRAINER_0 = 1905, + EVENT_BEAT_SILPH_CO_10F_TRAINER_1 = 1906, + EVENT_BEAT_SILPH_CO_11F_TRAINER_0 = 1924, + EVENT_BEAT_SILPH_CO_11F_TRAINER_1 = 1925, + EVENT_BEAT_SILPH_CO_2F_TRAINER_0 = 1778, + EVENT_BEAT_SILPH_CO_2F_TRAINER_1 = 1779, + EVENT_BEAT_SILPH_CO_2F_TRAINER_2 = 1780, + EVENT_BEAT_SILPH_CO_2F_TRAINER_3 = 1781, + EVENT_BEAT_SILPH_CO_3F_TRAINER_0 = 1794, + EVENT_BEAT_SILPH_CO_3F_TRAINER_1 = 1795, + EVENT_BEAT_SILPH_CO_4F_TRAINER_0 = 1810, + EVENT_BEAT_SILPH_CO_4F_TRAINER_1 = 1811, + EVENT_BEAT_SILPH_CO_4F_TRAINER_2 = 1812, + EVENT_BEAT_SILPH_CO_5F_TRAINER_0 = 1826, + EVENT_BEAT_SILPH_CO_5F_TRAINER_1 = 1827, + EVENT_BEAT_SILPH_CO_5F_TRAINER_2 = 1828, + EVENT_BEAT_SILPH_CO_5F_TRAINER_3 = 1829, + EVENT_BEAT_SILPH_CO_6F_TRAINER_0 = 1846, + EVENT_BEAT_SILPH_CO_6F_TRAINER_1 = 1847, + EVENT_BEAT_SILPH_CO_6F_TRAINER_2 = 1848, + EVENT_BEAT_SILPH_CO_7F_TRAINER_0 = 1861, + EVENT_BEAT_SILPH_CO_7F_TRAINER_1 = 1862, + EVENT_BEAT_SILPH_CO_7F_TRAINER_2 = 1863, + EVENT_BEAT_SILPH_CO_7F_TRAINER_3 = 1864, + EVENT_BEAT_SILPH_CO_8F_TRAINER_0 = 1874, + EVENT_BEAT_SILPH_CO_8F_TRAINER_1 = 1875, + EVENT_BEAT_SILPH_CO_8F_TRAINER_2 = 1876, + EVENT_BEAT_SILPH_CO_9F_TRAINER_0 = 1890, + EVENT_BEAT_SILPH_CO_9F_TRAINER_1 = 1891, + EVENT_BEAT_SILPH_CO_9F_TRAINER_2 = 1892, + EVENT_BEAT_SILPH_CO_GIOVANNI = 1935, + EVENT_BEAT_SILPH_CO_RIVAL = 1856, + EVENT_BEAT_SS_ANNE_10_TRAINER_0 = 1553, + EVENT_BEAT_SS_ANNE_10_TRAINER_1 = 1554, + EVENT_BEAT_SS_ANNE_10_TRAINER_2 = 1555, + EVENT_BEAT_SS_ANNE_10_TRAINER_3 = 1556, + EVENT_BEAT_SS_ANNE_10_TRAINER_4 = 1557, + EVENT_BEAT_SS_ANNE_10_TRAINER_5 = 1558, + EVENT_BEAT_SS_ANNE_5_TRAINER_0 = 1476, + EVENT_BEAT_SS_ANNE_5_TRAINER_1 = 1477, + EVENT_BEAT_SS_ANNE_8_TRAINER_0 = 1521, + EVENT_BEAT_SS_ANNE_8_TRAINER_1 = 1522, + EVENT_BEAT_SS_ANNE_8_TRAINER_2 = 1523, + EVENT_BEAT_SS_ANNE_8_TRAINER_3 = 1524, + EVENT_BEAT_SS_ANNE_9_TRAINER_0 = 1537, + EVENT_BEAT_SS_ANNE_9_TRAINER_1 = 1538, + EVENT_BEAT_SS_ANNE_9_TRAINER_2 = 1539, + EVENT_BEAT_SS_ANNE_9_TRAINER_3 = 1540, + EVENT_BEAT_VERMILION_GYM_TRAINER_0 = 354, + EVENT_BEAT_VERMILION_GYM_TRAINER_1 = 355, + EVENT_BEAT_VERMILION_GYM_TRAINER_2 = 356, + EVENT_BEAT_VICTORY_ROAD_1_TRAINER_0 = 2321, + EVENT_BEAT_VICTORY_ROAD_1_TRAINER_1 = 2322, + EVENT_BEAT_VICTORY_ROAD_2_TRAINER_0 = 1337, + EVENT_BEAT_VICTORY_ROAD_2_TRAINER_1 = 1338, + EVENT_BEAT_VICTORY_ROAD_2_TRAINER_2 = 1339, + EVENT_BEAT_VICTORY_ROAD_2_TRAINER_3 = 1340, + EVENT_BEAT_VICTORY_ROAD_2_TRAINER_4 = 1341, + EVENT_BEAT_VICTORY_ROAD_3_TRAINER_0 = 1633, + EVENT_BEAT_VICTORY_ROAD_3_TRAINER_1 = 1634, + EVENT_BEAT_VICTORY_ROAD_3_TRAINER_2 = 1635, + EVENT_BEAT_VICTORY_ROAD_3_TRAINER_3 = 1636, + EVENT_BEAT_VIRIDIAN_FOREST_TRAINER_0 = 1378, + EVENT_BEAT_VIRIDIAN_FOREST_TRAINER_1 = 1379, + EVENT_BEAT_VIRIDIAN_FOREST_TRAINER_2 = 1380, + EVENT_BEAT_VIRIDIAN_GYM_GIOVANNI = 81, + EVENT_BEAT_VIRIDIAN_GYM_TRAINER_0 = 82, + EVENT_BEAT_VIRIDIAN_GYM_TRAINER_1 = 83, + EVENT_BEAT_VIRIDIAN_GYM_TRAINER_2 = 84, + EVENT_BEAT_VIRIDIAN_GYM_TRAINER_3 = 85, + EVENT_BEAT_VIRIDIAN_GYM_TRAINER_4 = 86, + EVENT_BEAT_VIRIDIAN_GYM_TRAINER_5 = 87, + EVENT_BEAT_VIRIDIAN_GYM_TRAINER_6 = 88, + EVENT_BEAT_VIRIDIAN_GYM_TRAINER_7 = 89, + EVENT_BEAT_ZAPDOS = 1129, + EVENT_BILL_SAID_USE_CELL_SEPARATOR = 1374, + EVENT_BOUGHT_MAGIKARP = 1023, + EVENT_BOUGHT_MUSEUM_TICKET = 104, + EVENT_CINNABAR_GYM_GATE0_UNLOCKED = 680, + EVENT_CINNABAR_GYM_GATE1_UNLOCKED = 681, + EVENT_CINNABAR_GYM_GATE2_UNLOCKED = 682, + EVENT_CINNABAR_GYM_GATE3_UNLOCKED = 683, + EVENT_CINNABAR_GYM_GATE4_UNLOCKED = 684, + EVENT_CINNABAR_GYM_GATE5_UNLOCKED = 685, + EVENT_CINNABAR_GYM_GATE6_UNLOCKED = 686, + EVENT_DAISY_WALKING = 26, + EVENT_DEFEATED_FIGHTING_DOJO = 848, + EVENT_ENTERED_BLUES_HOUSE = 25, + EVENT_ENTERED_ROCKET_HIDEOUT = 1655, + EVENT_FIGHT_ROUTE12_SNORLAX = 1166, + EVENT_FIGHT_ROUTE16_SNORLAX = 1224, + EVENT_FOLLOWED_OAK_INTO_LAB = 0, + EVENT_FOLLOWED_OAK_INTO_LAB_2 = 32, + EVENT_FOUND_ROCKET_HIDEOUT = 441, + EVENT_GAVE_FOSSIL_TO_LAB = 736, + EVENT_GAVE_GOLD_TEETH = 569, + EVENT_GOT_10_COINS = 442, + EVENT_GOT_20_COINS = 443, + EVENT_GOT_20_COINS_2 = 444, + EVENT_GOT_BICYCLE = 192, + EVENT_GOT_BIKE_VOUCHER = 337, + EVENT_GOT_COIN_CASE = 480, + EVENT_GOT_DOME_FOSSIL = 1406, + EVENT_GOT_EXP_ALL = 1200, + EVENT_GOT_HELIX_FOSSIL = 1407, + EVENT_GOT_HITMONCHAN = 855, + EVENT_GOT_HITMONLEE = 854, + EVENT_GOT_HM01 = 1504, + EVENT_GOT_HM02 = 1230, + EVENT_GOT_HM03 = 2176, + EVENT_GOT_HM04 = 568, + EVENT_GOT_HM05 = 984, + EVENT_GOT_ITEMFINDER = 1151, + EVENT_GOT_MASTER_BALL = 1933, + EVENT_GOT_NUGGET = 1344, + EVENT_GOT_OAKS_PARCEL = 57, + EVENT_GOT_OLD_AMBER = 105, + EVENT_GOT_POKEBALLS_FROM_OAK = 36, + EVENT_GOT_POKEDEX = 37, + EVENT_GOT_POKE_FLUTE = 296, + EVENT_GOT_POTION_SAMPLE = 960, + EVENT_GOT_SS_TICKET = 1372, + EVENT_GOT_STARTER = 34, + EVENT_GOT_TM06 = 600, + EVENT_GOT_TM11 = 190, + EVENT_GOT_TM13 = 396, + EVENT_GOT_TM18 = 399, + EVENT_GOT_TM21 = 424, + EVENT_GOT_TM24 = 358, + EVENT_GOT_TM27 = 80, + EVENT_GOT_TM29 = 944, + EVENT_GOT_TM31 = 832, + EVENT_GOT_TM34 = 118, + EVENT_GOT_TM35 = 727, + EVENT_GOT_TM36 = 1791, + EVENT_GOT_TM38 = 664, + EVENT_GOT_TM39 = 1152, + EVENT_GOT_TM41 = 384, + EVENT_GOT_TM42 = 41, + EVENT_GOT_TM46 = 864, + EVENT_GOT_TM48 = 397, + EVENT_GOT_TM49 = 398, + EVENT_GOT_TOWN_MAP = 24, + EVENT_HALL_OF_FAME_DEX_RATING = 3, + EVENT_IN_PURIFIED_ZONE = 263, + EVENT_IN_SAFARI_ZONE = 591, + EVENT_IN_SEAFOAM_ISLANDS = 1280, + EVENT_LAB_HANDING_OVER_FOSSIL_MON = 738, + EVENT_LAB_STILL_REVIVING_FOSSIL = 737, + EVENT_LANCES_ROOM_LOCK_DOOR = 2303, + EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING = 1375, + EVENT_MANSION_SWITCH_ON = 632, + EVENT_MET_BILL = 1360, + EVENT_MET_BILL_2 = 1373, + EVENT_NUGGET_REWARD_AVAILABLE = 1353, + EVENT_OAK_APPEARED_IN_PALLET = 39, + EVENT_OAK_ASKED_TO_CHOOSE_MON = 33, + EVENT_OAK_GOT_PARCEL = 56, + EVENT_PALLET_AFTER_GETTING_POKEBALLS = 6, + EVENT_PALLET_AFTER_GETTING_POKEBALLS_2 = 38, + EVENT_PASSED_CASCADEBADGE_CHECK = 1328, + EVENT_PASSED_EARTHBADGE_CHECK = 1334, + EVENT_PASSED_MARSHBADGE_CHECK = 1332, + EVENT_PASSED_RAINBOWBADGE_CHECK = 1330, + EVENT_PASSED_SOULBADGE_CHECK = 1331, + EVENT_PASSED_THUNDERBADGE_CHECK = 1329, + EVENT_PASSED_VOLCANOBADGE_CHECK = 1333, + EVENT_PIKACHU_FAN_BOAST = 343, + EVENT_POKEMON_TOWER_RIVAL_ON_LEFT = 238, + EVENT_RESCUED_MR_FUJI = 1231, + EVENT_RESCUED_MR_FUJI_2 = 279, + EVENT_ROCKET_DROPPED_LIFT_KEY = 1702, + EVENT_ROCKET_HIDEOUT_4_DOOR_UNLOCKED = 1701, + EVENT_ROUTE22_RIVAL_WANTS_BATTLE = 1319, + EVENT_RUBBED_CAPTAINS_BACK = 1505, + EVENT_SAFARI_GAME_OVER = 590, + EVENT_SEAFOAM1_BOULDER1_DOWN_HOLE = 1294, + EVENT_SEAFOAM1_BOULDER2_DOWN_HOLE = 1295, + EVENT_SEAFOAM2_BOULDER1_DOWN_HOLE = 2496, + EVENT_SEAFOAM2_BOULDER2_DOWN_HOLE = 2497, + EVENT_SEAFOAM3_BOULDER1_DOWN_HOLE = 2504, + EVENT_SEAFOAM3_BOULDER2_DOWN_HOLE = 2505, + EVENT_SEAFOAM4_BOULDER1_DOWN_HOLE = 2512, + EVENT_SEAFOAM4_BOULDER2_DOWN_HOLE = 2513, + EVENT_SEEL_FAN_BOAST = 342, + EVENT_SILPH_CO_10_UNLOCKED_DOOR = 1912, + EVENT_SILPH_CO_11_UNLOCKED_DOOR = 1928, + EVENT_SILPH_CO_2_UNLOCKED_DOOR1 = 1789, + EVENT_SILPH_CO_2_UNLOCKED_DOOR2 = 1790, + EVENT_SILPH_CO_3_UNLOCKED_DOOR1 = 1800, + EVENT_SILPH_CO_3_UNLOCKED_DOOR2 = 1801, + EVENT_SILPH_CO_4_UNLOCKED_DOOR1 = 1816, + EVENT_SILPH_CO_4_UNLOCKED_DOOR2 = 1817, + EVENT_SILPH_CO_5_UNLOCKED_DOOR1 = 1832, + EVENT_SILPH_CO_5_UNLOCKED_DOOR2 = 1833, + EVENT_SILPH_CO_5_UNLOCKED_DOOR3 = 1834, + EVENT_SILPH_CO_6_UNLOCKED_DOOR = 1855, + EVENT_SILPH_CO_7_UNLOCKED_DOOR1 = 1868, + EVENT_SILPH_CO_7_UNLOCKED_DOOR2 = 1869, + EVENT_SILPH_CO_7_UNLOCKED_DOOR3 = 1870, + EVENT_SILPH_CO_8_UNLOCKED_DOOR = 1880, + EVENT_SILPH_CO_9_UNLOCKED_DOOR1 = 1896, + EVENT_SILPH_CO_9_UNLOCKED_DOOR2 = 1897, + EVENT_SILPH_CO_9_UNLOCKED_DOOR3 = 1898, + EVENT_SILPH_CO_9_UNLOCKED_DOOR4 = 1899, + EVENT_SILPH_CO_RECEPTIONIST_AT_DESK = 919, + EVENT_SS_ANNE_LEFT = 1506, + EVENT_STARTED_WALKING_OUT_OF_DOCK = 1508, + EVENT_USED_CELL_SEPARATOR_ON_BILL = 1371, + EVENT_VICTORY_ROAD_1_BOULDER_ON_SWITCH = 2327, + EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH1 = 1336, + EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH2 = 1343, + EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH1 = 1632, + EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH2 = 1638, + EVENT_VIRIDIAN_GYM_OPEN = 40, + EVENT_WALKED_OUT_OF_DOCK = 1509, + EVENT_WALKED_PAST_GUARD_AFTER_SS_ANNE_LEFT = 1507, + }, + count = 2560, + source = "pokered constants/event_constants.asm", +} diff --git a/src/script/Commands.lua b/src/script/Commands.lua index 9b23ef81..caf9469e 100644 --- a/src/script/Commands.lua +++ b/src/script/Commands.lua @@ -261,7 +261,9 @@ function Commands.move_player(ctx, dir, tiles) end function Commands.move_npc(ctx, objIndex, dir, tiles) - local npc = ctx.overworld:npcByIndex(objIndex) + local ow = ctx.overworld + if not ow then return end + local npc = ow:npcByIndex(objIndex) if npc then walkEntity(ctx, npc, dir, tiles) end end @@ -308,6 +310,7 @@ end function Commands.move_npc_to(ctx, objIndex, tx, ty) local ow = ctx.overworld + if not ow then return end local npc = ow:npcByIndex(objIndex) if not npc then return end local path = bfsPath(ow.map, npc.cellX, npc.cellY, tx, ty, ow.entities, npc) @@ -339,6 +342,20 @@ function Commands.face_object(ctx, objIndex, dir) if npc then npc.facing = dir end end +-- Instantly relocate an NPC (OaksLabCalcRivalMovementScript / SetSpritePosition1). +-- facing is optional. Headless-safe no-op without an overworld. +function Commands.place_npc(ctx, objIndex, x, y, facing) + local ow = ctx.overworld + if not ow then return end + local npc = ow:npcByIndex(objIndex) + if not npc then return end + npc.cellX, npc.cellY = x, y + npc.px, npc.py = x * 16, y * 16 + npc.moving = false + npc.targetX, npc.targetY = nil, nil + if facing then npc.facing = facing end +end + function Commands.face_npc(ctx) -- make the player face the talking NPC if ctx.npc and ctx.overworld then @@ -485,7 +502,7 @@ function Commands.heal_party(ctx) end -- AskName (engine/menus/naming_screen.asm): yes/no then NamingScreen. --- AddPartyMon only offers this for party mons (box deposits skip it). +-- AddPartyMon and SendNewMonToBox both offer this. -- Preserves ctx.lastCheck so GivePokemon's carry is not clobbered by the -- yes/no result (scripts jump_if_false on give failure afterwards). local function askNickname(ctx, mon) @@ -527,8 +544,9 @@ end -- the asm's carry: true when the mon was given, false when both the -- party and every box are full (that .boxFull path leaves the giver's -- script able to offer again later, e.g. the Celadon Eevee ball). --- Party adds run AskName (AddPartyMon) when a script runner is present; --- mods that pre-set gift.nickname skip the prompt. +-- AskName runs for party (AddPartyMon) and box (SendNewMonToBox) when a +-- script runner is present; mods that pre-set gift.nickname skip it. +-- Box deposits also print SentToBoxText (give_pokemon.asm:36-37). function Commands.give_pokemon(ctx, species, level) -- Native mods can transform a gift before the Pokémon object is created. -- This is intentionally an event rather than a special-case starter hook: @@ -540,14 +558,17 @@ function Commands.give_pokemon(ctx, species, level) end local Pokemon = require("src.pokemon.Pokemon") local Party = require("src.pokemon.Party") + local Boxes = require("src.pokemon.Boxes") local mon = Pokemon.new(ctx.game.data, species, level) if gift.nickname then mon.nickname = gift.nickname end ctx.game.stringBuffer = ctx.game.data.pokemon[species].name or species ctx.pendingPokemonName = species require("src.battle.BattleState").stampOT(ctx.save, mon) local addedToParty = Party.add(ctx.save.party, mon) + local boxNum = nil if not addedToParty then - if not require("src.pokemon.Boxes").deposit(ctx.save, mon) then + boxNum = Boxes.deposit(ctx.save, mon) + if not boxNum then ctx.lastCheck = false return end @@ -558,11 +579,30 @@ function Commands.give_pokemon(ctx, species, level) dex.owned[species] = true end ctx.lastCheck = true - -- AddPartyMon AskName: party only; skip box deposits, mod-set nicknames, - -- and callback-style callers that have no script runner to yield on. - if addedToParty and not gift.nickname and ctx.runner then + ctx.addedToParty = addedToParty + ctx.boxNum = boxNum + -- AskName: both AddPartyMon and SendNewMonToBox; skip mod-set nicks + -- and callback-style callers with no script runner to yield on. + if not gift.nickname and ctx.runner then askNickname(ctx, mon) end + if boxNum then + local name = mon.nickname + or (ctx.game.data.pokemon[species] and ctx.game.data.pokemon[species].name) + or species + ctx.game.boxMonNicks = name + ctx.game.stringBuffer = tostring(boxNum) + if ctx.runner then + Commands.show_text(ctx, "_SentToBoxText") + else + local TextBox = require("src.render.TextBox") + local raw = ctx.game.data.text and ctx.game.data.text._SentToBoxText + ctx.game.stack:push(TextBox.new(ctx.game, raw or ( + "There's no more\nroom for POKéMON!\v" .. name + .. " was\vsent to POKéMON\vBOX " .. boxNum .. " on PC!"), + function() end)) + end + end end function Commands.give_money(ctx, amount) @@ -783,6 +823,8 @@ function Commands.trade(ctx, tradeIndex, doneFlag) local newMon = Pokemon.new(data, trade.get, sent.level) newMon.nickname = trade.nickname newMon.traded = true -- boosted exp + Name Rater refusal (different OT) + newMon.ot = "TRAINER" -- InGameTrade_TrainerString + newMon.otId = (love.math and love.math.random or math.random)(0, 65535) table.remove(party, slot) table.insert(party, newMon) local dex = ctx.save.pokedex @@ -790,9 +832,13 @@ function Commands.trade(ctx, tradeIndex, doneFlag) dex.seen[trade.get] = true dex.owned[trade.get] = true end - -- the trade machine animation (engine/movie/trade.asm) + -- InternalClockTradeAnim (engine/movie/trade.asm) Screens.push(ctx.game, "TradeAnim", { sent = sent, received = newMon, + enemyName = "TRAINER", + playerOt = ctx.save.player.name, + playerOtId = sent.otId or ctx.save.player.id, + enemyOtId = newMon.otId, onDone = function() runner:resume() end, }) runner:yield() diff --git a/src/ui/BagMenu.lua b/src/ui/BagMenu.lua index 193170fb..c125669e 100644 --- a/src/ui/BagMenu.lua +++ b/src/ui/BagMenu.lua @@ -331,6 +331,12 @@ end local function useItem(game, battle, id, list) local def = game.data.items[id] + -- ItemUseTMHM checks wIsInBattle before BootedUpTMText + if battle and def and def.machine then + local _, payload = ItemEffects.use(game.data, game.save, id, nil, battle) + showMessages(game, payload) + return + end if ItemEffects.needsTarget(id, def) and not ItemEffects.isBall(id) then -- TMs/HMs boot up and announce their move before the target picker -- (ItemUseTMHM: BootedUpTMText / BootedUpHMText + TeachMachineMoveText) diff --git a/src/ui/BoxMenu.lua b/src/ui/BoxMenu.lua index ba205a51..adb6e62c 100644 --- a/src/ui/BoxMenu.lua +++ b/src/ui/BoxMenu.lua @@ -3,9 +3,11 @@ -- plus CHANGE BOX. local Boxes = require("src.pokemon.Boxes") +local Font = require("src.render.Font") local ListMenu = require("src.ui.ListMenu") local Menu = require("src.ui.Menu") local Party = require("src.pokemon.Party") +local TextBox = require("src.render.TextBox") local BoxMenu = {} @@ -14,6 +16,11 @@ local function monLabel(game, mon) return ("%s :L%d"):format(mon.nickname or def.name, mon.level) end +local function monName(game, mon) + local def = game.data.pokemon[mon.species] + return mon.nickname or def.name +end + -- Per-mon submenu (bills_pc.asm DisplayDepositWithdrawMenu): the chosen -- action + STATS + CANCEL. STATS shows the status screen and returns -- here; CANCEL/B goes back to the list. @@ -31,8 +38,26 @@ local function monSubmenu(game, action, mon, onAction) }, { tx = 9, ty = 10, tw = 11, th = 8, noSound = true })) end +-- After a successful transfer: close the mon list (BoxMenu stays beneath +-- via keepOpen) and show the taken-out / stored text (jp BillsPCMenu). +local function afterTransfer(game, list, text) + list:close() + game.stack:push(TextBox.new(game, text)) +end + local function withdraw(game) local box = Boxes.active(game.save) + local t = game.data.text + if #box == 0 then + game.stack:push(TextBox.new(game, t._NoMonText + or "What? There are\nno POKéMON here!")) + return + end + if #game.save.party >= Party.MAX then + game.stack:push(TextBox.new(game, t._CantTakeMonText + or "You can't take\nany more POKéMON.\fDeposit POKéMON\nfirst.")) + return + end local items = {} for i, mon in ipairs(box) do table.insert(items, { label = monLabel(game, mon), value = i }) @@ -49,13 +74,29 @@ local function withdraw(game) end table.remove(box, item.value) table.insert(game.save.party, mon) - list:close() + local name = monName(game, mon) + game.stringBuffer = name + require("src.core.Sound").playCry(game.data, mon.species) + afterTransfer(game, list, t._MonIsTakenOutText + or (name .. " is\ntaken out.\vGot " .. name .. ".")) end) end, })) end local function deposit(game) + local t = game.data.text + if #game.save.party <= 1 then + game.stack:push(TextBox.new(game, t._CantDepositLastMonText + or "You can't deposit\nthe last POKéMON!")) + return + end + local box = Boxes.active(game.save) + if #box >= Boxes.CAPACITY then + game.stack:push(TextBox.new(game, t._BoxFullText + or "Oops! This Box is\nfull of POKéMON.")) + return + end local items = {} for i, mon in ipairs(game.save.party) do table.insert(items, { label = monLabel(game, mon), value = i }) @@ -69,40 +110,50 @@ local function deposit(game) list.footer = "You need at least\none POKéMON!" return end - local box = Boxes.active(game.save) - if #box >= Boxes.CAPACITY then + local active = Boxes.active(game.save) + if #active >= Boxes.CAPACITY then list.footer = ("BOX %d is full!"):format(game.save.currentBox) return end table.remove(game.save.party, item.value) - table.insert(box, mon) - list:close() + table.insert(active, mon) + local name = monName(game, mon) + game.stringBuffer = name + game.boxNumString = tostring(game.save.currentBox) + require("src.core.Sound").playCry(game.data, mon.species) + afterTransfer(game, list, t._MonWasStoredText + or (name .. " was\nstored in Box " .. game.boxNumString .. ".")) end) end, })) end --- RELEASE POKéMON (bills_pc.asm .release): confirm, then "Bye [MON]!" +-- RELEASE POKéMON (bills_pc.asm BillsPCRelease): confirm, then "Bye [MON]!". +-- Index by list.index (not stale item.value) after removeCurrent (#171). local function release(game) local box = Boxes.active(game.save) + local t = game.data.text + if #box == 0 then + game.stack:push(TextBox.new(game, t._NoMonText + or "What? There are\nno POKéMON here!")) + return + end local items = {} for i, mon in ipairs(box) do table.insert(items, { label = monLabel(game, mon), value = i }) end game.stack:push(ListMenu.new(game, ("BOX %d (RELEASE)"):format(game.save.currentBox), items, { - onChoose = function(item, list) - local mon = box[item.value] + onChoose = function(_, list) + local mon = box[list.index] if not mon then return end - local def = game.data.pokemon[mon.species] - local name = mon.nickname or def.name + local name = monName(game, mon) local ChoiceBox = require("src.ui.ChoiceBox") - local TextBox = require("src.render.TextBox") game.stack:push(TextBox.new(game, "Once released,\n" .. name .. " is\ngone forever. OK?", function() game.stack:push(ChoiceBox.new(game, function(yes) if not yes then return end - table.remove(box, item.value) + table.remove(box, list.index) require("src.core.Sound").playCry(game.data, mon.species) game.stack:push(TextBox.new(game, ("%s was\nreleased outside.\fBye %s!"):format(name, name))) @@ -129,7 +180,6 @@ local function changeBox(game) -- the original asks BEFORE switching ("When you change a #MON -- BOX, data will be saved. OK?"); declining aborts the change local ChoiceBox = require("src.ui.ChoiceBox") - local TextBox = require("src.render.TextBox") game.stack:push(TextBox.new(game, "When you change a\nPOKéMON BOX, data\nwill be saved. OK?", function() game.stack:push(ChoiceBox.new(game, function(yes) @@ -143,21 +193,48 @@ local function changeBox(game) })) end +-- bills_pc.asm BillsPCMenu chrome: What? text box + BOX No. overlay +local function drawChrome(game) + Font.drawBox(0, 12, 20, 6) + love.graphics.setColor(0, 0, 0, 1) + Font.draw("What?", 8, 112) + Font.drawBox(9, 14, 11, 4) + love.graphics.setColor(0, 0, 0, 1) + Font.draw("BOX No.", 80, 128) + local n = game.save.currentBox or 1 + if n >= 10 then + Font.draw(tostring(n), 136, 128) + else + Font.draw(tostring(n), 144, 128) + end + love.graphics.setColor(1, 1, 1, 1) +end + function BoxMenu.new(game) Boxes.ensure(game.save) -- bills_pc.asm BillsPCMenu: TextBoxBorder at (0,0) with interior - -- 12x10 → total 14x12. "CHANGE BOX" is 10 tiles and needs the - -- full interior (cursor col + label); the old tw=12 right-side box - -- drew the final glyph on the border. - return Menu.new(game, { - { label = "WITHDRAW", onSelect = function() withdraw(game) end }, - { label = "DEPOSIT", onSelect = function() deposit(game) end }, - { label = "RELEASE", onSelect = function() release(game) end }, - { label = "CHANGE BOX", onSelect = function() changeBox(game) end }, + -- 12x10 → total 14x12. "CHANGE BOX" / "WITHDRAW " need the + -- full interior (cursor col + label). keepOpen so WITHDRAW/DEPOSIT/ + -- RELEASE/CHANGE BOX leave this menu underneath (jp BillsPCMenu). + local menu = Menu.new(game, { + { label = "WITHDRAW ", keepOpen = true, + onSelect = function() withdraw(game) end }, + { label = "DEPOSIT ", keepOpen = true, + onSelect = function() deposit(game) end }, + { label = "RELEASE ", keepOpen = true, + onSelect = function() release(game) end }, + { label = "CHANGE BOX", keepOpen = true, + onSelect = function() changeBox(game) end }, { label = "SEE YA!" }, -- Bill's PC runs silent end to end (BIT_NO_MENU_BUTTON_SOUND, -- engine/menus/pokemon_pc.asm) }, { tx = 0, ty = 0, tw = 14, th = 12, noSound = true }) + local baseDraw = menu.draw + function menu:draw() + baseDraw(self) + drawChrome(game) + end + return menu end return BoxMenu diff --git a/src/ui/ChoiceBox.lua b/src/ui/ChoiceBox.lua index 60299fde..b30fda61 100644 --- a/src/ui/ChoiceBox.lua +++ b/src/ui/ChoiceBox.lua @@ -1,4 +1,4 @@ --- YES/NO choice box (top-left of the text box area, like the original). +-- YES/NO choice box (InitYesNoTextBoxParameters: above the text box, right). local Font = require("src.render.Font") local Theme = require("src.ui.Theme") @@ -14,6 +14,11 @@ function ChoiceBox.new(game, onChoose, opts) self.index = (opts and opts.defaultNo) and 2 or 1 -- BIT_NO_MENU_BUTTON_SOUND: PC-session prompts stay silent self.noSound = (opts and opts.noSound) or false + local box = Theme.choiceBox + self.tx = (opts and opts.tx) or box.tx + self.ty = (opts and opts.ty) or box.ty + self.tw = (opts and opts.tw) or box.tw + self.th = (opts and opts.th) or box.th return self end @@ -38,13 +43,13 @@ function ChoiceBox:update(dt) end function ChoiceBox:draw() - local box = Theme.choiceBox - Font.drawBox(box.tx, box.ty, box.tw, box.th) + local tx, ty, tw, th = self.tx, self.ty, self.tw, self.th + Font.drawBox(tx, ty, tw, th) love.graphics.setColor(0, 0, 0, 1) - Font.draw("YES", (box.tx + 2) * 8, (box.ty + 1) * 8) - Font.draw("NO", (box.tx + 2) * 8, (box.ty + 3) * 8) - Font.drawCode(Theme.cursor, (box.tx + 1) * 8, - (box.ty + (self.index == 1 and 1 or 3)) * 8) + Font.draw("YES", (tx + 2) * 8, (ty + 1) * 8) + Font.draw("NO", (tx + 2) * 8, (ty + 3) * 8) + Font.drawCode(Theme.cursor, (tx + 1) * 8, + (ty + (self.index == 1 and 1 or 3)) * 8) love.graphics.setColor(1, 1, 1, 1) end diff --git a/src/ui/ListMenu.lua b/src/ui/ListMenu.lua index bc0ee654..516fa83b 100644 --- a/src/ui/ListMenu.lua +++ b/src/ui/ListMenu.lua @@ -37,8 +37,12 @@ function ListMenu.new(game, title, items, opts) -- text box, a money box sits top-right, and the list shortens to -- clear them (DisplayPokemartDialogue_'s screen) self.dialogue = opts.dialogue + -- PC item lists (players_pc.asm): PrintListMenuEntries shows 4 names + -- and PrintText footers ("How many?", stored/withdrew) use the standard + -- bottom text box — same row budget as the mart, without the money box. + self.messageBox = opts.messageBox self.money = opts.money -- () -> current money for the box - self.rows = opts.dialogue and 4 or ROWS + self.rows = opts.rows or ((opts.dialogue or opts.messageBox) and 4 or ROWS) return self end @@ -137,8 +141,10 @@ function ListMenu:draw() love.graphics.setColor(0, 0, 0, 1) local money = ("¥%d"):format(self.money and self.money() or 0) Font.draw(money, 152 - Font.width(money), 8) - -- the clerk's line in the standard bottom text box; long prompts - -- wrap and keep their last two lines, like the GB's scrolled box + end + if self.dialogue or (self.messageBox and self.footer) then + -- standard bottom text box (PrintText); long prompts wrap and keep + -- their last two lines, like the GB's scrolled box (#115/#174) Font.drawBox(0, 12, 20, 6) love.graphics.setColor(0, 0, 0, 1) if self.footer then @@ -153,8 +159,7 @@ function ListMenu:draw() end end elseif self.footer then - -- PC deposit/withdraw footers use "\n"; draw the last two lines so - -- long item names are not clipped at the screen edge (#115). + -- bare footer (bag money line, etc.) local flat = {} for _, page in ipairs(require("src.render.TextBox").paginate(self.footer)) do for _, line in ipairs(page) do flat[#flat + 1] = line end diff --git a/src/ui/MoveLearnMenu.lua b/src/ui/MoveLearnMenu.lua index 3a16d5de..f107b454 100644 --- a/src/ui/MoveLearnMenu.lua +++ b/src/ui/MoveLearnMenu.lua @@ -22,6 +22,8 @@ function MoveLearnMenu.new(game, mon, newMoveId, onDone) self.newMoveId = newMoveId self.onDone = onDone self.index = 1 + -- forget-list UI only after TryingToLearn YES (learn_move.asm .loop) + self.selecting = false return self end @@ -31,24 +33,32 @@ end -- TryingToLearnText + yes/no (learn_move.asm TryingToLearn): NO offers -- AbandonLearning, whose own NO loops back here (DontAbandonLearning). +-- Use TextBox opts.choice so YES/NO overlays the still-visible prompt; +-- pushing ChoiceBox from onDone pops the text first and leaves YES/NO +-- on "Which move should be forgotten?" (#173). function MoveLearnMenu:enter() local TextBox = require("src.render.TextBox") - local ChoiceBox = require("src.ui.ChoiceBox") local game = self.game local mdef = game.data.moves[self.newMoveId] local name = self:monName() + self.selecting = false game.stack:push(TextBox.new(game, ("%s is\ntrying to learn\v%s!\fBut, %s\ncan't learn more\vthan 4 moves!\f") :format(name, mdef.name, name) .. ("Delete an older\nmove to make room\vfor %s?"):format(mdef.name), - function() - game.stack:push(ChoiceBox.new(game, function(yes) - if not yes then self:confirmAbandon() end - end)) - end)) + nil, { + choice = function(yes) + if yes then + self.selecting = true + else + self:confirmAbandon() + end + end, + })) 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 if input:wasPressed("up") then @@ -82,15 +92,15 @@ end -- (DontAbandonLearning) function MoveLearnMenu:confirmAbandon() local TextBox = require("src.render.TextBox") - local ChoiceBox = require("src.ui.ChoiceBox") local game = self.game local mdef = game.data.moves[self.newMoveId] + self.selecting = false game.stack:push(TextBox.new(game, - ("Abandon learning\n%s?"):format(mdef.name), function() - game.stack:push(ChoiceBox.new(game, function(yes) - if yes then self:finish(false) else self:enter() end - end)) - end)) + ("Abandon learning\n%s?"):format(mdef.name), nil, { + choice = function(yes) + if yes then self:finish(false) else self:enter() end + end, + })) end function MoveLearnMenu:finish(learned) @@ -98,6 +108,7 @@ function MoveLearnMenu:finish(learned) local game = self.game local name = self:monName() local mdef = game.data.moves[self.newMoveId] + self.selecting = false game.stack:pop() local msg if learned then @@ -114,6 +125,7 @@ function MoveLearnMenu:finish(learned) 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) diff --git a/src/ui/OptionsMenu.lua b/src/ui/OptionsMenu.lua index 0e6bb3ca..e5c1e815 100644 --- a/src/ui/OptionsMenu.lua +++ b/src/ui/OptionsMenu.lua @@ -10,6 +10,7 @@ -- bottom line like pokered's. 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 Zoom = require("src.render.Zoom") @@ -193,6 +194,15 @@ local function buildRows(game) local o = g.save.options o.tilt = wrapIndex((o.tilt or 0) + dir, 4) Tilt.setLevel(o.tilt) + -- tilt and a mod's world pipeline are two answers to the same + -- question; turning this on switches that off (Pipelines does the + -- same in the other direction) + if o.tilt > 0 then + for _, entry in ipairs(Pipelines.list()) do + if entry.def.drawWorld then Pipelines.setLevel(entry.id, 0) end + end + Pipelines.syncOptions(o) + end return true end }, { id = "gbcfx", label = "GBC FX", @@ -295,6 +305,26 @@ local function buildRows(game) end rows = filtered end + -- A mod's render pipelines are display modes like TILT, so their rows sit + -- with it rather than at the end of the list where a mod's own + -- ui.options.rows additions land. Nothing registered means nothing + -- spliced, so a vanilla install sees the list it always had. + local pipelineRows = Pipelines.rows(game) + if pipelineRows[1] then + local merged = {} + for _, row in ipairs(rows) do + merged[#merged + 1] = row + if row.id == "tilt" then + for _, extra in ipairs(pipelineRows) do merged[#merged + 1] = extra end + end + end + -- no TILT row to anchor to (a future build could drop it): append + -- rather than silently lose the modes + if #merged == #rows then + for _, extra in ipairs(pipelineRows) do merged[#merged + 1] = extra end + end + rows = merged + end return rows end diff --git a/src/ui/PartyMenu.lua b/src/ui/PartyMenu.lua index 8c455d3e..5de185ca 100644 --- a/src/ui/PartyMenu.lua +++ b/src/ui/PartyMenu.lua @@ -1,8 +1,11 @@ -- Party menu: list the party, choose a member. -- Modes: --- default: A -> submenu (STATS / SWITCH order / CANCEL) --- opts.onSwitch: A -> hand the chosen mon to the callback (battle --- switch, item targeting via opts.pickOnly) +-- default: A -> submenu (STATS / SWITCH / field moves) +-- opts.onSwitch + opts.battle (voluntary PKMN): A -> SWITCH / STATS / +-- CANCEL (core.asm PartyMenuOrRockOrRun), then onSwitch on SWITCH +-- opts.onSwitch + opts.forceSwitch: A -> onSwitch immediately +-- (ChooseNextMon / SHIFT free-switch) +-- opts.pickOnly + opts.onSwitch: A -> onSwitch (item / script target) -- opts.onCancel: fired when the menu closes without a pick (B) -- Pops itself on B. @@ -125,6 +128,7 @@ function PartyMenu.new(game, opts) self.onSwitch = opts.onSwitch self.onCancel = opts.onCancel self.pickOnly = opts.pickOnly + self.forceSwitch = opts.forceSwitch self.battle = opts.battle self.party = opts.party -- link battles pass their clamped copies self.swapFrom = nil @@ -156,7 +160,17 @@ function PartyMenu:update(dt) -- hook-injected entries carry a callback instead of an action id entry.onSelect(mon, self.game) elseif action == "stats" then + -- battle and field alike return to the party list afterwards + -- (core.asm .partyMenuWasSelected) Screens.push(self.game, "SummaryMenu", mon) + elseif action == "battle_switch" then + self.game.stack:pop() + self.onSwitch(mon) + return + elseif action == "cancel" then + self.game.stack:pop() + if self.onCancel then self.onCancel() end + return elseif action == "switch" then self.swapFrom = self.index elseif action == "fly" then @@ -341,58 +355,66 @@ function PartyMenu:update(dt) require("src.core.Sound").play(self.game.data, "Swap") end self.swapFrom = nil - elseif self.onSwitch then + elseif self.onSwitch and (self.forceSwitch or self.pickOnly or not self.battle) then self.game.stack:pop() self.onSwitch(mon) else self.submenu = true self.subIndex = 1 - -- STATS/SWITCH plus this mon's field moves (start_sub_menus.asm - -- builds the same dynamic list) - local items = { { label = "STATS", action = "stats" }, - { label = "SWITCH", action = "switch" } } + local items local ow = self.game.overworld - -- Field moves (HMs/TMs) are usable out of battle even when the mon - -- is fainted -- Gen 1 does not require HP for Cut/Fly/Surf/etc. - -- Battle still excludes this list via `not self.battle`. Softboiled - -- can appear for a fainted user; its heal transfer then no-ops. - if not self.battle and ow then - -- FLY/TELEPORT: CheckIfInOutsideMap (OVERWORLD + PLATEAU — - -- Route 23 / Indigo Plateau outdoor), not OVERWORLD alone (#83) - local outside = Map.isOutside(ow.map.def, - FieldDefaults.field(self.game.data, "outsideTilesets")) - for _, mv in ipairs(mon.moves) do - if mv.id == "FLY" and outside - and self.game.save.inventory.THUNDERBADGE then - table.insert(items, { label = "FLY", action = "fly" }) - elseif mv.id == "FLASH" and ow.dark - and self.game.save.inventory.BOULDERBADGE then - table.insert(items, { label = "FLASH", action = "flash" }) - elseif mv.id == "CUT" and self.game.save.inventory.CASCADEBADGE then - -- CUT/SURF/STRENGTH are party-menu field moves too - -- (start_sub_menus.asm .outOfBattleMovePointers); listed here - -- with the same list-time badge filter this file already uses - -- for FLY/FLASH. The facing-tile/activation check happens on - -- selection (useCutFieldMove/useSurfFieldMove). - table.insert(items, { label = "CUT", action = "cut" }) - elseif mv.id == "SURF" and self.game.save.inventory.SOULBADGE then - table.insert(items, { label = "SURF", action = "surf" }) - elseif mv.id == "STRENGTH" and self.game.save.inventory.RAINBOWBADGE then - table.insert(items, { label = "STRENGTH", action = "strength" }) - elseif mv.id == "SOFTBOILED" then - table.insert(items, { label = "SOFTBOILED", action = "softboiled" }) - elseif mv.id == "TELEPORT" and outside then - -- TELEPORT works only OUTDOORS (start_sub_menus.asm - -- .teleport -> CheckIfInOutsideMap); dark maps don't - -- block it - table.insert(items, { label = "TELEPORT", action = "escape" }) - elseif mv.id == "DIG" and DIG_TILESETS[ow.map.def.tileset] - and ow.map.id ~= "AGATHAS_ROOM" then - -- DIG runs ItemUseEscapeRope (.dig sets wCurItem = - -- ESCAPE_ROPE): usable in the dungeon tilesets of - -- escape_rope_tilesets.asm minus Agatha's room, even in - -- the dark (Rock Tunnel) - table.insert(items, { label = "DIG", action = "escape" }) + if self.battle and self.onSwitch then + -- SwitchStatsCancelText (core.asm PartyMenuOrRockOrRun) + items = { { label = "SWITCH", action = "battle_switch" }, + { label = "STATS", action = "stats" }, + { label = "CANCEL", action = "cancel" } } + else + -- STATS/SWITCH plus this mon's field moves (start_sub_menus.asm + -- builds the same dynamic list) + items = { { label = "STATS", action = "stats" }, + { label = "SWITCH", action = "switch" } } + -- Field moves (HMs/TMs) are usable out of battle even when the mon + -- is fainted -- Gen 1 does not require HP for Cut/Fly/Surf/etc. + -- Battle still excludes this list via `not self.battle`. Softboiled + -- can appear for a fainted user; its heal transfer then no-ops. + if not self.battle and ow then + -- FLY/TELEPORT: CheckIfInOutsideMap (OVERWORLD + PLATEAU — + -- Route 23 / Indigo Plateau outdoor), not OVERWORLD alone (#83) + local outside = Map.isOutside(ow.map.def, + FieldDefaults.field(self.game.data, "outsideTilesets")) + for _, mv in ipairs(mon.moves) do + if mv.id == "FLY" and outside + and self.game.save.inventory.THUNDERBADGE then + table.insert(items, { label = "FLY", action = "fly" }) + elseif mv.id == "FLASH" and ow.dark + and self.game.save.inventory.BOULDERBADGE then + table.insert(items, { label = "FLASH", action = "flash" }) + elseif mv.id == "CUT" and self.game.save.inventory.CASCADEBADGE then + -- CUT/SURF/STRENGTH are party-menu field moves too + -- (start_sub_menus.asm .outOfBattleMovePointers); listed here + -- with the same list-time badge filter this file already uses + -- for FLY/FLASH. The facing-tile/activation check happens on + -- selection (useCutFieldMove/useSurfFieldMove). + table.insert(items, { label = "CUT", action = "cut" }) + elseif mv.id == "SURF" and self.game.save.inventory.SOULBADGE then + table.insert(items, { label = "SURF", action = "surf" }) + elseif mv.id == "STRENGTH" and self.game.save.inventory.RAINBOWBADGE then + table.insert(items, { label = "STRENGTH", action = "strength" }) + elseif mv.id == "SOFTBOILED" then + table.insert(items, { label = "SOFTBOILED", action = "softboiled" }) + elseif mv.id == "TELEPORT" and outside then + -- TELEPORT works only OUTDOORS (start_sub_menus.asm + -- .teleport -> CheckIfInOutsideMap); dark maps don't + -- block it + table.insert(items, { label = "TELEPORT", action = "escape" }) + elseif mv.id == "DIG" and DIG_TILESETS[ow.map.def.tileset] + and ow.map.id ~= "AGATHAS_ROOM" then + -- DIG runs ItemUseEscapeRope (.dig sets wCurItem = + -- ESCAPE_ROPE): usable in the dungeon tilesets of + -- escape_rope_tilesets.asm minus Agatha's room, even in + -- the dark (Rock Tunnel) + table.insert(items, { label = "DIG", action = "escape" }) + end end end end diff --git a/src/ui/PlayerPC.lua b/src/ui/PlayerPC.lua index 6a9f9379..1f11aebe 100644 --- a/src/ui/PlayerPC.lua +++ b/src/ui/PlayerPC.lua @@ -71,6 +71,7 @@ end local function withdraw(game) local pc = game.save.pcItems game.stack:push(ListMenu.new(game, "WITHDRAW ITEM", buildItems(game, pc), { + messageBox = true, onChoose = function(item, list) askQuantity(game, list, pc[item.value] or 1, item.value, function(qty) local Bag = require("src.inventory.Bag") @@ -107,6 +108,7 @@ local function deposit(game) if not Bag.isBadge(id) then depositable[id] = count end end game.stack:push(ListMenu.new(game, "DEPOSIT ITEM", buildItems(game, depositable), { + messageBox = true, onChoose = function(item, list) askQuantity(game, list, inv[item.value] or 1, item.value, function(qty) if pcFull(game, pc, item.value) then @@ -126,6 +128,7 @@ end local function toss(game) local pc = game.save.pcItems game.stack:push(ListMenu.new(game, "TOSS ITEM", buildItems(game, pc), { + messageBox = true, onChoose = function(item, list) local def = game.data.items[item.value] if (def and def.keyItem) or item.value:find("^HM_") then @@ -161,9 +164,9 @@ function PlayerPC.new(game) { label = "DEPOSIT ITEM", onSelect = function() deposit(game) end }, { label = "TOSS ITEM", onSelect = function() toss(game) end }, { label = "LOG OFF" }, - -- the whole PC session runs silent (BIT_NO_MENU_BUTTON_SOUND, - -- engine/menus/players_pc.asm PlayersPCMenu) - }, { tx = 3, ty = 0, tw = 17, th = 10, noSound = true }) + -- silent PC session (BIT_NO_MENU_BUTTON_SOUND); players_pc.asm + -- PlayersPCMenu TextBoxBorder (0,0) b=8 c=14 → 16x10 + }, { tx = 0, ty = 0, tw = 16, th = 10, noSound = true }) end return PlayerPC diff --git a/src/ui/QuantityBox.lua b/src/ui/QuantityBox.lua index e7a73705..b763d4f1 100644 --- a/src/ui/QuantityBox.lua +++ b/src/ui/QuantityBox.lua @@ -40,15 +40,19 @@ function QuantityBox:update(dt) end function QuantityBox:draw() - local w = self.unitPrice and 11 or 7 - local tx = 20 - w - 1 - Font.drawBox(tx, 13, w, 3) + -- DisplayChooseQuantityMenu (home/list_menu.asm): non-priced box at + -- hlcoord 15,9 (interior 3x1); priced at hlcoord 7,9 (interior 11x1). + -- TextBoxBorder adds the frame, so outer size is +2 on each axis. + local tw = self.unitPrice and 13 or 5 + local tx = self.unitPrice and 7 or 15 + local ty = 9 + Font.drawBox(tx, ty, tw, 3) love.graphics.setColor(0, 0, 0, 1) local s = ("×%02d"):format(self.qty) -- the multiply glyph tile if self.unitPrice then s = s .. (" ¥%d"):format(self.qty * self.unitPrice) end - Font.draw(s, (tx + 1) * 8, 14 * 8) + Font.draw(s, (tx + 1) * 8, (ty + 1) * 8) love.graphics.setColor(1, 1, 1, 1) end diff --git a/src/ui/Theme.lua b/src/ui/Theme.lua index c38181f2..c72992d1 100644 --- a/src/ui/Theme.lua +++ b/src/ui/Theme.lua @@ -15,7 +15,8 @@ local Theme = { cols = Renderer.WIDTH / 8, rows = Renderer.HEIGHT / 8, textBox = { tx = 0, ty = 12, tw = 20, th = 6, maxCols = 18 }, - choiceBox = { tx = 0, ty = 7, tw = 6, th = 5 }, + -- InitYesNoTextBoxParameters / AskName: hlcoord 14, 7 (YES_NO_MENU 4x3) + choiceBox = { tx = 14, ty = 7, tw = 6, th = 5 }, } function Theme.load(data) diff --git a/src/ui/TradeAnim.lua b/src/ui/TradeAnim.lua index 137854ee..7ead3d55 100644 --- a/src/ui/TradeAnim.lua +++ b/src/ui/TradeAnim.lua @@ -1,8 +1,7 @@ --- Link/in-game trade cinematic (engine/movie/trade.asm, trade2.asm): --- the traded POKéMON rises away with its cry and a goodbye, then the --- received one descends with its cry and "take good care" text. --- A skips the slide animations ahead. Calls onDone() after popping. +-- InternalClockTradeAnim (engine/movie/trade.asm): cable-trade cinematic +-- used by in-game NPC trades and internally-clocked link trades. +local Font = require("src.render.Font") local Sound = require("src.core.Sound") local TextBox = require("src.render.TextBox") @@ -10,13 +9,22 @@ local TradeAnim = {} TradeAnim.__index = TradeAnim TradeAnim.isOpaque = true --- SGB: generic whole-screen palette (SET_PAL_GENERIC) function TradeAnim:sgbPalettes(game) return require("src.render.PaletteFX").wholeNamed(game.data, "MEWMON") end -local SLIDE_FRAMES = 90 -local REST_Y = 44 -- resting top of the sprite, roughly screen centre +local DEFAULT_ART = { + gameBoy = "assets/generated/trade/game_boy.png", + openCable = "assets/generated/trade/open_cable.png", + cableHoriz = "assets/generated/trade/cable_horiz.png", + cableConn = "assets/generated/trade/cable_conn.png", + cableVert = "assets/generated/trade/cable_vert.png", + cableCorner = "assets/generated/trade/cable_corner.png", + cableEnd = "assets/generated/trade/cable_end.png", + cableBall = "assets/generated/trade/cable_ball.png", + cableBallAlt = "assets/generated/trade/cable_ball_alt.png", + bubble = "assets/generated/trade/bubble.png", +} local function tryImage(path) if not path then return nil end @@ -29,11 +37,46 @@ local function nameOf(game, mon) return mon.nickname or (def and def.name) or mon.species end +local function speciesName(game, mon) + local def = game.data.pokemon[mon.species] + return (def and def.name) or mon.species +end + +local function dexOf(game, mon) + local def = game.data.pokemon[mon.species] + return def and def.dex or 0 +end + local function spriteOf(game, mon) local def = game.data.pokemon[mon.species] return tryImage(def and def.spriteFront) end +local function expand(game, key, subs) + local raw = game.data.text and game.data.text[key] + if not raw then return key end + for token, value in pairs(subs or {}) do + raw = raw:gsub("{" .. token .. "}", value) + end + return TextBox.substitute(game, raw) +end + +-- InternalClockTradeFuncSequence +local SEQ = { + "show_player", + "open_cable", + "ball_enter", + "transfer_lr", + "delay", + "went_to", + "for_sends", + "farewell", + "transfer_rl", + "open_cable2", + "show_enemy", + "done", +} + function TradeAnim.new(game, opts) opts = opts or {} local self = setmetatable({}, TradeAnim) @@ -41,62 +84,401 @@ function TradeAnim.new(game, opts) self.sent = opts.sent self.received = opts.received self.onDone = opts.onDone + self.enemyName = opts.enemyName or (self.received and self.received.ot) or "TRAINER" + self.playerName = (game.save.player and game.save.player.name) or "RED" + self.playerOt = opts.playerOt or self.playerName + self.playerOtId = opts.playerOtId + or (self.sent and self.sent.otId) + or (game.save.player and game.save.player.id) + or 0 + self.enemyOtId = opts.enemyOtId + or (self.received and self.received.otId) + or love.math.random(0, 65535) + + local art = (game.data.field and game.data.field.tradeArt) or DEFAULT_ART + self.img = { + gameBoy = tryImage(art.gameBoy or DEFAULT_ART.gameBoy), + openCable = tryImage(art.openCable or DEFAULT_ART.openCable), + cableHoriz = tryImage(art.cableHoriz or DEFAULT_ART.cableHoriz), + cableConn = tryImage(art.cableConn or DEFAULT_ART.cableConn), + cableVert = tryImage(art.cableVert or DEFAULT_ART.cableVert), + cableCorner = tryImage(art.cableCorner or DEFAULT_ART.cableCorner), + cableEnd = tryImage(art.cableEnd or DEFAULT_ART.cableEnd), + cableBall = tryImage(art.cableBall or DEFAULT_ART.cableBall), + cableBallAlt = tryImage(art.cableBallAlt or DEFAULT_ART.cableBallAlt), + bubble = tryImage(art.bubble or DEFAULT_ART.bubble), + } self.sentSprite = spriteOf(game, self.sent) - self.receivedSprite = spriteOf(game, self.received) - self.phase = "out" + self.recvSprite = spriteOf(game, self.received) + + self.seq = 1 + self.phase = SEQ[1] self.t = 0 + self.scx = 0 + self.ballX = 0 + self.ballY = 0 + self.monX = 0 + self.monY = 0 + self.flash = false + self.monVisible = true + self.waitingText = false + self.cableFlash = false return self end function TradeAnim:enter() - Sound.playCry(self.game.data, self.sent.species) + Sound.play(self.game.data, "Trade_Machine") +end + +function TradeAnim:advance() + self.seq = self.seq + 1 + self.phase = SEQ[self.seq] or "done" + self.t = 0 + self.scx = 0 + self.sub = nil + self.flash = false + self.cableFlash = false + self.poof = nil + if self.phase == "done" then + self.game.stack:pop() + if self.onDone then self.onDone() end + elseif self.phase == "show_enemy" then + self.monVisible = false + elseif self.phase == "transfer_lr" then + -- wBaseCoord $54, $1c OAM -> screen (76, 12) + self.monX, self.monY = 76, 12 + elseif self.phase == "transfer_rl" then + -- wBaseCoord $64, $44 OAM -> screen (92, 52), right GB on screen + self.monX, self.monY = 92, 52 + self.scx = 160 + elseif self.phase == "ball_enter" then + -- lb bc, $20, $60: b = Y, c = X (Trade_AnimateBallEnteringLinkCable) + self.ballX, self.ballY = 0x60, 0x20 + elseif self.phase == "open_cable" or self.phase == "open_cable2" then + -- SCX $a0 -> $f0: cable slides in from the right, open end rests at x64 + self.scx = 0x50 + Sound.play(self.game.data, "Heal_HP") + end +end + +function TradeAnim:say(text, delay, thenFn) + self.waitingText = true + self.game.stack:push(TextBox.new(self.game, text, function() + self.waitingText = false + if thenFn then thenFn() else self:advance() end + end, { auto = { delay = delay or 80 } })) +end + +function TradeAnim:skipHeld() + return self.game.input:wasPressed("a") or self.game.input:isDown("a") end function TradeAnim:update(dt) - local input = self.game.input - if self.phase == "out" or self.phase == "in" then - self.t = self.t + 1 - if input:wasPressed("a") then self.t = SLIDE_FRAMES end - if self.t < SLIDE_FRAMES then return end - if self.phase == "out" then - self.phase = "goodbye" - self.game.stack:push(TextBox.new(self.game, - ("Goodbye %s!"):format(nameOf(self.game, self.sent)), - function() - self.phase = "in" - self.t = 0 - Sound.playCry(self.game.data, self.received.species) - end)) + if self.waitingText or self.phase == "done" then return end + local skip = self.game.input:wasPressed("a") + self.t = self.t + 1 + local p = self.phase + + if p == "show_player" then + -- slide from SCX $86 -> 0, hold 80, poof, cry (Trade_ShowPlayerMon) + if not self.sub then self.sub = "slide" end + if self.sub == "slide" then + self.scx = math.max(0, 0x86 - self.t * 2) + if skip or self.scx <= 0 then + self.scx = 0 + self.sub = "hold" + self.t = 0 + end + elseif self.sub == "hold" then + if skip or self.t >= 80 then + self.sub = "poof" + self.t = 0 + self.monVisible = false + Sound.play(self.game.data, "Ball_Poof") + end + elseif self.sub == "poof" then + self.poof = math.max(0, 16 - self.t) + if skip or self.t >= 16 then + Sound.playCry(self.game.data, self.sent.species) + self.sub = nil + self:advance() + end + end + + elseif p == "open_cable" or p == "open_cable2" then + -- 20 steps of 4px, matching the SCX loop + self.scx = math.max(0, 0x50 - self.t * 4) + if skip or self.scx <= 0 then + self.scx = 0 + self:advance() + end + + elseif p == "ball_enter" then + -- TRADE_BALL_SHAKE then ball rides the cable; X from $60 toward $a0 + if self.t < 20 then + if skip then self.t = 20 end + return + end + local step = self.t - 20 + if step % 3 == 0 then + self.ballX = 0x60 + math.floor(step / 3) * 4 + self.flash = not self.flash + if self.ballX < 0xA0 then Sound.play(self.game.data, "Tink") end + end + if skip then self.ballX = 0xA0 end + if self.ballX >= 0xA0 then self:advance() end + + elseif p == "transfer_lr" then + -- scroll left GB off while the mon rides the cable, then the sprite + -- itself moves right and down into the right GB (Trade_AnimMonMoveVertical) + if self.t <= 80 then + self.scx = math.min(160, self.t * 2) + self.monX, self.monY = 76, 12 + elseif self.t <= 80 + 32 then + self.monX = 76 + math.floor((self.t - 80) / 8) * 4 + elseif self.t <= 80 + 64 then + self.monX = 92 + self.monY = 12 + math.floor((self.t - 112) / 8) * 10 else - self.phase = "takecare" - self.game.stack:push(TextBox.new(self.game, - ("Take good care\nof %s!"):format(nameOf(self.game, self.received)), - function() - self.game.stack:pop() - if self.onDone then self.onDone() end - end)) + self:advance() + return + end + if self.t % 8 == 0 then self.cableFlash = not self.cableFlash end + if skip then self:advance() end + + elseif p == "transfer_rl" then + -- mirror: sprite climbs out of the right GB, then the screen scrolls + -- back to the left GB + if self.t <= 32 then + self.scx = 160 + self.monX, self.monY = 92, 52 - math.floor(self.t / 8) * 10 + elseif self.t <= 64 then + self.monX = 92 - math.floor((self.t - 32) / 8) * 4 + self.monY = 12 + elseif self.t <= 64 + 80 then + self.scx = math.max(0, 160 - (self.t - 64) * 2) + self.monX, self.monY = 76, 12 + else + self:advance() + return + end + if self.t % 8 == 0 then self.cableFlash = not self.cableFlash end + if skip then self:advance() end + + elseif p == "delay" then + if skip or self.t >= 100 then self:advance() end + + elseif p == "went_to" then + local text = expand(self.game, "_TradeWentToText", { + ["RAM:wStringBuffer"] = speciesName(self.game, self.sent), + ["RAM:wLinkEnemyTrainerName"] = self.enemyName, + }) + self:say(text, 200) + + elseif p == "for_sends" then + local a = expand(self.game, "_TradeForText", { + ["RAM:wStringBuffer"] = speciesName(self.game, self.sent), + }) + local b = expand(self.game, "_TradeSendsText", { + ["RAM:wLinkEnemyTrainerName"] = self.enemyName, + ["RAM:wNameBuffer"] = nameOf(self.game, self.received), + }) + self.waitingText = true + self.game.stack:push(TextBox.new(self.game, a, function() + self.game.stack:push(TextBox.new(self.game, b, function() + self.waitingText = false + self:advance() + end, { auto = { delay = 80 } })) + end, { auto = { delay = 80 } })) + + elseif p == "farewell" then + local a = expand(self.game, "_TradeWavesFarewellText", { + ["RAM:wLinkEnemyTrainerName"] = self.enemyName, + }) + local b = expand(self.game, "_TradeTransferredText", { + ["RAM:wNameBuffer"] = nameOf(self.game, self.received), + }) + self.waitingText = true + self.game.stack:push(TextBox.new(self.game, a, function() + self.game.stack:push(TextBox.new(self.game, b, function() + self.waitingText = false + self:advance() + end, { auto = { delay = 80 } })) + end, { auto = { delay = 80 } })) + + elseif p == "show_enemy" then + if self.t == 1 then + Sound.play(self.game.data, "Ball_Poof") + self.monVisible = true + elseif self.t == 20 then + Sound.playCry(self.game.data, self.received.species) + elseif self.t >= 120 or skip then + local text = expand(self.game, "_TradeTakeCareText", { + ["RAM:wNameBuffer"] = nameOf(self.game, self.received), + }) + self:say(text, 80) end end end +local function drawCableHoriz(self, y, x0, x1) + if self.img.cableHoriz then + love.graphics.draw(self.img.cableHoriz, x0 - (self.scx % 8), y) + else + love.graphics.setColor(0.2, 0.2, 0.2, 1) + love.graphics.rectangle("fill", x0, y + 1, math.max(0, x1 - x0), 6) + love.graphics.setColor(1, 1, 1, 1) + end +end + +function TradeAnim:drawMonInfo(mon, ot, otId, boxTy) + -- Trade_PrintPlayerMonInfoText: rows +0/+2/+4/+6 from the box top; the + -- No. line replaces part of the top border (hlcoord 5, 0 in trade2.asm) + Font.drawBox(4, boxTy, 12, 8) + local y0 = boxTy * 8 + local no = ("No.%03d"):format(dexOf(self.game, mon)) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.rectangle("fill", 56, y0, Font.width(no), 8) + love.graphics.setColor(0, 0, 0, 1) + Font.draw(no, 56, y0) + Font.draw(speciesName(self.game, mon), 40, y0 + 16) + Font.draw("OT/" .. (ot or "????"), 40, y0 + 32) + Font.draw(("IDNo.%05d"):format(otId or 0), 40, y0 + 48) + love.graphics.setColor(1, 1, 1, 1) +end + +function TradeAnim:drawIconInBubble(mon, x, y) + local spr = (mon.species == self.received.species) and self.recvSprite or self.sentSprite + if spr then + local sw, sh = spr:getDimensions() + local s = 16 / math.max(sw, sh) + love.graphics.draw(spr, x, y, 0, s, s) + else + love.graphics.setColor(0, 0, 0, 1) + love.graphics.rectangle("fill", x + 4, y + 4, 8, 8) + love.graphics.setColor(1, 1, 1, 1) + end + if self.img.bubble then + if not self.bubbleQuad then + local iw, ih = self.img.bubble:getDimensions() + self.bubbleQuad = love.graphics.newQuad(0, 0, 16, 16, iw, ih) + self.bubbleQuadAlt = ih >= 32 + and love.graphics.newQuad(0, 16, 16, 16, iw, ih) + or self.bubbleQuad + end + local q = self.cableFlash and self.bubbleQuadAlt or self.bubbleQuad + love.graphics.draw(self.img.bubble, q, x - 8, y - 8) + end +end + +function TradeAnim:drawGameBoy(x, y) + if self.img.gameBoy then + love.graphics.draw(self.img.gameBoy, x, y) + else + love.graphics.setColor(0, 0, 0, 1) + love.graphics.rectangle("line", x, y, 48, 64) + love.graphics.setColor(1, 1, 1, 1) + end +end + +function TradeAnim:drawLeftGB() + -- cable from GB to the right edge + if self.img.cableConn then + love.graphics.draw(self.img.cableConn, 88, 32) + end + drawCableHoriz(self, 32, 96, 160) + self:drawGameBoy(40, 24) + Font.drawBox(4, 12, 9, 4) + love.graphics.setColor(0, 0, 0, 1) + Font.draw(self.playerName, 40, 112) + love.graphics.setColor(1, 1, 1, 1) +end + +function TradeAnim:drawRightGB() + drawCableHoriz(self, 32, 0, 112) + if self.img.cableCorner then love.graphics.draw(self.img.cableCorner, 112, 32) end + if self.img.cableVert then + for i = 1, 4 do + love.graphics.draw(self.img.cableVert, 120, 40 + (i - 1) * 8) + end + end + if self.img.cableEnd then love.graphics.draw(self.img.cableEnd, 112, 72) end + if self.img.cableConn then love.graphics.draw(self.img.cableConn, 104, 72) end + self:drawGameBoy(56, 64) + Font.drawBox(6, 0, 9, 4) + love.graphics.setColor(0, 0, 0, 1) + Font.draw(self.enemyName, 56, 16) + love.graphics.setColor(1, 1, 1, 1) +end + function TradeAnim:draw() love.graphics.setColor(1, 1, 1, 1) love.graphics.rectangle("fill", 0, 0, 160, 144) - local sprite, y - if self.phase == "out" then - -- the sent mon rises up and off the screen - sprite = self.sentSprite - y = REST_Y - math.floor((self.t / SLIDE_FRAMES) * (REST_Y + 60)) - elseif self.phase == "in" or self.phase == "takecare" then - -- the received mon descends into place - sprite = self.receivedSprite - local t = self.phase == "in" and self.t or SLIDE_FRAMES - y = -60 + math.floor((t / SLIDE_FRAMES) * (REST_Y + 60)) - end - if sprite and y then - local w = sprite:getWidth() - love.graphics.draw(sprite, math.floor((160 - w) / 2), y) + local p = self.phase + + if p == "show_player" then + love.graphics.push() + love.graphics.translate(-self.scx, 0) + -- mon pic on the BG at hlcoord 7, 2; info box on the window at + -- hWY $50, so it sits in the bottom half of the screen + if self.monVisible and self.sentSprite then + love.graphics.draw(self.sentSprite, 56, 16) + end + self:drawMonInfo(self.sent, self.playerOt, self.playerOtId, 10) + love.graphics.pop() + if self.poof and self.poof > 0 then + love.graphics.setColor(0, 0, 0, self.poof / 16) + love.graphics.circle("fill", 80, 40, 20 - (self.poof or 0)) + love.graphics.setColor(1, 1, 1, 1) + end + + elseif p == "open_cable" or p == "open_cable2" then + love.graphics.push() + love.graphics.translate(self.scx, 0) + if self.img.openCable then + love.graphics.draw(self.img.openCable, 64, 16) -- open end at SCX $f0 + end + love.graphics.pop() + + elseif p == "ball_enter" then + if self.img.openCable then + love.graphics.draw(self.img.openCable, 64, 16) + end + -- OAM coords carry a (+8, +16) hardware offset + local ball = self.flash and (self.img.cableBallAlt or self.img.cableBall) + or self.img.cableBall + if ball then + love.graphics.draw(ball, self.ballX - 8, self.ballY - 16) + else + love.graphics.setColor(0, 0, 0, 1) + love.graphics.circle("fill", self.ballX, self.ballY - 8, 6) + love.graphics.setColor(1, 1, 1, 1) + end + + elseif p == "transfer_lr" or p == "transfer_rl" then + -- two-screen world: left GB scene at x0, right GB scene at x160; + -- the mon icon stays in screen space like the OAM sprite it ports + love.graphics.push() + love.graphics.translate(-self.scx, 0) + self:drawLeftGB() + love.graphics.translate(160, 0) + self:drawRightGB() + love.graphics.pop() + local mon = (p == "transfer_lr") and self.sent or self.received + self:drawIconInBubble(mon, self.monX, self.monY) + if self.cableFlash then + love.graphics.setColor(1, 1, 1, 0.15) + love.graphics.rectangle("fill", 0, 32, 160, 8) + love.graphics.setColor(1, 1, 1, 1) + end + + elseif p == "show_enemy" then + if self.monVisible and self.recvSprite then + love.graphics.draw(self.recvSprite, 56, 16) + end + self:drawMonInfo(self.received, self.enemyName, self.enemyOtId, 10) end + -- went_to / for_sends / farewell / delay: cleared window; TextBox draws end return TradeAnim diff --git a/src/ui/TrainerCard.lua b/src/ui/TrainerCard.lua index 7f7f78a8..7493d2c5 100644 --- a/src/ui/TrainerCard.lua +++ b/src/ui/TrainerCard.lua @@ -34,7 +34,8 @@ function TrainerCard.new(game) local self = setmetatable({ game = game }, TrainerCard) local img = tryImage("assets/generated/trainer_card/badges.png") if img then - -- 8 pairs of [gym leader face, badge] + -- badges.2bpp is 8 stacked [face, badge] pairs (DrawBadges FaceBadgeTiles) + self.faces = { img = img, quads = quads16(img, 8, 32, 0, 0) } self.badges = { img = img, quads = quads16(img, 8, 32, 0, 16) } end local nums = tryImage("assets/generated/trainer_card/badge_numbers.png") @@ -124,7 +125,7 @@ function TrainerCard:draw() love.graphics.setColor(0, 0, 0, 1) end - -- numbered badge grid (rows 11-17): earned solid, unearned dimmed + -- numbered badge grid (rows 11-17): face by default, badge when owned self:frameBox(0, 11, 20, 7) local badges = Badges.list(self.game.data) for i = 1, #badges do @@ -136,12 +137,11 @@ function TrainerCard:draw() love.graphics.setColor(1, 1, 1, 1) love.graphics.draw(self.nums.img, self.nums.quads[i - 1], tx, ty) end - if self.badges and self.badges.quads[i - 1] - and save.inventory[Badges.itemFor(badges[i])] then - -- unearned badge slots stay blank (DrawBadges) + if self.faces and self.faces.quads[i - 1] then love.graphics.setColor(1, 1, 1, 1) - love.graphics.draw(self.badges.img, self.badges.quads[i - 1], - tx + 4, ty + 6) + local owned = save.inventory[Badges.itemFor(badges[i])] + local sheet = owned and self.badges or self.faces + love.graphics.draw(sheet.img, sheet.quads[i - 1], tx + 4, ty + 6) end end love.graphics.setColor(1, 1, 1, 1) diff --git a/src/update/Boot.lua b/src/update/Boot.lua new file mode 100644 index 00000000..3c706c4e --- /dev/null +++ b/src/update/Boot.lua @@ -0,0 +1,263 @@ +-- The boot shell: the heart of the self-updater. A fused build, before it +-- runs the game bundled inside it, looks in its save directory for a newer +-- payload (a downloaded gen1recomp-X.Y.Z.love), and if one is present and +-- runnable, mounts it over the bundled source and chainloads it -- so the +-- binary shipped once can keep updating the Lua it runs without a reinstall. +-- +-- Only a fused build self-updates. A dev / source checkout IS the game, so +-- Boot.run is a no-op there. +-- +-- Three pieces, deliberately layered so the risky part is small and the +-- decision part is testable: +-- * Boot.select -- pure: given probed candidates + the bundled version, +-- decide what to run and what to delete. No love.*. +-- * Boot.probePayload-- read one archive's advertised version, isolated. +-- * Boot.run -- orchestrates: crash-guard, enumerate, select, and +-- (if a payload wins) mount + chainload with full +-- rollback on any failure so the bundled game always +-- boots. +-- +-- Known limitation: the bundled love.run keeps driving the frame loop after a +-- handoff (it has already returned its stepper to LÖVE; redefining the global +-- love.run does nothing to the running one). A payload that must change +-- love.run itself therefore requires a minShell bump so an older shell refuses +-- to chainload it. + +local Semver = require("src.update.Semver") + +local Boot = {} + +-- Save-directory layout (identity "pokemon-love2d"), per the shared contract. +local PAYLOAD_DIR = "updates" +local PENDING = "updates/pending.txt" + +-- Isolated mountpoint used only to peek at a candidate's Version.lua, so its +-- copy never collides with the running source's copy at "/". +local PROBE_MOUNT = "__pokeport_probe" + +-- Downloaded payloads are named gen1recomp-.love. +local function isPayloadName(name) + return name:match("^gen1recomp%-.+%.love$") ~= nil +end + +-- The love callbacks the payload's main.lua chunk may redefine when it runs. +-- We snapshot these before a handoff and restore them if the handoff fails, so +-- the exact bundled closures (with their intact upvalues) drive the game +-- again. love.run is included: harmless to restore, and it is one of the +-- globals a payload main.lua reassigns. +local CALLBACK_NAMES = { + "load", "update", "draw", "quit", "run", + "keypressed", "keyreleased", "textinput", + "mousepressed", "mousereleased", "mousemoved", "wheelmoved", + "touchpressed", "touchmoved", "touchreleased", + "gamepadpressed", "gamepadreleased", "gamepadaxis", "joystickremoved", + "focus", "visible", "resize", "filedropped", "directorydropped", + "errorhandler", "threaderror", "lowmemory", +} + +local function snapshotCallbacks() + local snap = {} + for _, k in ipairs(CALLBACK_NAMES) do snap[k] = love[k] end + return snap +end + +local function restoreCallbacks(snap) + for _, k in ipairs(CALLBACK_NAMES) do love[k] = snap[k] end +end + +-- Drop every bundled Lua module the payload must be allowed to re-resolve: all +-- src.* modules plus the main/conf chunks. conf.lua cached the bundled +-- Version into package.loaded["src.core.Version"]; without this purge the next +-- require would hand back the bundled copy instead of the payload's. Setting +-- existing fields to nil during a pairs traversal is explicitly permitted. +local function purgeBundledModules() + for key in pairs(package.loaded) do + if key:match("^src%.") or key == "main" or key == "conf" then + package.loaded[key] = nil + end + end +end + +-- Boot.probePayload(rel) -> { engine = string, minShell = number } | nil, err +-- +-- Mount the archive at rel (a save-directory-relative path) on an isolated +-- mountpoint, read its src/core/Version.lua by executing the source with +-- loadstring (NEVER require -- we must not cache or run it as a module), then +-- unmount. Version.lua is zero-require, so running its chunk is safe. +function Boot.probePayload(rel) + if not love.filesystem.mount(rel, PROBE_MOUNT) then + return nil, "could not mount " .. tostring(rel) + end + local chunkPath = PROBE_MOUNT .. "/src/core/Version.lua" + local ok, result = pcall(function() + local src = love.filesystem.read(chunkPath) + if not src then error("Version.lua missing", 0) end + local chunk = loadstring(src, "@" .. chunkPath) + if not chunk then error("Version.lua would not compile", 0) end + return chunk() + end) + love.filesystem.unmount(rel) + if not ok then return nil, tostring(result) end + local v = result + if type(v) ~= "table" or type(v.engine) ~= "string" then + return nil, "payload has no usable Version table" + end + return { engine = v.engine, minShell = tonumber(v.minShell) or 1 } +end + +-- Boot.select(candidates, bundledEngine, bundledShell) -> chosen | nil, toDelete +-- +-- Pure (no love.*): decide which payload to run and which to delete. +-- candidates is a list of { name = , engine = , minShell = }. +-- * chosen: the highest engine that is STRICTLY newer than bundledEngine and +-- whose minShell <= bundledShell (a payload the running shell can host). +-- * toDelete: stale payloads -- engine <= bundled (old or the same as what we +-- already ship), or superseded by the chosen one (not newer than chosen). +-- A payload newer than the chosen one but unrunnable here (minShell too +-- high) is kept: a future shell upgrade may be able to run it. +function Boot.select(candidates, bundledEngine, bundledShell) + local chosen + for _, c in ipairs(candidates) do + local newer = Semver.compare(c.engine, bundledEngine) > 0 + local runnable = (c.minShell or 1) <= bundledShell + if newer and runnable then + if not chosen or Semver.compare(c.engine, chosen.engine) > 0 then + chosen = c + end + end + end + + local toDelete = {} + for _, c in ipairs(candidates) do + if not (chosen and c.name == chosen.name) then + local stale = Semver.compare(c.engine, bundledEngine) <= 0 + if chosen and Semver.compare(c.engine, chosen.engine) <= 0 then + stale = true + end + if stale then toDelete[#toDelete + 1] = c.name end + end + end + + return chosen and chosen.name or nil, toDelete +end + +-- Mount the chosen payload and hand control to it. Returns true when the +-- payload is live and has completed its own love.load; false (with full +-- rollback) on any failure, so the caller runs the bundled game instead. +local function chainload(name, args) + local rel = PAYLOAD_DIR .. "/" .. name + + -- Crash marker: if we die between here and clearing it, the next boot's + -- crash guard distrusts this payload and deletes it. + love.filesystem.write(PENDING, name) + + -- Prepend-mount the payload at "/" (appendToPath = false) so its files win + -- over the fused source for every subsequent require / love.filesystem read. + if not love.filesystem.mount(rel, "/", false) then + love.filesystem.remove(PENDING) + return false + end + + local snapshot = snapshotCallbacks() + purgeBundledModules() + _G.POKEPORT_PAYLOAD_MOUNTED = true + + -- Chainload: run the payload's main.lua (redefines the love callbacks from + -- the NEW code), then call its love.load. The new love.load calls Boot.run + -- again, which no-ops via the flag set above. + local ok, err = pcall(function() + local chunk = assert(love.filesystem.load("main.lua")) + chunk() + love.load(args) + end) + + if not ok then + -- Handoff failed after mounting. Unwind everything so the bundled game + -- boots cleanly: clear the flag, unmount the payload, purge any payload + -- modules it cached (so bundled requires reload from source), restore the + -- bundled love callbacks with their intact upvalues, and drop the marker. + -- Delete the payload too: it failed deterministically once, so leaving it + -- would re-select and re-fail it on every boot forever. + print("update: payload handoff failed, reverting to bundled: " .. tostring(err)) + _G.POKEPORT_PAYLOAD_MOUNTED = nil + pcall(love.filesystem.unmount, rel) + purgeBundledModules() + restoreCallbacks(snapshot) + love.filesystem.remove(rel) + love.filesystem.remove(PENDING) + return false + end + + -- Success: the payload owns the game now. Drop the marker and tell the + -- caller to stop so the bundled love.load does not run on top of it. + love.filesystem.remove(PENDING) + return true +end + +-- Everything after the fused / flag guards, wrapped so an unexpected error in +-- enumeration or selection can never crash the boot. +local function runInner(args) + -- Crash guard first: a pending.txt naming a payload means a previous boot + -- crashed mid-handoff. Distrust that payload -- delete it and the marker -- + -- then continue (we may still pick an older valid payload, or fall through + -- to the bundled game). + local pending = love.filesystem.read(PENDING) + if pending then + pending = pending:gsub("%s+$", "") + if pending ~= "" then + love.filesystem.remove(PAYLOAD_DIR .. "/" .. pending) + end + love.filesystem.remove(PENDING) + end + + -- Enumerate and probe every payload in updates/. + local candidates = {} + if love.filesystem.getInfo(PAYLOAD_DIR, "directory") then + for _, entry in ipairs(love.filesystem.getDirectoryItems(PAYLOAD_DIR)) do + if isPayloadName(entry) then + local info = Boot.probePayload(PAYLOAD_DIR .. "/" .. entry) + if info then + candidates[#candidates + 1] = { + name = entry, + engine = info.engine, + minShell = info.minShell, + } + end + end + end + end + + local Version = require("src.core.Version") + local chosen, toDelete = Boot.select(candidates, Version.engine, Version.shell) + + for _, victim in ipairs(toDelete) do + love.filesystem.remove(PAYLOAD_DIR .. "/" .. victim) + end + + if not chosen then return false end + return chainload(chosen, args) +end + +-- Boot.run(args) -> boolean +-- +-- The first line of love.load. True means a payload was mounted and +-- chainloaded and the caller must return immediately; false means boot the +-- bundled game as normal. +function Boot.run(args) + -- Dev / source checkouts never self-update. + if not (love.filesystem.isFused and love.filesystem.isFused()) then + return false + end + -- The chainloaded love.load calls Boot.run again; the flag makes it a no-op. + if _G.POKEPORT_PAYLOAD_MOUNTED then return false end + + local ok, result = pcall(runInner, args) + if not ok then + -- An error escaped before any handoff mount (chainload cleans up after + -- itself), so state is still clean. Never crash the boot. + return false + end + return result +end + +return Boot diff --git a/src/update/Check.lua b/src/update/Check.lua new file mode 100644 index 00000000..fd98dabb --- /dev/null +++ b/src/update/Check.lua @@ -0,0 +1,177 @@ +-- Async release-check and payload-download for the self-update flow. +-- +-- The heavy lifting (curl calls, sha256 verification, the Boot gate) happens +-- on a background love.thread worker (src/update/check_worker.lua); this module +-- is only the thin main-thread state machine the UI polls. Two channels carry +-- the conversation: +-- "update_check_cmd" main -> worker: { cmd = "check" | "download" | "quit" } +-- "update_check_state" worker -> main: { status, latest, progress, error } +-- +-- Nothing here ever blocks or throws into the game loop: when love.thread is +-- absent (the headless test stub) or the worker cannot run (no curl, Android), +-- state() simply reports "error" and the UI hides itself. See the shared +-- contract in the task brief for the status vocabulary and the file layout. +-- +-- The release-JSON extraction and the sums parsing are exported as pure +-- functions (no love.* calls) so plain-Lua tests can cover them, and so the +-- worker can reuse the exact same code path via love.filesystem.load. + +local Check = {} + +Check.REPO = "bryanthaboi/pokemon-gen1-recomp-project" + +local CMD = "update_check_cmd" +local STATE = "update_check_state" + +-- --------------------------------------------------------------------------- +-- pure helpers (no love.*) -- also used inside the worker +-- --------------------------------------------------------------------------- + +-- Find the release asset named exactly `name`, returning its download URL and +-- byte size (or nil when the release has no such asset). +function Check.pickAsset(assets, name) + if type(assets) ~= "table" then return nil end + for _, a in ipairs(assets) do + if type(a) == "table" and a.name == name then + return { url = a.browser_download_url, size = tonumber(a.size) } + end + end + return nil +end + +local function stripV(tag) + return (tostring(tag):gsub("^[vV]", "")) +end + +-- Decode a GitHub "releases/latest" response into just the fields the updater +-- needs. Returns { version, payloadName, payload, sums } where payload/sums are +-- { url, size } tables (or nil when that asset is missing), or nil, err when the +-- document is not a release with a strict X.Y.Z tag. Json is injected so the +-- worker can pass a filesystem-loaded codec; on the main thread / in tests it +-- falls back to require. +function Check.parseRelease(jsonText, Json) + Json = Json or require("src.link.Json") + local doc = Json.decode(jsonText) + if type(doc) ~= "table" or not doc.tag_name then + return nil, "no tag_name in release json" + end + local version = stripV(doc.tag_name) + if not version:match("^%d+%.%d+%.%d+$") then + return nil, "release tag is not X.Y.Z: " .. tostring(doc.tag_name) + end + local payloadName = "gen1recomp-" .. version .. ".love" + return { + version = version, + payloadName = payloadName, + payload = Check.pickAsset(doc.assets, payloadName), + sums = Check.pickAsset(doc.assets, "sha256sums.txt"), + } +end + +-- Parse a shasum -a 256 file (" ", bare filenames). With a +-- `target` argument returns just that file's hash (or nil); otherwise returns +-- the whole name -> hash map. Tolerates the "*" binary marker and "./" prefix. +function Check.parseSums(text, target) + local map = {} + for line in tostring(text):gmatch("[^\r\n]+") do + local hash, file = line:match("^(%x+)%s+%*?(%S+)") + if hash and file then + map[(file:gsub("^%./", ""))] = hash:lower() + end + end + if target ~= nil then return map[target] end + return map +end + +-- --------------------------------------------------------------------------- +-- main-thread state machine +-- --------------------------------------------------------------------------- + +function Check.releaseUrl() + return "https://github.com/" .. Check.REPO .. "/releases/latest" +end + +local worker -- the love.thread, once started +local cmdCh, stateCh -- the two channels +local workerReady -- nil = untried, true = running, false = unavailable +local requested -- a check has been asked for this session +local cache = { status = "idle" } -- newest snapshot from the worker + +local function ensureWorker() + if workerReady ~= nil then return workerReady end + if not (love and love.thread and love.thread.newThread) then + workerReady = false + return false + end + local ok, th = pcall(love.thread.newThread, "src/update/check_worker.lua") + if not ok or not th then + workerReady = false + return false + end + cmdCh = love.thread.getChannel(CMD) + stateCh = love.thread.getChannel(STATE) + if not pcall(function() th:start() end) then + workerReady = false + return false + end + worker = th + workerReady = true + return true +end + +-- Pull every pending snapshot off the state channel (keeping the newest) and +-- surface a worker crash as a soft error the UI can hide on. +local function drain() + if stateCh then + local msg = stateCh:pop() + while msg do + cache = msg + msg = stateCh:pop() + end + end + if worker then + local err = worker:getError() + if err then + cache = { status = "error", error = tostring(err) } + end + end +end + +-- Begin (or, on a prior error, retry) an async check. Safe to call every frame: +-- once a check is in flight or has reached a terminal state it is a no-op. +function Check.start() + drain() + if cache.status == "checking" or cache.status == "downloading" then return end + if requested and cache.status ~= "error" and cache.status ~= "idle" then return end + if not ensureWorker() then + cache = { status = "error", error = "background threads unavailable" } + return + end + requested = true + cache = { status = "checking" } + cmdCh:push({ cmd = "check" }) +end + +-- Current snapshot: { status, latest, progress, error }. status is one of +-- idle | checking | uptodate | available | downloading | ready | needs_full | error. +function Check.state() + drain() + return { + status = cache.status or "idle", + latest = cache.latest, + progress = cache.progress, + error = cache.error, + } +end + +-- Start downloading the payload announced by an "available" check. A no-op in +-- any other state (the worker still holds the release info from the check). +function Check.download() + drain() + if not cmdCh then return end + if cache.status ~= "available" then return end + cache = { status = "downloading", latest = cache.latest, progress = 0 } + cmdCh:push({ cmd = "download" }) +end + +return Check diff --git a/src/update/Semver.lua b/src/update/Semver.lua new file mode 100644 index 00000000..eefee187 --- /dev/null +++ b/src/update/Semver.lua @@ -0,0 +1,53 @@ +-- Strict X.Y.Z semantic-version parsing and comparison for the self-updater. +-- Every part of the updater (Boot, Check, the release picker) agrees on this +-- one notion of "newer". Zero requires and no love.* calls, so plain-Lua +-- tests can exercise it and Boot can use it during the earliest boot step. +-- +-- We only need the numeric core (major.minor.patch): the engine field is a +-- bare X.Y.Z in shipped builds and the "0.0.0-dev" placeholder in the working +-- tree. Pre-release / build metadata is intentionally not supported -- a +-- "-dev" or any other suffix makes parse fail, which is the safe answer for +-- the updater (a dev checkout never counts as a real release to chainload). + +local Semver = {} + +-- Parse a strict "X.Y.Z" string (an optional leading "v" is allowed) into +-- { major = n, minor = n, patch = n }. Returns nil for anything else -- +-- extra components, non-numeric parts, or a trailing suffix like "-dev". +function Semver.parse(s) + if type(s) ~= "string" then return nil end + local body = s:match("^v?(.+)$") + if not body then return nil end + local maj, min, pat = body:match("^(%d+)%.(%d+)%.(%d+)$") + if not maj then return nil end + return { + major = tonumber(maj), + minor = tonumber(min), + patch = tonumber(pat), + } +end + +-- Coerce an argument that is either an already-parsed table or a version +-- string into a parsed table (or nil). +local function coerce(v) + if type(v) == "table" then return v end + return Semver.parse(v) +end + +-- Compare two versions, each a parsed table or an X.Y.Z string. +-- Returns -1 when a < b, 0 when equal, 1 when a > b. An unparseable side +-- sorts as the lowest possible version so a bogus value never wins a "newer" +-- test; two unparseable sides compare equal. +function Semver.compare(a, b) + local pa, pb = coerce(a), coerce(b) + if not pa and not pb then return 0 end + if not pa then return -1 end + if not pb then return 1 end + for _, field in ipairs({ "major", "minor", "patch" }) do + if pa[field] < pb[field] then return -1 end + if pa[field] > pb[field] then return 1 end + end + return 0 +end + +return Semver diff --git a/src/update/check_worker.lua b/src/update/check_worker.lua new file mode 100644 index 00000000..1f21d13a --- /dev/null +++ b/src/update/check_worker.lua @@ -0,0 +1,343 @@ +-- Background worker for the self-update flow (driven by src/update/Check.lua). +-- +-- Runs on a love.thread so no curl call, sha256 pass or archive probe ever +-- touches the render thread. Talks over two channels: +-- "update_check_cmd" in: { cmd = "check" | "download" | "quit" } +-- "update_check_state" out: { status, latest, progress, error } +-- +-- Transport is curl shelled out via io.popen (curl ships on macOS, Windows 10+ +-- and desktop Linux). Everything is wrapped so a missing curl, an HTTP error, +-- or a hung download degrades to a "error"/"needs_full" state rather than +-- blocking or crashing the game. On Android curl is absent and the check +-- soft-fails to "error", which the UI hides. +-- +-- Fresh love threads do not carry the "src.*" package searcher, so sibling +-- modules are pulled in with love.filesystem.load exactly like +-- src/core/chip_worker.lua does. Semver and Boot are authored in parallel; we +-- load them defensively and degrade (a local semver fallback, a permissive +-- gate) if they are not present yet. + +require("love.thread") +require("love.filesystem") +require("love.data") +require("love.timer") +require("love.system") + +local function loadModule(path) + local ok, chunk = pcall(love.filesystem.load, path) + if not ok or type(chunk) ~= "function" then return nil end + local ok2, mod = pcall(chunk) + if not ok2 then return nil end + return mod +end + +local Json = loadModule("src/link/Json.lua") +local Check = loadModule("src/update/Check.lua") +local Version = loadModule("src/core/Version.lua") +local Semver = loadModule("src/update/Semver.lua") +-- Boot's top-level require("src.update.Semver") cannot resolve in this thread +-- (no src.* searcher), which would leave Boot nil and the minShell gate +-- permanently permissive. Seed the loaded table first so it resolves. +if Semver then package.loaded["src.update.Semver"] = Semver end +local Boot = loadModule("src/update/Boot.lua") + +local cmdCh = love.thread.getChannel("update_check_cmd") +local stateCh = love.thread.getChannel("update_check_state") + +local function post(t) stateCh:push(t) end + +local osName = (love.system and love.system.getOS and love.system.getOS()) or "" +local isWindows = osName == "Windows" +local saveDir = love.filesystem.getSaveDirectory() + +local API_URL = "https://api.github.com/repos/bryanthaboi/pokemon-gen1-recomp-project/releases/latest" + +-- the release picked by the last "check"; kept between commands so "download" +-- knows the payload url/size/name without re-fetching +local pending = nil + +-- --------------------------------------------------------------------------- +-- shell / curl +-- --------------------------------------------------------------------------- + +local function shq(s) + s = tostring(s) + if isWindows then + return '"' .. s:gsub('"', '') .. '"' + end + return "'" .. s:gsub("'", "'\\''") .. "'" +end + +-- run curl and return its response body (text), or nil on any failure. Used +-- for the small text resources (release JSON, sums file); -f makes curl exit +-- non-zero and emit nothing on an HTTP error, so an empty read is a failure. +local function curlCapture(url) + local cmd = "curl -fsSL --connect-timeout 10 --max-time 40 " + .. "-H " .. shq("User-Agent: gen1recomp-updater") .. " " + .. "-H " .. shq("Accept: application/vnd.github+json") .. " " + .. shq(url) + local ok, pipe = pcall(io.popen, cmd) + if not ok or not pipe then return nil end + local out = pipe:read("*a") + pipe:close() + if not out or out == "" then return nil end + return out +end + +local function haveCurl() + local ok, pipe = pcall(io.popen, "curl --version") + if not ok or not pipe then return false end + local out = pipe:read("*a") + pipe:close() + return out ~= nil and out:find("curl", 1, true) ~= nil +end + +-- --------------------------------------------------------------------------- +-- version compare (Semver per contract item 5, with a local fallback) +-- --------------------------------------------------------------------------- + +local function parseTriple(s) + s = (tostring(s):gsub("^[vV]", "")) + local a, b, c = s:match("^(%d+)%.(%d+)%.(%d+)") + if not a then return nil end + return { tonumber(a), tonumber(b), tonumber(c) } +end + +-- -1 | 0 | 1 for ab +local function compareVersions(a, b) + if Semver and Semver.compare then + local ok, r = pcall(Semver.compare, a, b) + if ok and r ~= nil then return r end + end + local pa, pb = parseTriple(a), parseTriple(b) + if not pa or not pb then return 0 end + for i = 1, 3 do + if pa[i] ~= pb[i] then return pa[i] < pb[i] and -1 or 1 end + end + return 0 +end + +-- --------------------------------------------------------------------------- +-- verification and the shell gate +-- --------------------------------------------------------------------------- + +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 + +-- Confirm the save-dir file `rel` hashes to the sum listed for `payloadName`. +local function verifyPayload(rel, payloadName, sumsText) + local want = Check.parseSums(sumsText, payloadName) + if not want then return false, "no checksum for " .. payloadName end + local data = love.filesystem.read(rel) + if not data then return false, "cannot read downloaded payload" end + if sha256hex(data):lower() ~= want:lower() then + return false, "checksum mismatch" + end + return true +end + +-- true = ok to run, false = payload needs a newer shell (needs_full). When Boot +-- cannot probe (module missing during parallel dev, or a probe failure) we allow +-- it: Boot.run's crash-guard handles a payload that turns out unrunnable. +local function gatePasses(rel) + if not (Boot and Boot.probePayload) then return true end + local info = Boot.probePayload(rel) + if not info then return true end + local shell = (Version and Version.shell) or 1 + if info.minShell and info.minShell > shell then return false end + return true +end + +-- --------------------------------------------------------------------------- +-- check +-- --------------------------------------------------------------------------- + +local function doCheck() + post({ status = "checking" }) + + if not haveCurl() then + post({ status = "error", error = "curl not available" }) + return + end + + local body = curlCapture(API_URL) + if not body then + post({ status = "error", error = "release check failed" }) + return + end + + local rel, perr = Check.parseRelease(body, Json) + if not rel then + post({ status = "error", error = perr or "bad release json" }) + return + end + pending = rel + + -- Unstamped dev build: the working tree always looks "newer", so never + -- pester the developer with an update (contract item, Check design). + local currentEngine = (Version and Version.engine) or "0.0.0-dev" + if currentEngine == "0.0.0-dev" then + post({ status = "uptodate", latest = rel.version }) + return + end + + if compareVersions(rel.version, currentEngine) <= 0 then + post({ status = "uptodate", latest = rel.version }) + return + end + + -- A newer release, but without the .love payload or its sums we cannot do an + -- in-place update: send the user to the full installers. + if not (rel.payload and rel.payload.url and rel.sums and rel.sums.url) then + post({ status = "needs_full", latest = rel.version }) + return + end + + -- Already downloaded on a previous run? Verify and gate it rather than + -- pulling the bytes again. + local finalRel = "updates/" .. rel.payloadName + if love.filesystem.getInfo(finalRel) then + local sums = curlCapture(rel.sums.url) + if sums and verifyPayload(finalRel, rel.payloadName, sums) then + if gatePasses(finalRel) == false then + love.filesystem.remove(finalRel) + post({ status = "needs_full", latest = rel.version }) + return + end + post({ status = "ready", latest = rel.version }) + return + end + -- stale / corrupt: drop it and offer a fresh download + love.filesystem.remove(finalRel) + end + + post({ status = "available", latest = rel.version }) +end + +-- --------------------------------------------------------------------------- +-- download +-- --------------------------------------------------------------------------- + +-- Launch curl in the background writing `partAbs`, touching `doneAbs` when it +-- exits. Returns without waiting so the caller can poll the growing file for +-- progress. We deliberately do not capture curl's exit code: an incomplete or +-- failed transfer simply fails the checksum below, which is the real gate. +local function launchDownload(url, partAbs, doneAbs) + if isWindows then + -- a tiny batch file sidesteps cmd.exe's nested-quote madness + local batRel = "updates/dl.bat" + love.filesystem.write(batRel, + "@echo off\r\n" + .. "curl -fsSL --connect-timeout 15 --max-time 900 -o \"" + .. partAbs .. "\" \"" .. url .. "\"\r\n" + .. "type nul > \"" .. doneAbs .. "\"\r\n") + os.execute('start "" /b ' .. shq(saveDir .. "/" .. batRel)) + else + -- ( ... ) & backgrounds the whole group so os.execute returns at once + os.execute("( curl -fsSL --connect-timeout 15 --max-time 900 -o " + .. shq(partAbs) .. " " .. shq(url) + .. " ; touch " .. shq(doneAbs) .. " ) >/dev/null 2>&1 &") + end +end + +local function doDownload() + if not (pending and pending.payload and pending.payload.url) then + post({ status = "error", error = "nothing to download" }) + return + end + local rel = pending + post({ status = "downloading", latest = rel.version, progress = 0 }) + + love.filesystem.createDirectory("updates") + local partRel = "updates/" .. rel.payloadName .. ".part" + local doneRel = "updates/" .. rel.payloadName .. ".done" + local finalRel = "updates/" .. rel.payloadName + love.filesystem.remove(partRel) + love.filesystem.remove(doneRel) + + local partAbs = saveDir .. "/updates/" .. rel.payloadName .. ".part" + local doneAbs = saveDir .. "/updates/" .. rel.payloadName .. ".done" + local size = rel.payload.size or 0 + + launchDownload(rel.payload.url, partAbs, doneAbs) + + -- poll the .part size for progress until curl drops the done-marker; a + -- stalled or run-away transfer breaks out and lets verification fail cleanly + local waited, lastSize, lastChange = 0, -1, 0 + while true do + if love.filesystem.getInfo(doneRel) then break end + local pinfo = love.filesystem.getInfo(partRel) + local cur = (pinfo and pinfo.size) or 0 + if size > 0 then + local p = cur / size + if p > 0.999 then p = 0.999 end -- 1.0 is reserved for "ready" + post({ status = "downloading", latest = rel.version, progress = p }) + else + post({ status = "downloading", latest = rel.version }) + end + if cur ~= lastSize then lastSize, lastChange = cur, waited end + if waited - lastChange > 60 then break end -- 60s with no growth: give up + if waited > 960 then break end -- absolute ceiling + love.timer.sleep(0.25) + waited = waited + 0.25 + end + love.filesystem.remove(doneRel) + + local sums = curlCapture(rel.sums and rel.sums.url or "") + if not sums then + love.filesystem.remove(partRel) + post({ status = "error", error = "checksum fetch failed" }) + return + end + + local ok, verr = verifyPayload(partRel, rel.payloadName, sums) + if not ok then + love.filesystem.remove(partRel) + post({ status = "error", error = verr or "verification failed" }) + return + end + + if gatePasses(partRel) == false then + love.filesystem.remove(partRel) + post({ status = "needs_full", latest = rel.version }) + return + end + + -- finalize: rename the verified .part to its real name (fall back to a + -- love.filesystem copy if os.rename is unavailable on this platform) + if not os.rename(partAbs, saveDir .. "/updates/" .. rel.payloadName) then + local data = love.filesystem.read(partRel) + if not data then + post({ status = "error", error = "finalize failed" }) + return + end + love.filesystem.write(finalRel, data) + love.filesystem.remove(partRel) + end + + post({ status = "ready", latest = rel.version }) +end + +-- --------------------------------------------------------------------------- +-- command loop +-- --------------------------------------------------------------------------- + +while true do + local cmd = cmdCh:demand() -- blocks until the main thread pushes work + if type(cmd) == "table" then + if cmd.cmd == "quit" then + break + elseif cmd.cmd == "check" then + local ok, err = pcall(doCheck) + if not ok then post({ status = "error", error = tostring(err) }) end + elseif cmd.cmd == "download" then + local ok, err = pcall(doDownload) + if not ok then post({ status = "error", error = tostring(err) }) end + end + end +end diff --git a/src/world/FieldDefaults.lua b/src/world/FieldDefaults.lua index 4b1027b6..f26dc38b 100644 --- a/src/world/FieldDefaults.lua +++ b/src/world/FieldDefaults.lua @@ -116,6 +116,16 @@ FieldDefaults.FIELD = { badgeGates = { ROUTE_22_GATE = { passedFlag = "PASSED_ROUTE22_GATE" } }, -- VermilionGymSetDoorTile opens the motorized door once both locks are hit hiddenExtras = { + -- PrintTrashText bins (#188); seeds stale caches missing the key + printTrash = { + SS_ANNE_KITCHEN = { + { x = 13, y = 5, facing = "down" }, + { x = 13, y = 7, facing = "down" }, + }, + VERMILION_GYM = { + { x = 6, y = 1, facing = "down" }, + }, + }, trashCans = { map = "VERMILION_GYM", doorBlock = { bx = 2, by = 2, block = 5 } }, }, diff --git a/src/world/Map.lua b/src/world/Map.lua index 7c261732..2b966af4 100644 --- a/src/world/Map.lua +++ b/src/world/Map.lua @@ -223,6 +223,12 @@ function Map:setBlock(bx, by, block) self.def.blocks[by * self.def.width + bx + 1] = block end +-- true if the cell's collision tile is a door tile +-- (pokered IsPlayerStandingOnDoorTile) +function Map:isDoorTileCell(cx, cy) + return self.doorTiles[self:cellTile(cx, cy)] or false +end + -- true if the cell's collision tile is a door or warp-activating tile function Map:isWarpTileCell(cx, cy) local t = self:cellTile(cx, cy) diff --git a/src/world/NPC.lua b/src/world/NPC.lua index b94e83a0..98fd54a2 100644 --- a/src/world/NPC.lua +++ b/src/world/NPC.lua @@ -101,9 +101,18 @@ function NPC:walkPhase() return (p >= 4 and p < 12) and 1 or 0 end +-- Same contract as Player:pose -- the sheet, position, facing and step +-- phase this frame renders to -- so a render pipeline can pose an NPC +-- without caring which kind of entity it is. An NPC never hops, so the +-- trailing hop flag is always false. +function NPC:pose() + return self.sprite, self.px, self.py, self.facing, + self:walkPhase(), self.stepFlip, false +end + function NPC:draw(camX, camY) - self.sprite:draw(self.px, self.py, camX, camY, self.facing, - self:walkPhase(), self.stepFlip) + local sprite, px, py, facing, phase, flip = self:pose() + sprite:draw(px, py, camX, camY, facing, phase, flip) end return NPC diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index a0f6d1fb..0772056f 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -12,6 +12,7 @@ local Map = require("src.world.Map") local MapLoader = require("src.world.MapLoader") local NPC = require("src.world.NPC") local PaletteFX = require("src.render.PaletteFX") +local Pipelines = require("src.render.Pipelines") local Player = require("src.world.Player") local Runtime = require("src.mods.Runtime") local Screens = require("src.ui.Screens") @@ -20,6 +21,7 @@ local Tilt = require("src.render.Tilt") local TextBox = require("src.render.TextBox") local Transition = require("src.render.Transition") local Warp = require("src.world.Warp") +local Zoom = require("src.render.Zoom") -- isOverworld marks the live world state for WorldAPI's stack scan local OverworldState = { isOpaque = true, isOverworld = true } @@ -716,16 +718,13 @@ function OverworldState:update(dt) end if self.healAnim then local ha = self.healAnim - local Music = require("src.core.Music") - if ha.jinglePlaying and not ha.jingleDone then - ha.jingleDone = not Music.oneShotPlaying() - end local ev = OverworldState.stepHealAnim(ha) if ev == "ball" then require("src.core.Sound").play(Game.data, "Healing_Machine") elseif ev == "jingle" then - ha.jinglePlaying = Music.playOnce(Game.data, "Music_PkmnHealed") - ha.jingleDone = not ha.jinglePlaying + -- playOnce restores the map theme when the jingle ends; we no longer + -- block the fighting-fit text on that (#157) + require("src.core.Music").playOnce(Game.data, "Music_PkmnHealed") elseif ev == "done" then local done = ha.onDone self.healAnim = nil @@ -1509,6 +1508,15 @@ function OverworldState:tryHiddenObject(fx, fy) end end + -- PrintTrashText: SS Anne kitchen + Vermilion Gym non-puzzle can + for _, h in ipairs(extras.printTrash and extras.printTrash[self.map.id] or {}) do + if h.x == fx and h.y == fy then + Game.stack:push(TextBox.new(Game, txt._VermilionGymTrashText + or "Nope, there's\nonly trash here.")) + return true + end + end + -- the Vermilion Gym trash can lock puzzle if self.map.id == "VERMILION_GYM" then for _, h in ipairs(extras.trashCans.cans or {}) do @@ -2188,13 +2196,10 @@ function OverworldState:dexRating() Game.stack:push(TextBox.new(Game, completion .. "\f" .. rating)) end --- AnimateHealingMachine (engine/overworld/healing_machine.asm): the --- monitor lights, then one ball per party mon appears every 30 frames --- (SFX_HEALING_MACHINE each); the healed jingle plays while the machine --- sprites flash 8 times (an OBP1 xor every 10 frames), then a 32-frame --- beat once the jingle ends. Pure per-frame step over the ha table --- ({ balls, lit, timer, visible, jingleDone }); returns "ball"/"jingle"/ --- "done" when the caller must fire the matching side effect. +-- AnimateHealingMachine (engine/overworld/healing_machine.asm): balls +-- every 30 frames, then jingle + FlashSprite8Times (8 x 10). #157: skip +-- pokered's post-flash .waitLoop2 / DelayFrames 32 so fighting-fit is +-- immediate; jingle still plays and restoreMap runs when it ends. function OverworldState.stepHealAnim(ha) ha.timer = ha.timer + 1 ha.phase = ha.phase or "balls" @@ -2217,17 +2222,11 @@ function OverworldState.stepHealAnim(ha) ha.visible = not ha.visible ha.flashes = ha.flashes + 1 if ha.flashes >= 8 then - ha.phase = "wait" ha.visible = true + ha.phase = "done" + return "done" end end - elseif ha.phase == "wait" then - -- .waitLoop2: hold until the jingle ends, then 32 more frames - if not ha.jingleDone then - ha.timer = 0 - elseif ha.timer >= 32 then - return "done" - end end end @@ -2403,19 +2402,34 @@ function OverworldState:checkVictoryRewards(trainerClass, partyIndex) Commands.hide_object(ctx, entry[1], entry[2]) end end - local lines = {} if reward.badge then Game.save.inventory[reward.badge] = 1 - local name = Game.data.items[reward.badge] and Game.data.items[reward.badge].name - or reward.badge - table.insert(lines, ("%s received\nthe %s!"):format(Game.save.player.name, name)) end if reward.item then local inv = Game.save.inventory inv[reward.item] = (inv[reward.item] or 0) + 1 - local name = Game.data.items[reward.item] and Game.data.items[reward.item].name - or reward.item - table.insert(lines, ("%s received\n%s!"):format(Game.save.player.name, name)) + local idef = Game.data.items[reward.item] + -- GiveItem -> CopyToStringBuffer for "{RAM:wStringBuffer}" received texts + Game.stringBuffer = idef and idef.name or reward.item + end + local lines = {} + if reward.dialogue then + local text = Game.data.text or {} + for _, label in ipairs(reward.dialogue) do + if text[label] and text[label] ~= "" then + table.insert(lines, text[label]) + end + end + elseif reward.badge or reward.item then + if reward.badge then + local name = Game.data.items[reward.badge] and Game.data.items[reward.badge].name + or reward.badge + table.insert(lines, ("%s received\nthe %s!"):format(Game.save.player.name, name)) + end + if reward.item then + local name = Game.stringBuffer or reward.item + table.insert(lines, ("%s received\n%s!"):format(Game.save.player.name, name)) + end end if #lines > 0 then Game.stack:push(TextBox.new(Game, table.concat(lines, "\f"))) @@ -2437,6 +2451,8 @@ end -- interposed NPCs / walls -- but unsigned 8-bit Y makes a sprite exactly 4 -- tiles north of the player sit at Y=$fc, so |$3c-$fc|=$c0 and a range-4 -- DOWN trainer does not engage that tile (Route 9 Bug Catcher / issue #76). +-- Off-screen sprites (IMAGEINDEX=$ff) never engage: without that gate, the +-- same 8-bit wrap makes far same-row trainers look in-range (#153/#183). local PLAYER_SCREEN_X, PLAYER_SCREEN_Y = 0x40, 0x3c local function u8(n) return n % 256 end local function calcDiff(a, b) @@ -2452,6 +2468,14 @@ local function trainerSightPixelDist(npc, player, horizontal) u8(PLAYER_SCREEN_Y + (npc.cellY - player.cellY) * 16)) end +-- CheckSpriteAvailability (movement.asm): wXCoord/wYCoord = player - 4; +-- visible when sprite is in [wCoord, wCoord + SCREEN_*/2 - 1] (GB 10x9). +local function trainerSpriteOnScreen(npc, player) + local dx = npc.cellX - player.cellX + local dy = npc.cellY - player.cellY + return dx >= -4 and dx <= 5 and dy >= -4 and dy <= 4 +end + -- STAY trainers with a facing spot the player crossing their line of -- sight (range from the extracted trainer headers), walk up and battle. function OverworldState:checkTrainerSight() @@ -2464,7 +2488,8 @@ function OverworldState:checkTrainerSight() -- walkers included (they sight between steps) if d.trainerClass and not npc.moving and not self:trainerDefeated(npc) - and not mapScripts.talkScript(self.map.id, d.text) then + and not mapScripts.talkScript(self.map.id, d.text) + and trainerSpriteOnScreen(npc, p) then local header = Game.data:trainerHeader(self.map.def.label, d.index) local range = header and header.range or 0 local vec = DIRVEC[npc.facing] @@ -3208,7 +3233,7 @@ function OverworldState:takeWarp(warpDef) self:startWarpTo(destMap, x, y, facing) return end - self.doorWarp = true -- door SFX + outdoor walk-out step + self.doorWarp = true -- door SFX + PlayerStepOutFromDoor walk-out self:startWarpTo(destMap, x, y, facing) end @@ -3293,12 +3318,12 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts) local outdoor = Map.isOutdoor(self.map.def) require("src.core.Sound").play(Game.data, outdoor and "Go_Outside" or "Go_Inside") - -- stepping out of an outdoor door/cave entrance (the original's - -- walk-out). Auto-walk leaves the mat, so the arrival disable + -- PlayerStepOutFromDoor (engine/overworld/auto_movement.asm): any + -- warp that lands on a door tile auto-steps south once, indoor or + -- outdoor. Auto-walk leaves the mat, so the arrival disable -- (warpEntryCell / justWarped) is unnecessary -- and would let you -- stand on the door without re-entering if you hold back into it. - if outdoor and self.player.facing == "down" - and self.map:isWarpTileCell(self.player.cellX, self.player.cellY) then + if self.map:isDoorTileCell(self.player.cellX, self.player.cellY) then self.warpEntryCell = nil self.justWarped = false self:scriptMove(self.player, "down", 1) @@ -3539,11 +3564,22 @@ function OverworldState:drawWorld() -- tilt is active). So the ground draw calls below never change with tilt; -- only the sprite/FX draw path below them branches. The sorts below only -- reorder (no draws), so they run once for both paths. - local tilt = Tilt.active() - self.map.renderer:drawBorderFill(cam.x, bgY, vw, vh) - self.map.renderer:draw(cam.x, bgY, vw, vh) - for _, nb in ipairs(self.neighbors) do - nb.map.renderer:drawMapOnly(cam.x - nb.ox, bgY - nb.oy, vw, vh) + -- A render pipeline (src/render/Pipelines.lua) replaces the ground draw + -- entirely with geometry of its own, so it is decided before tilt and + -- wins over it. It falls back to the tilt/flat path whenever it cannot + -- run this frame -- headless, a driver with no depth canvas, or a mod + -- that threw -- so no caller ever sees a blank frame. + local pipelineId = Pipelines.worldPipeline() + local tilt = (not pipelineId) and Tilt.active() + -- the pipeline's finished world image, once it has run; nil keeps every + -- path below on the vanilla flat/tilt draw + local override + if not pipelineId then + self.map.renderer:drawBorderFill(cam.x, bgY, vw, vh) + self.map.renderer:draw(cam.x, bgY, vw, vh) + for _, nb in ipairs(self.neighbors) do + nb.map.renderer:drawMapOnly(cam.x - nb.ox, bgY - nb.oy, vw, vh) + end end -- per-billboard SGB palette source; only needed (and only paid for) when -- tilting. nil headless / on stale palettes -> billboards go uncolorized. @@ -3774,7 +3810,114 @@ function OverworldState:drawWorld() end end - if not tilt then + if pipelineId then + -- === PIPELINE PATH: a mod owns the world pass. ====================== + -- It renders terrain and characters however it likes and hands back one + -- window-resolution image; the field FX stay ordinary 2D draws + -- composited on top by ctx.drawFx, each anchored to where its ground + -- point projects under the pipeline's own camera. That is the direct + -- analogue of what :billboard does for tilt, and it keeps exactly one + -- copy of every effect: the closures above are the ones that run. + local pw, ph = love.graphics.getDimensions() + local pscale = Zoom.scale(Game.renderer:fitScale()) + local ctx = { + state = self, cam = cam, vw = vw, vh = vh, bgY = bgY, + width = pw, height = ph, scale = pscale, + level = Pipelines.level(pipelineId), + -- the SGB world palette a map draws under; nil in the true-colour + -- modes, whose art is already baked (and must not be re-mapped) + paletteFor = function(map) + return PaletteFX.pal(Game.data, self:paletteNameFor(map or self.map)) + end, + spriteColors = function(map) + if PaletteFX.usesGbcPack() then return nil end + return PaletteFX.pal(Game.data, self:paletteNameFor(map or self.map)) + end, + fx = { heal = fxHeal, dust = fxDust, cutTree = fxCutTree, + emote = fxEmote, dark = fxDark, bird = fxBird, rod = fxRod }, + } + -- Draw every active field FX into the finished scene. `project(wx, wy)` + -- maps a world point to canvas pixels (nil when it is behind the + -- camera) and `scale` is canvas pixels per world pixel; the pipeline + -- owns the camera, this owns where each effect belongs and how the + -- closures' flat coordinates are slid onto the projected anchor. + -- Deliberately unscaled by depth, like :billboard: an effect keeps its + -- crisp authored size and only its anchor moves. + ctx.drawFx = function(project, scale) + scale = scale or pscale + local colors = ctx.spriteColors() + local function at(drawFn, wx, wy) + if not drawFn then return end + local sx, sy = project(wx, wy) + if not sx then return end -- behind the camera + local shader = colors and PaletteFX.shader() or nil + if shader then + PaletteFX.sendColors(shader, colors) + love.graphics.setShader(shader) + end + -- the closures draw relative to the flat foot; slide that onto the + -- projected anchor, in world-pixel units inside the scaled transform + local fx, fy = wx - cam.x, wy - cam.y + love.graphics.push() + love.graphics.scale(scale, scale) + love.graphics.translate(sx / scale - fx, sy / scale - fy) + drawFn() + love.graphics.pop() + if shader then love.graphics.setShader() end + end + -- ground-hugging effects sit on the cell they belong to + if self.dustAnim then + at(fxDust, self.dustAnim.x * 16 + 8, self.dustAnim.y * 16 + 8) + end + if self.cutAnim then + at(fxCutTree, self.cutAnim.x * 16 + 8, self.cutAnim.y * 16 + 16) + end + if self.healAnim then + at(fxHeal, self.healAnim.px + 8, self.healAnim.py + 16) + end + -- standing effects anchor at the foot of whoever they belong to + if self.emote and self.emote.npc then + at(fxEmote, self.emote.npc.px + 8, self.emote.npc.py + 16) + end + if self.flyAnim then + at(fxBird, self.player.px + 8, self.player.py + 16) + end + if self.fishing then + at(fxRod, self.player.px + 8, self.player.py + 16) + end + -- Rock Tunnel darkness is a screen-space light window, not a ground + -- object: draw it flat over the finished scene like the tilt path. + -- It fills the view in world-pixel units, so it only needs the scale. + if self.dark then + love.graphics.push() + love.graphics.scale(scale, scale) + fxDark() + love.graphics.pop() + end + end + override = Pipelines.drawWorld(pipelineId, ctx) + -- world post-processes (a miniature-diorama blur, a colour grade) fold + -- over the finished scene here, so they never touch the UI drawn on top + if override then + override = Pipelines.worldPresent(override, ctx) + end + Game.renderer:setWorldOverride(override) + if not override then + -- The pipeline declined this frame (nothing to draw, or it threw and + -- was retired). The ground pass was skipped on its behalf above, so + -- draw it now and fall through to the flat path below rather than + -- compositing an empty canvas. + self.map.renderer:drawBorderFill(cam.x, bgY, vw, vh) + self.map.renderer:draw(cam.x, bgY, vw, vh) + for _, nb in ipairs(self.neighbors) do + nb.map.renderer:drawMapOnly(cam.x - nb.ox, bgY - nb.oy, vw, vh) + end + end + end + + if override then + -- the pipeline owns the whole frame; nothing else draws into the world + elseif not tilt then -- === FLAT PATH: everything into the one world canvas, as before ===== -- OBP-baked sprites replay after the zone pass in GBC mode, so their -- grass feet-overdraw must replay over them too, colorized with the diff --git a/src/world/Player.lua b/src/world/Player.lua index d966fbb8..b6730ffb 100644 --- a/src/world/Player.lua +++ b/src/world/Player.lua @@ -140,28 +140,29 @@ end local SPIN_ORDER = { "down", "left", "up", "right" } -function Player:draw(camX, camY) +-- What this frame renders to: the sheet, where it sits, which way it faces +-- and how far through a step it is. Shared by the 2D draw below and by a +-- render pipeline's own geometry (src/render/Pipelines.lua), so the two can +-- never disagree about which sprite or facing is current. +-- +-- The last return says the player is mid-ledge-hop, which is what the 2D +-- path draws the ground shadow from and a 3D path turns into vertical lift. +-- +-- This ADVANCES the surf-bob and spinner timers, so exactly one of pose() +-- and draw() may run per frame -- and draw() is written in terms of pose() +-- to keep that true by construction. (hopFrames counts down in +-- Player:update, on the fixed step, so it is safe to read here.) +function Player:pose() local py = self.py - -- ledge hops arc (set for 2 cells by the ledge handler); surfing bobs. - -- hopFrames counts down in Player:update (fixed step), never here. + local hopping = false + -- ledge hops arc (set for 2 cells by the ledge handler); surfing bobs if self.hopFrames and self.hopFrames > 0 then local total = self.hopTotal or 32 -- update runs before draw, so remaining N means N steps already -- consumed this hop → t matches the old draw-side post-decrement phase local t = 1 - self.hopFrames / total py = py - math.floor(10 * math.sin(t * math.pi) + 0.5) - -- the shadow stays on the ground under the jumper: one 8x8 tile - -- mirrored into a 2x2 block (normal/XFLIP/YFLIP/both) whose top-left - -- is 8px below the sprite's standing top-left (LoadHoppingShadowOAM + - -- LedgeHoppingShadowOAMBlock, engine/overworld/ledges.asm) - if self.shadowImg then - local sx = math.floor(self.px - camX) - local sy = math.floor(self.py - camY) - 4 + 8 - love.graphics.draw(self.shadowImg, sx, sy) - love.graphics.draw(self.shadowImg, sx + 16, sy, 0, -1, 1) - love.graphics.draw(self.shadowImg, sx, sy + 16, 0, 1, -1) - love.graphics.draw(self.shadowImg, sx + 16, sy + 16, 0, -1, -1) - end + hopping = true elseif self.surfing then self.bobTimer = ((self.bobTimer or 0) + 1) % 32 py = py + (self.bobTimer < 16 and 0 or 1) @@ -186,7 +187,24 @@ function Player:draw(camX, camY) end local sprite = (self.surfing and self.surfSprite) or (self.onBike and self.bikeSprite) or self.sprite - sprite:draw(self.px, py, camX, camY, facing, phase, flip) + return sprite, self.px, py, facing, phase, flip, hopping +end + +function Player:draw(camX, camY) + local sprite, px, py, facing, phase, flip, hopping = self:pose() + -- the shadow stays on the ground under the jumper: one 8x8 tile + -- mirrored into a 2x2 block (normal/XFLIP/YFLIP/both) whose top-left + -- is 8px below the sprite's standing top-left (LoadHoppingShadowOAM + + -- LedgeHoppingShadowOAMBlock, engine/overworld/ledges.asm) + if hopping and self.shadowImg then + local sx = math.floor(self.px - camX) + local sy = math.floor(self.py - camY) - 4 + 8 + love.graphics.draw(self.shadowImg, sx, sy) + love.graphics.draw(self.shadowImg, sx + 16, sy, 0, -1, 1) + love.graphics.draw(self.shadowImg, sx, sy + 16, 0, 1, -1) + love.graphics.draw(self.shadowImg, sx + 16, sy + 16, 0, -1, -1) + end + sprite:draw(px, py, camX, camY, facing, phase, flip) end return Player diff --git a/src/world/SsAnneLayout.lua b/src/world/SsAnneLayout.lua new file mode 100644 index 00000000..527cb806 --- /dev/null +++ b/src/world/SsAnneLayout.lua @@ -0,0 +1,62 @@ +-- S.S. Anne 1F cabins: ROM packs rooms so hallway doors map right→left +-- onto the 3×2 rooms map (warp 1 = top-left = rightmost door). Survey +-- zoom makes that look left/right flipped vs 2F/B1F. Reorder door→cell +-- and slide objects so each door keeps its OG occupants while L→R doors +-- land in reading order on the rooms map. + +local SsAnneLayout = {} + +local function leftmostRoomDoor(hall) + local best + for _, w in ipairs(hall.warps or {}) do + if w.destMap == "SS_ANNE_1F_ROOMS" then + if not best or w.x < best.x then best = w end + end + end + return best +end + +function SsAnneLayout.apply(maps) + if not maps then return false end + local hall = maps.SS_ANNE_1F + local rooms = maps.SS_ANNE_1F_ROOMS + if not hall or not rooms then return false end + local left = leftmostRoomDoor(hall) + -- already hallway-ordered (leftmost door → rooms warp 1) + if not left or left.destWarp == 1 then return false end + + local doors = {} + for _, w in ipairs(hall.warps) do + if w.destMap == "SS_ANNE_1F_ROOMS" then doors[#doors + 1] = w end + end + table.sort(doors, function(a, b) return a.x < b.x end) + for i, w in ipairs(doors) do w.destWarp = i end + + -- rooms warps 1..6 return through hall warps 8..3 (L→R cabin doors) + for i, w in ipairs(rooms.warps or {}) do + if w.destMap == "SS_ANNE_1F" then w.destWarp = 9 - i end + end + + local warpPos = {} + for i, w in ipairs(rooms.warps or {}) do + warpPos[i] = { x = w.x, y = w.y } + end + for _, o in ipairs(rooms.objects or {}) do + local col, row = math.floor(o.x / 10), math.floor(o.y / 10) + local oldK + for k, p in ipairs(warpPos) do + if math.floor(p.x / 10) == col and math.floor(p.y / 10) == row then + oldK = k + break + end + end + if oldK then + local newK = 7 - oldK + o.x = o.x + (warpPos[newK].x - warpPos[oldK].x) + o.y = o.y + (warpPos[newK].y - warpPos[oldK].y) + end + end + return true +end + +return SsAnneLayout diff --git a/tests/drivers/anne_door_test.lua b/tests/drivers/anne_door_test.lua new file mode 100644 index 00000000..e8bea3a0 --- /dev/null +++ b/tests/drivers/anne_door_test.lua @@ -0,0 +1,28 @@ +-- Driver: #187 S.S. Anne cabin door entry. Entering a room from the +-- corridor should land one tile south of the door (pokered +-- PlayerStepOutFromDoor), not leave the player on the door tile. +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + -- SS_ANNE_1F warp at (31,8) -> SS_ANNE_1F_ROOMS (rightmost cabin) + U.teleport(game, "SS_ANNE_1F", 31, 9, "up") + U.shot(game, DIR .. "/anne_0_before_enter.png") + local ow = game.overworld + U.log("before map:", ow.map.id, "pos:", ow.player.cellX, ow.player.cellY, + "facing:", ow.player.facing) + + U.hold(game, "up", 20) + for _ = 1, 90 do + U.wait(1) + if ow.map.id == "SS_ANNE_1F_ROOMS" and not ow.transitioning + and #ow.scriptMoves == 0 and not ow.player.moving then + break + end + end + U.wait(8) + U.shot(game, DIR .. "/anne_1_after_enter.png") + U.log("after map:", ow.map.id, "pos:", ow.player.cellX, ow.player.cellY, + "facing:", ow.player.facing, + "onDoor:", tostring(ow.map:isWarpTileCell(ow.player.cellX, ow.player.cellY))) +end diff --git a/tests/drivers/battle_party_submenu_test.lua b/tests/drivers/battle_party_submenu_test.lua new file mode 100644 index 00000000..7a4f38f4 --- /dev/null +++ b/tests/drivers/battle_party_submenu_test.lua @@ -0,0 +1,58 @@ +-- Driver: mid-battle PKMN opens SWITCH / STATS / CANCEL (#180). +-- SHOT_DIR=/tmp/shots POKEPORT_DRIVER=tests/drivers/battle_party_submenu_test.lua 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") + + game.save.party = { + Pokemon.new(game.data, "CHARMANDER", 12), + Pokemon.new(game.data, "SQUIRTLE", 10), + } + U.teleport(game, "ROUTE_1", 5, 5, "down") + local ow = game.overworld + + local battle = BattleState.newWild(game, "PIDGEY", 8) + battle.onFinish = function() end + ow:pushBattle(battle) + + local function mashUntil(cond, max) + for _ = 1, max or 80 do + if cond() then return true end + U.tap(game, "a") + U.wait(4) + end + return false + end + + mashUntil(function() return battle.phase == "menu" end) + U.shot(game, DIR .. "/battle_party_0_menu.png") + + -- FIGHT/PKMN/ITEM/RUN: right to PKMN, then A + U.tap(game, "right"); U.wait(4) + U.tap(game, "a"); U.wait(12) + U.shot(game, DIR .. "/battle_party_1_list.png") + + local pm = game.stack:top() + U.log("party open:", pm and pm.onSwitch ~= nil) + U.tap(game, "a"); U.wait(8) + pm = game.stack:top() + U.shot(game, DIR .. "/battle_party_2_switch_stats.png") + + local labels = {} + if pm and pm.submenu and pm.subItems then + for _, e in ipairs(pm.subItems) do + table.insert(labels, e.label) + end + end + U.log("submenu:", table.concat(labels, "/")) + U.log("order ok:", labels[1] == "SWITCH" and labels[2] == "STATS" + and labels[3] == "CANCEL") + + -- STATS + U.tap(game, "down"); U.wait(4) + U.tap(game, "a"); U.wait(14) + U.shot(game, DIR .. "/battle_party_3_stats.png") + U.log("stats open:", game.stack:top() ~= pm) +end diff --git a/tests/drivers/battleflow_test.lua b/tests/drivers/battleflow_test.lua index da8135e3..6984ed9a 100644 --- a/tests/drivers/battleflow_test.lua +++ b/tests/drivers/battleflow_test.lua @@ -1,10 +1,12 @@ --- Driver: the two reworked battle flows. +-- Driver: reworked battle flows. -- A) Old man catch tutorial (DisplayBattleMenu's old-man script): -- scripted cursor FIGHT(80f) -> ITEM(50f), forced item menu with -- one POKé BALL x50 -- itself scripted (list_menu.asm:65-80): -- '▶' hover 80f, auto-A leaves the hollow '▷', always-caught throw. -- B) Mimic's MID-move copy menu (MimicEffect): the chooser opens only -- after the hit test, at (0,7) like MoveSelectionMenu .mimicmenu. +-- C) #162 trainer SHIFT offer: after KO'ing the first enemy mon, +-- "about to use" + "Will … change POKéMON?" with YES/NO. return function(game) local U = dofile("tests/drivers/util.lua") local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" @@ -83,5 +85,55 @@ return function(game) U.shot(game, DIR .. "/mimic_5_moves_after.png") -- slot now holds the copy while game.stack:top() ~= ow do game.stack:pop() end U.wait(5) + + -- ------------------------------------------------ C) SHIFT about-to-use + -- Inject the foe-faint path at the menu so screenshots don't depend on + -- accuracy rolls; party has 2 slots so TrainerAboutToUseText fires. + local ChoiceBox = require("src.ui.ChoiceBox") + local PartyMenu = require("src.ui.PartyMenu") + game.save.options = game.save.options or {} + game.save.options.battleStyle = "shift" + game.save.options.animations = false + game.save.party = { + Pokemon.new(game.data, "CHARIZARD", 50), + Pokemon.new(game.data, "BLASTOISE", 50), + } + local trainer = BattleState.newTrainer(game, "OPP_YOUNGSTER", 1) + trainer.onFinish = function() end + ow:pushBattle(trainer) + mashUntil(function() return trainer.phase == "menu" end) + U.shot(game, DIR .. "/shift_0_menu.png") + trainer:markParticipant() + trainer.enemy.mon.hp = 0 + trainer.enemyParty[1].hp = 0 + trainer.phase = "messages" + trainer.afterQueue = "menu" + trainer:onFaint(trainer.enemy) + local function topIs(cls) + return getmetatable(game.stack:top()) == cls + end + local function textDone(needle) + local t = trainer.current and trainer.current.text + return t and t:find(needle, 1, true) + and (trainer.charIndex or 0) >= (trainer.total or 0) + end + mashUntil(function() return textDone("about to use") end, 200) + U.shot(game, DIR .. "/shift_1_about_to_use.png") + mashUntil(function() return textDone("change POKéMON") end, 80) + U.wait(20) -- ChoiceBox auto-opens once the page has typed out + mashUntil(function() return topIs(ChoiceBox) end, 40) + U.shot(game, DIR .. "/shift_2_will_change.png") + U.shot(game, DIR .. "/shift_3_yes_no.png") + U.tap(game, "a"); U.wait(8) -- YES + mashUntil(function() return topIs(PartyMenu) end, 40) + U.shot(game, DIR .. "/shift_4_party.png") + U.tap(game, "down"); U.wait(4) + U.tap(game, "a"); U.wait(8) -- BLASTOISE + mashUntil(function() return textDone("sent") end, 120) + U.shot(game, DIR .. "/shift_5_enemy_sent.png") + mashUntil(function() return textDone("BLASTOISE") end, 120) + U.shot(game, DIR .. "/shift_6_player_sent.png") + while game.stack:top() ~= ow do game.stack:pop() end + U.wait(5) love.event.quit() end diff --git a/tests/drivers/bills_pc_test.lua b/tests/drivers/bills_pc_test.lua new file mode 100644 index 00000000..8b4e57aa --- /dev/null +++ b/tests/drivers/bills_pc_test.lua @@ -0,0 +1,120 @@ +-- Driver: Bill's PC (#177) — chrome (What? / BOX No. / ) and +-- withdraw/deposit returning to BillsPCMenu instead of closing the PC. + +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local Boxes = require("src.pokemon.Boxes") + local Menu = require("src.ui.Menu") + local ListMenu = require("src.ui.ListMenu") + local TextBox = require("src.render.TextBox") + local Pokemon = require("src.pokemon.Pokemon") + + local pass = true + local function check(cond, msg) + if cond then U.log("PASS: " .. msg) else pass = false; U.log("FAIL: " .. msg) end + end + local function topIs(mt) return getmetatable(game.stack:top()) == mt end + local function topMenu() + local top = game.stack:top() + return getmetatable(top) == Menu and top or nil + end + local function mash(btn, cond, frames) + for _ = 1, frames or 120 do + if cond() then return true end + U.tap(game, btn) + U.wait(3) + end + return false + end + + -- party + box stocked so withdraw and deposit both work + game.save.party = { + Pokemon.new(game.data, "PIKACHU", 12), + Pokemon.new(game.data, "EEVEE", 10), + } + Boxes.ensure(game.save) + game.save.currentBox = 1 + game.save.boxes[1] = { + Pokemon.new(game.data, "SPEAROW", 8), + Pokemon.new(game.data, "RATTATA", 6), + } + game.save.flags = game.save.flags or {} + game.save.flags.EVENT_MET_BILL = true + + -- PC tile at (13,3); stand on (13,4) facing up + U.teleport(game, "VIRIDIAN_POKECENTER", 13, 4, "up") + local ow = game.overworld + + U.tap(game, "a") -- open PC + U.wait(6) + check(topIs(Menu), "PC main menu opened") + U.tap(game, "a") -- BILL'S PC + U.wait(8) + local bills = topMenu() + check(bills ~= nil, "Bill's PC menu opened") + check(bills and bills.items[1] and bills.items[1].label:find("", 1, true), + "WITHDRAW label includes icon tokens") + U.shot(game, DIR .. "/bills_pc_1_menu.png") + + -- WITHDRAW first box mon + U.tap(game, "a") -- WITHDRAW + U.wait(8) + check(topIs(ListMenu), "withdraw list opened") + U.tap(game, "a") -- pick SPEAROW + U.wait(6) + check(topIs(Menu), "withdraw mon submenu opened") + U.tap(game, "a") -- WITHDRAW action + U.wait(6) + check(mash("a", function() return topIs(TextBox) end, 60), + "withdraw shows taken-out text") + -- let the typewriter finish so the shot shows the taken-out line + for _ = 1, 90 do + local tb = game.stack:top() + if getmetatable(tb) == TextBox and (tb.waiting or tb.done) then break end + U.wait(2) + end + U.shot(game, DIR .. "/bills_pc_2_withdraw_text.png") + check(mash("a", function() + local m = topMenu() + return m and m.items and m.items[1] + and tostring(m.items[1].label):find("WITHDRAW", 1, true) + end, 120), "after withdraw, back on Bill's PC menu") + check(#game.save.party == 3, "party gained the withdrawn mon") + check(#game.save.boxes[1] == 1, "box lost the withdrawn mon") + U.shot(game, DIR .. "/bills_pc_3_menu_after_withdraw.png") + + -- DEPOSIT party lead (PIKACHU) + U.tap(game, "down") -- DEPOSIT + U.wait(2) + U.tap(game, "a") + U.wait(8) + check(topIs(ListMenu), "deposit list opened") + U.tap(game, "a") -- first party mon + U.wait(6) + check(topIs(Menu), "deposit mon submenu opened") + U.tap(game, "a") -- DEPOSIT action + U.wait(6) + check(mash("a", function() return topIs(TextBox) end, 60), + "deposit shows stored text") + for _ = 1, 90 do + local tb = game.stack:top() + if getmetatable(tb) == TextBox and (tb.waiting or tb.done) then break end + U.wait(2) + end + U.shot(game, DIR .. "/bills_pc_4_deposit_text.png") + check(mash("a", function() + local m = topMenu() + return m and m.items and m.items[1] + and tostring(m.items[1].label):find("WITHDRAW", 1, true) + end, 120), "after deposit, back on Bill's PC menu") + check(#game.save.party == 2, "party lost the deposited mon") + check(#game.save.boxes[1] == 2, "box gained the deposited mon") + U.shot(game, DIR .. "/bills_pc_5_menu_after_deposit.png") + + -- still inside the PC (not back on the overworld) + check(game.stack:top() ~= ow, "PC still open after transfers") + check(not topIs(ListMenu), "not stuck in a transfer list") + + U.log(pass and "RESULT: ALL PASS" or "RESULT: SEE FAILURES ABOVE") +end diff --git a/tests/drivers/catch_test.lua b/tests/drivers/catch_test.lua index ca0bc30c..73821f31 100644 --- a/tests/drivers/catch_test.lua +++ b/tests/drivers/catch_test.lua @@ -1,15 +1,35 @@ -- Driver: throw Poké Balls and capture the catch suspense sequence --- (toss -> poof -> mon hides -> ball shakes -> breakout or capture). +-- (toss -> poof -> mon hides -> ball shakes -> breakout or capture), +-- #159 nickname ask (text + white field, YES then NO), and #172 +-- full-party catch nickname prompt before box transfer. 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 ChoiceBox = require("src.ui.ChoiceBox") table.insert(game.save.party, Pokemon.new(game.data, "CHARMANDER", 12)) U.teleport(game, "ROUTE_1", 5, 5, "down") local ow = game.overworld local BattleState = require("src.battle.BattleState") + local function topIs(cls) + return getmetatable(game.stack:top()) == cls + end + + local function mashUntil(cond, n) + for _ = 1, (n or 400) do + if cond() then return true end + U.tap(game, "a") + U.wait(3) + end + return false + end + + local function onNicknameChoice() + return topIs(ChoiceBox) + end + -- one throw, screenshotting every 20 frames through the chain local function throwAndShoot(tag, rng) local battle = BattleState.newWild(game, "PIDGEY", 8) @@ -36,4 +56,78 @@ return function(game) throwAndShoot("break", function(a, b) return b end) -- rng low: clean capture, ball stays shut throwAndShoot("caught", function(a, b) return a end) + + -- #159: party catch -> nickname ask on white (YES cursor, then NO) + game.save.party = { Pokemon.new(game.data, "PIKACHU", 5) } + game.save.pokedex = game.save.pokedex or { owned = {}, seen = {} } + game.save.pokedex.owned.RATTATA = true + game.save.pokedex.seen.RATTATA = true + + local battle = BattleState.newWild(game, "RATTATA", 3) + battle.onFinish = function() end + battle.rng = function(a, b) return a end + ow:pushBattle(battle) + for _ = 1, 14 do U.tap(game, "a"); U.wait(6) end + battle.phase = "messages" + battle.afterQueue = "menu" + battle:throwBall("POKE_BALL") + + U.log("nick YES:", mashUntil(onNicknameChoice, 600)) + U.shot(game, DIR .. "/catch_nickname_yes.png") + U.tap(game, "down") + U.wait(4) + U.shot(game, DIR .. "/catch_nickname_no.png") + U.tap(game, "a") -- confirm NO + U.wait(8) + for _ = 1, 20 do U.tap(game, "a"); U.wait(4) end + while game.stack:top() ~= ow do game.stack:pop() end + U.wait(5) + + -- #172: full party of 6 -> nickname ask, then "sent to BOX" / PC text + game.save.party = {} + for _ = 1, 6 do + table.insert(game.save.party, Pokemon.new(game.data, "RATTATA", 5)) + end + game.save.pokedex.owned.PIDGEY = true + game.save.pokedex.seen.PIDGEY = true + game.save.flags = game.save.flags or {} + game.save.flags.EVENT_MET_BILL = true + + battle = BattleState.newWild(game, "PIDGEY", 8) + battle.onFinish = function() end + battle.rng = function(a, b) return a end + ow:pushBattle(battle) + for _ = 1, 14 do U.tap(game, "a"); U.wait(6) end + battle.phase = "messages" + battle.afterQueue = "menu" + battle:throwBall("POKE_BALL") + + U.log("full-party nick ask:", mashUntil(onNicknameChoice, 600)) + U.wait(4) + U.shot(game, DIR .. "/catch_fullparty_nickname.png") + + -- decline nickname (NO), then wait (no A/B) for ItemUseBallText07 + if topIs(ChoiceBox) then + U.tap(game, "b") -- NO + end + -- drain the B edge before the transfer text can see it + for _ = 1, 8 do U.wait(1) end + local sawBox = false + for _ = 1, 400 do + if game.stack:top() == battle and battle.current and battle.current.text + and battle.current.text:find("transferred", 1, true) then + sawBox = true + -- wait until the typewriter has drawn most of the line + if (battle.charIndex or 0) >= math.floor((battle.total or 0) * 0.7) then + break + end + end + U.wait(1) + end + U.log("full-party box text:", sawBox, + battle.current and battle.current.text or "nil") + U.shot(game, DIR .. "/catch_fullparty_box.png") + + for _ = 1, 30 do U.tap(game, "a"); U.wait(4) end + while game.stack:top() ~= ow do game.stack:pop() end end diff --git a/tests/drivers/cerulean_rocket_test.lua b/tests/drivers/cerulean_rocket_test.lua new file mode 100644 index 00000000..c64f61ac --- /dev/null +++ b/tests/drivers/cerulean_rocket_test.lua @@ -0,0 +1,70 @@ +-- Driver: Cerulean TM28 Rocket despawn fade (#170). +-- Skips the fight (EVENT_BEAT set), talks through the TM return, and +-- captures a mid-fade frame while CeruleanHideRocket runs. +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") + table.insert(game.save.party, Pokemon.new(game.data, "BLASTOISE", 50)) + game.save.flags.EVENT_BEAT_CERULEAN_ROCKET_THIEF = true + -- Rocket at (30,8); stand south and face him + U.teleport(game, "CERULEAN_CITY", 30, 9, "up") + local ow = game.overworld + + local function mashUntil(cond, label, cap) + for _ = 1, cap or 600 do + if cond() then return true end + if game.stack:top() ~= ow then U.tap(game, "a") end + U.wait(3) + end + U.log("TIMEOUT waiting for " .. label) + return false + end + + U.shot(game, DIR .. "/cerulean_rocket_00_before.png") + U.tap(game, "a") + U.wait(20) + U.shot(game, DIR .. "/cerulean_rocket_01_return_tm.png") + + local function rocketAlive() + for _, n in ipairs(ow.npcs or {}) do + if n.def and n.def.name == "CERULEANCITY_ROCKET" then return true end + end + return false + end + + -- Prefer mid fade-in (rocket already toggled off, screen still dark), + -- matching pokered's post-HideObject GBFadeInFromBlack frames. + local midShot = false + for _ = 1, 900 do + local overlay = ow.fadeOverlay + local alpha = overlay and overlay.alpha or 0 + if not midShot and alpha > 0.35 and alpha < 0.95 and not rocketAlive() then + U.shot(game, DIR .. "/cerulean_rocket_02_mid_fade.png") + midShot = true + end + if midShot and not ow.fadeOverlay and game.stack:top() == ow + and not ow.runner:isRunning() then + break + end + if game.stack:top() ~= ow then U.tap(game, "a") end + U.wait(2) + end + U.log("mid-fade shot:", tostring(midShot)) + mashUntil(function() + return game.stack:top() == ow and not ow.runner:isRunning() + and not ow.fadeOverlay + end, "idle after hide", 200) + U.wait(10) + U.shot(game, DIR .. "/cerulean_rocket_03_after.png") + + local names = {} + for _, n in ipairs(ow.npcs or {}) do + names[#names + 1] = n.def and n.def.name or "?" + end + local toggles = game.save.objectToggles and game.save.objectToggles.CERULEAN_CITY + U.log("rocket alive:", tostring(rocketAlive())) + U.log("rocket toggle:", tostring(toggles and toggles.CERULEANCITY_ROCKET)) + U.log("got TM28:", tostring(game.save.flags.EVENT_GOT_TM28)) + U.log("npcs:", table.concat(names, ",")) +end diff --git a/tests/drivers/copycat_tm31_test.lua b/tests/drivers/copycat_tm31_test.lua new file mode 100644 index 00000000..cbb98b17 --- /dev/null +++ b/tests/drivers/copycat_tm31_test.lua @@ -0,0 +1,104 @@ +-- Driver: Copycat TM31 MIMIC trade (scripts/CopycatsHouse2F.asm). +-- Teleport to 2F with a POKE DOLL, talk through the mimic dialogue + gift, +-- screenshot the TM receive box, and assert flag/inventory. +-- +-- SHOT_DIR=/tmp/copycat_tm31 POKEPORT_DRIVER=tests/drivers/copycat_tm31_test.lua love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local Bag = require("src.inventory.Bag") + local TextBox = require("src.render.TextBox") + + Bag.add(game.save, "POKE_DOLL", 1) + U.teleport(game, "COPYCATS_HOUSE_2F", 4, 4, "up") + local ow = game.overworld + + for _, npc in ipairs(ow.npcs) do + if npc.def and npc.def.text == "TEXT_COPYCATSHOUSE2F_COPYCAT" then + npc.wanders, npc.moving = false, false + npc.cellX, npc.cellY = 4, 3 + npc.px, npc.py = npc.cellX * 16, npc.cellY * 16 + npc.facing = "down" + end + end + U.wait(5) + U.shot(game, DIR .. "/copycat_0_room.png") + + local function topBox() + local top = game.stack:top() + return getmetatable(top) == TextBox and top or nil + end + + local function pageText(box) + if not box then return "" end + return table.concat(box.pages[box.pageIndex] or {}, "\n") + end + + -- Advance until a fully-typed page matches pred(pageText), then stop + -- without consuming that page (so the shot catches it). Last pages + -- set done (not waiting); mid-text page breaks set waiting. + local function mashToPage(pred) + for _ = 1, 500 do + local box = topBox() + if box and (box.waiting or box.done) and pred(pageText(box)) then + return true + end + U.tap(game, "a") + U.wait(3) + end + return false + end + + U.tap(game, "a") + U.wait(20) + U.log("mimic page:", mashToPage(function(s) + return s:find("like POKéMON", 1, true) ~= nil + or s:find("like POK", 1, true) ~= nil + end)) + U.shot(game, DIR .. "/copycat_1_mimic.png") + + U.log("pre-receive page:", mashToPage(function(s) + return s:find("DOLL", 1, true) ~= nil + end)) + U.shot(game, DIR .. "/copycat_2_prereceive.png") + + U.log("TM receive page:", mashToPage(function(s) + return s:find("received", 1, true) ~= nil + and s:find("TM31", 1, true) ~= nil + end)) + U.shot(game, DIR .. "/copycat_3_received_tm31.png") + + local function mash(btn, cond) + for _ = 1, 400 do + if cond() then return true end + U.tap(game, btn) + U.wait(3) + end + return false + end + + U.log("dialogue done:", mash("a", function() + return game.stack:top() == ow + end)) + U.shot(game, DIR .. "/copycat_4_done.png") + + U.log("EVENT_GOT_TM31:", tostring(game.save.flags.EVENT_GOT_TM31)) + U.log("bag TM_MIMIC:", tostring(game.save.inventory.TM_MIMIC), + "POKE_DOLL:", tostring(game.save.inventory.POKE_DOLL)) + + assert(game.save.flags.EVENT_GOT_TM31, "EVENT_GOT_TM31 not set") + assert((game.save.inventory.TM_MIMIC or 0) >= 1, "TM_MIMIC missing from bag") + assert((game.save.inventory.POKE_DOLL or 0) == 0, "POKE_DOLL not taken") + + U.tap(game, "a") + U.wait(20) + U.log("thanks page:", mashToPage(function(s) + return s:find("Thanks for TM31", 1, true) ~= nil + end)) + U.wait(2) + U.shot(game, DIR .. "/copycat_5_thanks.png") + mash("a", function() return game.stack:top() == ow end) + + U.log("DONE") + love.event.quit() +end diff --git a/tests/drivers/daycare_moves_test.lua b/tests/drivers/daycare_moves_test.lua new file mode 100644 index 00000000..e4492236 --- /dev/null +++ b/tests/drivers/daycare_moves_test.lua @@ -0,0 +1,75 @@ +-- Driver (#184): Magikarp Day Care 13→19 should learn Tackle at 15. +-- Runs the DAYCARE retrieve script (same path as the gentleman NPC), +-- logs the moveset, then screenshots status page 2 (moves + PP). +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 Growth = require("src.pokemon.Growth") + local SummaryMenu = require("src.ui.SummaryMenu") + local story2 = require("data.scripts.story2") + + local def = game.data.pokemon.MAGIKARP + local karp = Pokemon.new(game.data, "MAGIKARP", 13) + karp.nickname = "FISHY" + game.save.party = { Pokemon.new(game.data, "RATTATA", 5) } + game.save.money = 10000 + game.save.player.name = "RED" + + local steps = Growth.expForLevel(def.growthRate, 19) + - Growth.expForLevel(def.growthRate, 13) + game.save.daycare = { + mon = karp, steps = steps, depositLevel = 13, + } + + local before = {} + for _, mv in ipairs(karp.moves) do before[#before + 1] = mv.id end + U.log("before: Lv", karp.level, "moves", table.concat(before, ",")) + + U.teleport(game, "DAYCARE", 2, 2, "up") + + -- Resolve dialogue synchronously (parity_daycare style) without stacking + -- fake TextBoxes over the overworld. + local realTextBox = package.loaded["src.render.TextBox"] + package.loaded["src.render.TextBox"] = { + new = function(_, text, onDone, opts) + if opts and opts.choice then + opts.choice(true) + elseif onDone then + onDone() + end + return { text = text } + end, + } + local realPush = game.stack.push + game.stack.push = function() end + local done = false + story2.DAYCARE.talk.TEXT_DAYCARE_GENTLEMAN( + game, game.overworld, nil, function() done = true end) + game.stack.push = realPush + package.loaded["src.render.TextBox"] = realTextBox + + local mon = game.save.party[#game.save.party] + local ids = {} + for _, mv in ipairs(mon.moves or {}) do ids[#ids + 1] = mv.id end + U.log("after: Lv", mon.level, "moves", table.concat(ids, ","), + "done=", done, "daycare_cleared=", game.save.daycare == nil) + + local hasTackle = false + for _, id in ipairs(ids) do if id == "TACKLE" then hasTackle = true end end + U.log("Magikarp 13→19 learned Tackle:", hasTackle) + + local summary = SummaryMenu.new(game, mon) + game.stack:push(summary) + U.wait(4) + U.shot(game, DIR .. "/daycare_00_summary_p1.png") + summary.page = 2 + U.wait(2) + U.shot(game, DIR .. "/daycare_01_moves.png") + U.log("shots under", DIR) + + if not (mon.species == "MAGIKARP" and mon.level == 19 and hasTackle) then + error("daycare #184 failed: Lv" .. tostring(mon.level) + .. " [" .. table.concat(ids, ",") .. "]") + end +end diff --git a/tests/drivers/dialogue_cont_wait_test.lua b/tests/drivers/dialogue_cont_wait_test.lua new file mode 100644 index 00000000..306d2ddf --- /dev/null +++ b/tests/drivers/dialogue_cont_wait_test.lua @@ -0,0 +1,97 @@ +-- Driver: #163 cont (\\v) must show ▼ and wait for A before scrolling. +-- Talks to VIRIDIANFOREST_YOUNGSTER5 (cont mid-page) and the nearby +-- TRAINER TIPS1 sign (para then conts). Screenshots the ▼ wait frames. +-- +-- SHOT_DIR=/tmp/dialogue_cont POKEPORT_DRIVER=tests/drivers/dialogue_cont_wait_test.lua love . + +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/dialogue_cont" + local TextBox = require("src.render.TextBox") + + local pass = true + local function check(cond, msg) + if cond then U.log("PASS: " .. msg) else pass = false; U.log("FAIL: " .. msg) end + end + local function topIs(mt) return getmetatable(game.stack:top()) == mt end + + local function waitForTextBox(frames) + for _ = 1, frames or 60 do + if topIs(TextBox) then return game.stack:top() end + U.wait(1) + end + return nil + end + + -- type out until waiting/done without mashing A (held A would only + -- speed the typewriter; wasPressed edges are what clear waits) + local function waitUntilPrompt(box, frames) + for _ = 1, frames or 240 do + if not topIs(TextBox) then return false end + if box.waiting or box.done then return true end + U.wait(1) + end + return box.waiting or box.done + end + + -- ▼ blinks half the time (blink < 30); settle so the shot shows it. + -- Keep POKEPORT_SPEED=1: scriptedIterations>1 can advance past the + -- wait in the same rendered frame before love.draw captures. + local function shotPrompt(box, path) + for _ = 1, 60 do + if not (box.waiting or box.done) then break end + if box.blink < 20 then break end + U.wait(1) + end + U.shot(game, path) + U.wait(2) + end + + local function dismissText() + for _ = 1, 80 do + if not topIs(TextBox) then return end + U.tap(game, "a") + U.wait(2) + end + end + + -- Youngster5 @ (27,40): "I ran out of POKé / BALLs to catch" then + -- \v "POKéMON with!" — the bug auto-scrolled past this with no ▼. + U.teleport(game, "VIRIDIAN_FOREST", 27, 41, "up") + U.tap(game, "a") + local box = waitForTextBox(90) + check(box ~= nil, "youngster5 opened a TextBox") + if box then + check(waitUntilPrompt(box, 300), "youngster5 reached a prompt") + check(box.waiting and box.contAdvance, + "youngster5 first prompt is a cont wait (▼)") + shotPrompt(box, DIR .. "/dialogue_cont_00_youngster5_arrow.png") + U.tap(game, "a") + U.wait(3) + check(waitUntilPrompt(box, 300), "youngster5 reached para/done after cont") + shotPrompt(box, DIR .. "/dialogue_cont_01_youngster5_after.png") + dismissText() + end + + -- Trainer Tips1 sign @ (24,40): para after title, then two conts. + U.teleport(game, "VIRIDIAN_FOREST", 24, 41, "up") + U.tap(game, "a") + box = waitForTextBox(90) + check(box ~= nil, "tips1 sign opened a TextBox") + if box then + check(waitUntilPrompt(box, 300), "tips1 reached first prompt") + -- first prompt is the para after "TRAINER TIPS" + check(box.waiting and not box.contAdvance, + "tips1 first prompt is a para wait") + shotPrompt(box, DIR .. "/dialogue_cont_02_tips1_para.png") + U.tap(game, "a") + U.wait(3) + check(waitUntilPrompt(box, 300), "tips1 typed into page 2") + check(box.waiting and box.contAdvance, + "tips1 page2 pauses on cont with ▼") + shotPrompt(box, DIR .. "/dialogue_cont_03_tips1_cont_arrow.png") + dismissText() + end + + U.log(pass and "RESULT: ALL PASS" or "RESULT: SEE FAILURES ABOVE") +end diff --git a/tests/drivers/door_test.lua b/tests/drivers/door_test.lua index d434c868..0ff70bae 100644 --- a/tests/drivers/door_test.lua +++ b/tests/drivers/door_test.lua @@ -1,5 +1,5 @@ --- Driver: reproduce the door-warp flow. Teleports to Pallet Town in --- front of Red's house, walks in, tries to move inside, walks back out. +-- Driver: door-warp flow (Pallet house) plus #187 S.S. Anne enter-from-above. +-- Anne-only shots: tests/drivers/anne_door_test.lua return function(game) local U = dofile("tests/drivers/util.lua") @@ -32,4 +32,20 @@ return function(game) U.shot(game, DIR .. "/door_9_back_outside.png") U.log("final map:", ow.map.id, "pos:", ow.player.cellX, ow.player.cellY, "transitioning:", tostring(ow.transitioning)) + + -- #187: enter SS Anne cabin from above; expect one tile south of door + U.teleport(game, "SS_ANNE_1F", 31, 9, "up") + U.shot(game, DIR .. "/door_10_anne_before.png") + U.hold(game, "up", 20) + for _ = 1, 90 do + U.wait(1) + if ow.map.id == "SS_ANNE_1F_ROOMS" and not ow.transitioning + and #ow.scriptMoves == 0 and not ow.player.moving then + break + end + end + U.wait(8) + U.shot(game, DIR .. "/door_11_anne_after.png") + U.log("anne map:", ow.map.id, "pos:", ow.player.cellX, ow.player.cellY, + "facing:", ow.player.facing) end diff --git a/tests/drivers/gym_leader_victory_test.lua b/tests/drivers/gym_leader_victory_test.lua new file mode 100644 index 00000000..0f74f596 --- /dev/null +++ b/tests/drivers/gym_leader_victory_test.lua @@ -0,0 +1,93 @@ +-- Driver: gym-leader post-battle dialogue chain (#164). +-- Invokes checkVictoryRewards for Brock then Misty (same path as a win) +-- and screenshots pages that must include badge-effect + TM explanation +-- text — not just a synthetic "received badge/TM" stub. +-- +-- SHOT_DIR=/tmp/gym164 POKEPORT_DRIVER=tests/drivers/gym_leader_victory_test.lua love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local TextBox = require("src.render.TextBox") + local OW = require("src.world.OverworldController") + + local function currentPageText() + local top = game.stack:top() + if getmetatable(top) ~= TextBox then return "" end + local page = top.pages and top.pages[top.pageIndex] + if not page then return "" end + return table.concat(page, "\n") + end + + local function mashUntil(cond) + for _ = 1, 1200 do + if cond() then return true end + U.tap(game, "a") + U.wait(2) + end + return false + end + + local function advancePages(want, shotName) + U.log(shotName, "waiting for:", want) + local ok = mashUntil(function() + local top = game.stack:top() + if getmetatable(top) ~= TextBox then return false end + -- only match once the current page has finished typing + if not (top.waiting or top.done) then return false end + return currentPageText():find(want, 1, true) ~= nil + end) + U.log(shotName, "found:", ok, "page:", currentPageText():gsub("\n", "\\n")) + U.shot(game, DIR .. "/" .. shotName .. ".png") + assert(ok, shotName .. " missing dialogue containing: " .. want) + end + + local function runLeader(mapId, x, y, class, party, shots) + while game.stack:top() do game.stack:pop() end + game.save.flags = game.save.flags or {} + game.save.inventory = game.save.inventory or {} + game.save.defeatedTrainers = game.save.defeatedTrainers or {} + game.stack:push(OW, mapId, x, y, "up") + U.wait(5) + local ow = game.stack:top() + U.shot(game, DIR .. "/" .. shots.prefix .. "_0_gym.png") + ow:checkVictoryRewards(class, party) + U.wait(10) + for _, s in ipairs(shots.pages) do + advancePages(s.want, shots.prefix .. "_" .. s.name) + end + U.log(shots.prefix, "closing box:", mashUntil(function() + return game.stack:top() == ow + end)) + U.shot(game, DIR .. "/" .. shots.prefix .. "_done.png") + end + + game.save.player.name = game.save.player.name or "RED" + + runLeader("PEWTER_GYM", 4, 3, "OPP_BROCK", 1, { + prefix = "brock", + pages = { + { want = "BOULDERBADGE", name = "1_badge" }, + { want = "FLASH", name = "2_flash" }, + { want = "Wait!", name = "3_wait" }, + { want = "TM34", name = "4_tm34" }, + { want = "BIDE", name = "5_bide" }, + }, + }) + assert(game.save.flags.EVENT_BEAT_BROCK, "EVENT_BEAT_BROCK") + assert(game.save.inventory.BOULDERBADGE, "BOULDERBADGE") + assert((game.save.inventory.TM_BIDE or 0) >= 1, "TM_BIDE") + + runLeader("CERULEAN_GYM", 5, 5, "OPP_MISTY", 1, { + prefix = "misty", + pages = { + { want = "CASCADEBADGE", name = "1_badge" }, + { want = "CUT", name = "2_cut" }, + { want = "TM11", name = "3_tm11" }, + }, + }) + assert(game.save.flags.EVENT_BEAT_MISTY, "EVENT_BEAT_MISTY") + assert(game.save.inventory.CASCADEBADGE, "CASCADEBADGE") + assert((game.save.inventory.TM_BUBBLEBEAM or 0) >= 1, "TM_BUBBLEBEAM") + + U.log("gym_leader_victory_test: ok") +end diff --git a/tests/drivers/heal_test.lua b/tests/drivers/heal_test.lua index b339f2a0..19158252 100644 --- a/tests/drivers/heal_test.lua +++ b/tests/drivers/heal_test.lua @@ -1,10 +1,11 @@ --- Driver: Pokémon Center nurse heal, welcome/choice dialogue, the --- machine monitor + per-mon balls, the jingle flash, and the farewell. +-- Driver: Pokémon Center nurse heal + Mom home heal. +-- Logs frames between healAnim end and the fighting-fit / looking-great text. 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 ChoiceBox = require("src.ui.ChoiceBox") + local TextBox = require("src.render.TextBox") local mon = Pokemon.new(game.data, "CHARMANDER", 12) mon.hp = 3 table.insert(game.save.party, mon) @@ -14,7 +15,6 @@ return function(game) U.teleport(game, "VIRIDIAN_POKECENTER", 3, 3, "up") local ow = game.overworld - -- mash A until a condition holds local function mashUntil(cond) for _ = 1, 400 do if cond() then return true end @@ -24,6 +24,11 @@ return function(game) return false end + local function topIsText() + return getmetatable(game.stack:top()) == TextBox + end + + -- -------- Poké Center -------- U.tap(game, "a") -- talk to the nurse U.wait(30) U.shot(game, DIR .. "/heal_00_welcome.png") @@ -38,7 +43,6 @@ return function(game) U.shot(game, DIR .. "/heal_02_ball1.png") U.wait(28) U.shot(game, DIR .. "/heal_03_ball2.png") - -- overlay must stay glued to the machine under survey zoom game:zoomStep(-1); game:zoomStep(-1) U.wait(3) U.shot(game, DIR .. "/heal_03z_zoomed.png") @@ -51,14 +55,73 @@ return function(game) U.shot(game, DIR .. "/heal_05_flash_b.png") U.wait(10) U.shot(game, DIR .. "/heal_06_flash_c.png") - -- wait out the jingle + 32-frame beat - for _ = 1, 40 do - if not ow.healAnim then break end - U.wait(10) + + local animEndFrame, fitFrame + for _ = 1, 400 do + if ow.healAnim == nil and not animEndFrame then + animEndFrame = U.frame() + if topIsText() then fitFrame = animEndFrame end + U.shot(game, DIR .. "/heal_06b_anim_end.png") + if fitFrame then break end + end + if animEndFrame and topIsText() and not fitFrame then + fitFrame = U.frame() + break + end + U.wait(1) end U.shot(game, DIR .. "/heal_07_fit.png") + U.log("pc anim_end_frame:", animEndFrame, "fit_frame:", fitFrame, + "gap_anim_to_text:", + (fitFrame and animEndFrame) and (fitFrame - animEndFrame) or "n/a") U.log("farewell reached:", mashUntil(function() return game.stack:top() == ow end)) U.shot(game, DIR .. "/heal_08_end.png") + + -- -------- Mom (Reds House) -------- + require("src.script.Flags").set(game.save, "EVENT_GOT_STARTER") + for _, m in ipairs(game.save.party) do + m.hp = 1 + end + -- stand below Mom (5,4) facing up + U.teleport(game, "REDS_HOUSE_1F", 5, 5, "up") + ow = game.overworld + U.tap(game, "a") + U.wait(20) + U.shot(game, DIR .. "/heal_mom_00_rest.png") + U.log("mom fade started:", mashUntil(function() + return ow.fadeOverlay ~= nil + end)) + local fadeStart = U.frame() + U.shot(game, DIR .. "/heal_mom_01_fade.png") + local Music = require("src.core.Music") + local sawJingle, jingleDoneFrame, fadeGone, greatFrame = false + for _ = 1, 600 do + if Music.oneShotPlaying() then sawJingle = true end + if sawJingle and not Music.oneShotPlaying() and not jingleDoneFrame then + jingleDoneFrame = U.frame() + end + if ow.fadeOverlay == nil and fadeStart and not fadeGone + and U.frame() > fadeStart + 5 then + fadeGone = U.frame() + end + if fadeGone and topIsText() then + greatFrame = U.frame() + break + end + U.wait(1) + end + U.shot(game, DIR .. "/heal_mom_02_great.png") + U.log("mom saw_jingle:", sawJingle, + "jingle_done_frame:", jingleDoneFrame, + "fade_gone_frame:", fadeGone, + "great_frame:", greatFrame, + "gap_fade_to_text:", + (greatFrame and fadeGone) and (greatFrame - fadeGone) or "n/a") + mashUntil(function() + return game.stack:top() == ow + end) + U.shot(game, DIR .. "/heal_mom_03_end.png") end + diff --git a/tests/drivers/learn_move_full_test.lua b/tests/drivers/learn_move_full_test.lua new file mode 100644 index 00000000..e89d6c71 --- /dev/null +++ b/tests/drivers/learn_move_full_test.lua @@ -0,0 +1,162 @@ +-- Driver: level-up learn with a full moveset (#173). +-- Injects the post-KO level-up queue (grew + StatBox + learnMove) inside +-- a live wild battle so MoveLearnMenu runs the same path as a real win. +-- +-- SHOT_DIR=/tmp/learn173 POKEPORT_DRIVER=tests/drivers/learn_move_full_test.lua love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/learn173" + os.execute("mkdir -p " .. DIR) + + local Pokemon = require("src.pokemon.Pokemon") + local mon = Pokemon.new(game.data, "CHARMANDER", 14) + mon.moves = { + { id = "SCRATCH", pp = 35 }, + { id = "GROWL", pp = 40 }, + { id = "EMBER", pp = 25 }, + { id = "SMOKESCREEN", pp = 20 }, + } + table.insert(game.save.party, 1, mon) + game.save.options = game.save.options or {} + game.save.options.textSpeed = 1 + + U.teleport(game, "ROUTE_1", 5, 5, "down") + local ow = game.overworld + local BattleState = require("src.battle.BattleState") + local battle = BattleState.newWild(game, "RATTATA", 2) + battle.onFinish = function() end + ow:pushBattle(battle) + + local function top() return game.stack:top() end + local function stackIds() + local parts = {} + for _, st in ipairs(game.stack.states or {}) do + local id = st.screenId or (st.pages and "TextBox") + or (st.onChoose and "ChoiceBox") + or (st.mon and st.stats and "StatBox") + or (st.newMoveId and "MoveLearnMenu") + or (st.phase and ("Battle:" .. tostring(st.phase))) + or "?" + if st.selecting ~= nil then + id = id .. (st.selecting and "[sel]" or "[ask]") + end + if st.pages then + id = id .. string.format("[p%d/%d%s%s]", st.pageIndex or 0, #st.pages, + st.waiting and "W" or "", st.done and "D" or "") + end + parts[#parts + 1] = id + end + return table.concat(parts, ">") + end + local function isText() + local t = top() + return t and t.pages ~= nil + end + local function isChoice() + local t = top() + return t and t.onChoose ~= nil and t.index ~= nil and not t.mon + end + local function learnMenu() + for _, st in ipairs(game.stack.states or {}) do + if st.newMoveId and st.mon and st.index then return st end + end + end + local function waitFor(cond, max) + for i = 1, max or 900 do + if cond() then return true end + U.wait(1) + end + return false + end + local function atPrompt() + local t = top() + return t and t.pages and (t.waiting or t.done) + end + -- #163: \v conts pause with ▼ mid-page; tap through them so the shot + -- lands on the full page (or the done+choice tail) + local function advanceTextPage() + waitFor(atPrompt, 300) + for _ = 1, 4 do + local t = top() + if not (t and t.pages and t.waiting and t.contAdvance) then break end + U.tap(game, "a") + U.wait(2) + waitFor(atPrompt, 300) + end + U.wait(2) + end + + waitFor(function() return battle.phase == "menu" end, 400) + local StatBox = BattleState.StatBox + battle.phase = "messages" + battle.afterQueue = "menu" + battle.queue = {} + battle.current = nil + battle.nextInsert = 0 + battle:say("CHARMANDER gained\n16 EXP. Points!") + battle:say("CHARMANDER grew\nto level 15!") + battle:ui(function() return StatBox.new(game, mon) end) + mon.level = 15 + -- append (not uiNext): learnMove uses uiNext for mid-fn ordering + battle:ui(function() + return battle:buildScreen("MoveLearnMenu", mon, "LEER") + end) + + for _ = 1, 400 do + if learnMenu() then break end + U.tap(game, "a") + U.wait(2) + end + if not learnMenu() then error("MoveLearnMenu never opened: " .. stackIds()) end + U.log("opened", stackIds()) + + -- page 1 + advanceTextPage() + U.log("shot0", stackIds()) + U.shot(game, DIR .. "/learn_0_trying.png") + U.tap(game, "a"); U.wait(2) + + -- page 2 + advanceTextPage() + U.log("shot1", stackIds()) + U.shot(game, DIR .. "/learn_1_cant_more.png") + U.tap(game, "a"); U.wait(2) + + -- page 3 types out (through its cont), then ChoiceBox overlays + advanceTextPage() + if not waitFor(isChoice, 120) then + error("ChoiceBox never appeared: " .. stackIds()) + end + U.wait(4) -- let choice settle over Delete prompt + U.log("shot2", stackIds()) + U.shot(game, DIR .. "/learn_2_delete_yesno.png") + U.tap(game, "a"); U.wait(4) -- YES + + if not waitFor(function() + local m = learnMenu() + return m and m.selecting and top() == m + end, 120) then + error("forget list never active: " .. stackIds()) + end + U.log("shot3", stackIds()) + U.shot(game, DIR .. "/learn_3_which_move.png") + U.tap(game, "a"); U.wait(4) -- forget SCRATCH + + local n = 4 + for _ = 1, 60 do + if not isText() then break end + local t = top() + if t.done or t.waiting then + U.log("shot" .. n, stackIds()) + U.shot(game, ("%s/learn_%d_after.png"):format(DIR, n)) + n = n + 1 + U.tap(game, "a") + U.wait(4) + else + U.wait(1) + end + end + + U.log("done", DIR, "moves[1]=", mon.moves[1] and mon.moves[1].id, stackIds()) + love.event.quit() +end diff --git a/tests/drivers/mt_moon_fossil_test.lua b/tests/drivers/mt_moon_fossil_test.lua new file mode 100644 index 00000000..19613a98 --- /dev/null +++ b/tests/drivers/mt_moon_fossil_test.lua @@ -0,0 +1,112 @@ +-- Driver: Mt Moon B2F fossil pick (scripts/MtMoonB2F.asm). After the +-- Super Nerd is beaten, take one fossil and confirm he walks to the other +-- and says "All right. Then this is mine!" before it vanishes. +-- Runs DOME then HELIX paths (map is reset between). +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") + + if #game.save.party == 0 then + table.insert(game.save.party, Pokemon.new(game.data, "PIKACHU", 20)) + end + + local function runPick(tag, x, y, takeName, leaveName, itemId, expectNerdX, expectNerdY, gotFlag) + game.save.flags = game.save.flags or {} + game.save.flags.EVENT_BEAT_MT_MOON_3_SUPER_NERD = true + game.save.flags.EVENT_GOT_DOME_FOSSIL = nil + game.save.flags.EVENT_GOT_HELIX_FOSSIL = nil + game.save.inventory.DOME_FOSSIL = nil + game.save.inventory.HELIX_FOSSIL = nil + game.save.objectToggles = game.save.objectToggles or {} + game.save.objectToggles.MT_MOON_B2F = nil + + U.teleport(game, "MT_MOON_B2F", x, y, "up") + local ow = game.overworld + U.wait(5) + + local function idle() + return game.stack:top() == ow and not ow.runner:isRunning() + and #ow.scriptMoves == 0 + end + local function mash(cond, label, cap) + for _ = 1, cap or 400 do + if cond() then return true end + U.tap(game, "a") + U.wait(3) + end + U.log("TIMEOUT:", tag, label) + return false + end + local function fossilVisible(name) + for _, n in ipairs(ow.npcs) do + if n.def and n.def.name == name then return true end + end + return false + end + local function nerd() + return ow:npcByIndex(1) + end + + U.shot(game, DIR .. ("/mtmoon_%s_0_before.png"):format(tag)) + U.log(tag, "before", takeName .. ":", tostring(fossilVisible(takeName)), + leaveName .. ":", tostring(fossilVisible(leaveName)), + "nerd:", nerd() and (nerd().cellX .. "," .. nerd().cellY) or "nil") + + U.tap(game, "a") + U.wait(24) + U.shot(game, DIR .. ("/mtmoon_%s_1_ask.png"):format(tag)) + U.tap(game, "a") -- YES + mash(function() + return game.stack:top() == ow or ow.runner:isRunning() + end, "received / walk start", 200) + + local sawMineText, midwalk = false, false + for _ = 1, 500 do + local n = nerd() + if not midwalk and n and (n.cellX ~= 12 or n.cellY ~= 8 + or n.moving or #ow.scriptMoves > 0) then + if n.cellX ~= 12 or n.cellY ~= 8 or n.moving then + midwalk = true + U.shot(game, DIR .. ("/mtmoon_%s_2_walk.png"):format(tag)) + end + end + local top = game.stack:top() + if top and top ~= ow and top.pages and top.done then + local flat = "" + for _, page in ipairs(top.pages) do + for _, line in ipairs(page) do flat = flat .. line .. " " end + end + if flat:find("this is mine", 1, true) then + sawMineText = true + U.shot(game, DIR .. ("/mtmoon_%s_3_mine.png"):format(tag)) + break + end + end + U.wait(1) + end + + mash(idle, "cutscene idle", 400) + U.shot(game, DIR .. ("/mtmoon_%s_4_done.png"):format(tag)) + + local n = nerd() + U.log(tag, "sawMineText:", tostring(sawMineText), + "midwalk:", tostring(midwalk)) + U.log(tag, "after", takeName .. ":", tostring(fossilVisible(takeName)), + leaveName .. ":", tostring(fossilVisible(leaveName))) + U.log(tag, "nerd at:", n and (n.cellX .. "," .. n.cellY) or "nil", + "expect:", expectNerdX .. "," .. expectNerdY) + U.log(tag, "flag:", gotFlag, tostring(game.save.flags[gotFlag]), + "bag:", tostring(game.save.inventory[itemId])) + end + + runPick("dome", 12, 7, + "MTMOONB2F_DOME_FOSSIL", "MTMOONB2F_HELIX_FOSSIL", "DOME_FOSSIL", + 13, 7, "EVENT_GOT_DOME_FOSSIL") + runPick("helix", 13, 7, + "MTMOONB2F_HELIX_FOSSIL", "MTMOONB2F_DOME_FOSSIL", "HELIX_FOSSIL", + 12, 7, "EVENT_GOT_HELIX_FOSSIL") + + U.log("DONE") + love.event.quit() +end diff --git a/tests/drivers/oak_test.lua b/tests/drivers/oak_test.lua index 92a8e626..d3683ea9 100644 --- a/tests/drivers/oak_test.lua +++ b/tests/drivers/oak_test.lua @@ -1,6 +1,5 @@ --- Driver: the full Pallet intro chain. Steps north to trigger Oak, --- follows him to the lab, takes a starter, watches the rival --- counter-pick, tries to leave (door gate + ambush battle). +-- Driver: Pallet intro (Oak escort → starter → rival ambush) plus the +-- #158 parcel-return / rival-join / Pokédex-gift cutscene. return function(game) local U = dofile("tests/drivers/util.lua") @@ -96,13 +95,68 @@ return function(game) U.log("final:", ow.map.id, ow.player.cellX, ow.player.cellY, "battled:", tostring(game.save.flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB), "battleSeen:", tostring(BattleSeen)) - U.log("stuck-state: runner=", tostring(ow.runner:isRunning()), - "moves=", #ow.scriptMoves, "top=", tostring(game.stack:top() == ow), - "transitioning=", tostring(ow.transitioning), - "emote=", tostring(ow.emote ~= nil)) - for i, mv in ipairs(ow.scriptMoves) do - U.log((" move %d: entity=%s dir=%s remaining=%d moving=%s"):format( - i, tostring(mv.entity.def and mv.entity.def.sprite or "PLAYER"), - tostring(mv.dir), mv.remaining, tostring(mv.entity.moving))) + + -- ---- #158: return Oak's Parcel → rival joins → Pokédex gift ---- + local Flags = require("src.script.Flags") + Flags.set(game.save, "EVENT_GOT_OAKS_PARCEL") + game.save.inventory.OAKS_PARCEL = 1 + -- desk Oak + both dexes visible; rival hidden until the script shows + -- him. Keep the starter-ball hides from the pick (Squirtle + Bulbasaur). + game.save.objectToggles = game.save.objectToggles or {} + game.save.objectToggles.OAKS_LAB = { + OAKSLAB_OAK1 = true, + OAKSLAB_OAK2 = false, + OAKSLAB_RIVAL = false, + OAKSLAB_POKEDEX1 = true, + OAKSLAB_POKEDEX2 = true, + OAKSLAB_SQUIRTLE_POKE_BALL = false, + OAKSLAB_BULBASAUR_POKE_BALL = false, + } + U.teleport(game, "OAKS_LAB", 5, 3, "up") + ow = game.overworld + U.wait(20) + U.shot(game, DIR .. "/oak_12_parcel_ready.png") + U.tap(game, "a") + U.wait(40) + U.shot(game, DIR .. "/oak_13_deliver.png") + local rivalSeen, atDeskShot, leaveShot = false, false, false + for _ = 1, 1200 do + if idle() and Flags.get(game.save, "EVENT_GOT_POKEDEX") + and not ow:npcByIndex(1) then + break + end + local rival = ow:npcByIndex(1) + local top = game.stack:top() + if rival and not rivalSeen then + rivalSeen = true + U.wait(24) + U.shot(game, DIR .. "/oak_14_rival_arrive.png") + end + if rival and rival.cellY <= 3 and not atDeskShot then + atDeskShot = true + U.shot(game, DIR .. "/oak_14b_rival_at_desk.png") + end + if rival and rival.cellY >= 6 and Flags.get(game.save, "EVENT_GOT_POKEDEX") + and not leaveShot then + leaveShot = true + U.shot(game, DIR .. "/oak_14c_rival_leave.png") + end + if top ~= ow then + U.tap(game, "a") + end + U.wait(4) end + mashUntil(idle, "pokedex cutscene done", 600) + U.shot(game, DIR .. "/oak_15_after_pokedex.png") + local toggles = game.save.objectToggles and game.save.objectToggles.OAKS_LAB + local rivalAfter = ow:npcByIndex(1) + U.log("parcel:", + "gotDex=", tostring(Flags.get(game.save, "EVENT_GOT_POKEDEX")), + "oakGot=", tostring(Flags.get(game.save, "EVENT_OAK_GOT_PARCEL")), + "rivalSeen=", tostring(rivalSeen), + "rivalGone=", tostring(rivalAfter == nil), + "atDesk=", tostring(atDeskShot), + "dex1=", tostring(toggles and toggles.OAKSLAB_POKEDEX1), + "dex2=", tostring(toggles and toggles.OAKSLAB_POKEDEX2), + "route22=", tostring(Flags.get(game.save, "EVENT_ROUTE22_RIVAL_WANTS_BATTLE"))) end diff --git a/tests/drivers/pc_deposit_test.lua b/tests/drivers/pc_deposit_test.lua new file mode 100644 index 00000000..0de8e0c1 --- /dev/null +++ b/tests/drivers/pc_deposit_test.lua @@ -0,0 +1,87 @@ +-- Driver: Player's PC deposit "How many?" with >7 bag stacks (#174). +-- pokered PrintListMenuEntries shows 4 names; PrintText + quantity box +-- must not overlap the list. +-- SHOT_DIR=/tmp/pc_deposit POKEPORT_DRIVER=tests/drivers/pc_deposit_test.lua love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local PlayerPC = require("src.ui.PlayerPC") + local ListMenu = require("src.ui.ListMenu") + local QuantityBox = require("src.ui.QuantityBox") + local Bag = require("src.inventory.Bag") + + local pass = true + local function check(cond, msg) + if cond then U.log("PASS: " .. msg) else pass = false; U.log("FAIL: " .. msg) end + end + local function topIs(mt) return getmetatable(game.stack:top()) == mt end + + U.teleport(game, "VIRIDIAN_POKECENTER", 13, 4, "up") + game.save.pcItems = {} + -- >7 distinct depositable stacks (bug reproduces only then) + local seed = { + "POTION", "SUPER_POTION", "ANTIDOTE", "AWAKENING", "BURN_HEAL", + "ICE_HEAL", "PARLYZ_HEAL", "FULL_HEAL", "REPEL", "POKE_BALL", + } + for _, id in ipairs(seed) do Bag.add(game.save, id, 5) end + check(#seed > 7, "seeded more than 7 bag stacks") + + game.stack:push(PlayerPC.new(game)) + U.wait(4) + U.tap(game, "down"); U.wait(1) -- DEPOSIT ITEM + U.tap(game, "a"); U.wait(4) + check(topIs(ListMenu), "DEPOSIT ITEM list opened") + local list = game.stack:top() + check(list.rows == 4, "deposit list uses 4 rows like PrintListMenuEntries") + check(list.messageBox == true, "deposit list uses messageBox footer") + check(#list.items > 7, "list has more than 7 depositable items") + U.shot(game, DIR .. "/pc_deposit_0_list.png") + + -- pick first non-key stack (seed items are all quantity-prompted) + U.tap(game, "a"); U.wait(4) + check(topIs(QuantityBox), "quantity box on stack") + check(list.footer == "How many?", "How many? footer set") + U.shot(game, DIR .. "/pc_deposit_1_how_many.png") + + -- layout probe: list rows stay above the text box (ty=12 → y=96) + local drawn = {} + local Font = require("src.render.Font") + local savedDraw, savedBox = Font.draw, Font.drawBox + Font.draw = function(text, x, y) + drawn[#drawn + 1] = { kind = "text", text = tostring(text), x = x, y = y } + return savedDraw(text, x, y) + end + Font.drawBox = function(tx, ty, tw, th) + drawn[#drawn + 1] = { kind = "box", tx = tx, ty = ty, tw = tw, th = th } + return savedBox(tx, ty, tw, th) + end + game.stack:draw() + Font.draw, Font.drawBox = savedDraw, savedBox + + local howY, qtyBox, textBox, maxItemY + for _, d in ipairs(drawn) do + if d.kind == "text" and d.text == "How many?" then howY = d.y end + if d.kind == "box" and d.ty == 9 and d.tw == 5 then qtyBox = d end + if d.kind == "box" and d.tx == 0 and d.ty == 12 and d.tw == 20 then + textBox = d + end + if d.kind == "text" and tostring(d.text):match("^x%d") and d.y then + if not maxItemY or d.y > maxItemY then maxItemY = d.y end + end + end + check(howY == 112, "How many? sits on text-box first line (y=112)") + check(textBox ~= nil, "bottom PrintText box drawn") + check(qtyBox ~= nil, "quantity box at pokered row 9") + check(maxItemY and maxItemY <= 72, "last visible item row above qty/text (y<=72)") + if maxItemY and howY then + check(maxItemY + 8 <= howY, "item glyphs do not overlap How many?") + end + + U.tap(game, "a"); U.wait(6) -- confirm x01 + U.shot(game, DIR .. "/pc_deposit_2_stored.png") + check(type(list.footer) == "string" and list.footer:find("stored", 1, true), + "stored-via-PC footer after deposit") + + U.log(pass and "RESULT: ALL PASS" or "RESULT: SEE FAILURES ABOVE") + love.event.quit(pass and 0 or 1) +end diff --git a/tests/drivers/pc_menu_sides_test.lua b/tests/drivers/pc_menu_sides_test.lua new file mode 100644 index 00000000..2e2264d9 --- /dev/null +++ b/tests/drivers/pc_menu_sides_test.lua @@ -0,0 +1,37 @@ +-- Driver: Bill's PC vs player's PC top menus (#176). Both should open +-- on the left (pokered BillsPCMenu / PlayersPCMenu TextBoxBorder at 0,0). +-- SHOT_DIR=/tmp/pc_sides POKEPORT_DRIVER=tests/drivers/pc_menu_sides_test.lua love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local BoxMenu = require("src.ui.BoxMenu") + local PlayerPC = require("src.ui.PlayerPC") + + U.teleport(game, "VIRIDIAN_POKECENTER", 13, 4, "up") + game.save.flags = game.save.flags or {} + game.save.flags.EVENT_MET_BILL = true + + game.stack:push(BoxMenu.new(game)) + U.wait(5) + local bills = game.stack:top() + U.log(("bills tx=%d ty=%d tw=%d th=%d"):format( + bills.tx, bills.ty, bills.tw, bills.th)) + U.shot(game, DIR .. "/pc_0_bills.png") + U.wait(8) -- let async captureScreenshot flush before pop + game.stack:pop() + U.wait(2) + + game.stack:push(PlayerPC.new(game)) + U.wait(5) + local player = game.stack:top() + U.log(("player tx=%d ty=%d tw=%d th=%d"):format( + player.tx, player.ty, player.tw, player.th)) + U.shot(game, DIR .. "/pc_1_player.png") + U.wait(8) + + local sameSide = player.tx == bills.tx and player.ty == bills.ty + U.log(sameSide and "PASS: both menus share top-left origin" + or "FAIL: menus not on the same side") + U.log("DONE") + love.event.quit() +end diff --git a/tests/drivers/pc_release_two_test.lua b/tests/drivers/pc_release_two_test.lua new file mode 100644 index 00000000..f134120d --- /dev/null +++ b/tests/drivers/pc_release_two_test.lua @@ -0,0 +1,74 @@ +-- Driver: Bill's PC RELEASE two mons in one list session (#171). +-- SHOT_DIR=/tmp/pc_release POKEPORT_DRIVER=tests/drivers/pc_release_two_test.lua 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 Boxes = require("src.pokemon.Boxes") + local BoxMenu = require("src.ui.BoxMenu") + local ListMenu = require("src.ui.ListMenu") + local ChoiceBox = require("src.ui.ChoiceBox") + local TextBox = require("src.render.TextBox") + + local pass = true + local function check(cond, msg) + if cond then U.log("PASS: " .. msg) else pass = false; U.log("FAIL: " .. msg) end + end + local function topIs(mt) return getmetatable(game.stack:top()) == mt end + local function mash(btn, cond, n) + for _ = 1, (n or 240) do + if cond() then return true end + U.tap(game, btn) + U.wait(2) + end + return false + end + + U.teleport(game, "VIRIDIAN_POKECENTER", 13, 4, "up") + game.save.flags = game.save.flags or {} + game.save.flags.EVENT_MET_BILL = true + game.save.options = game.save.options or {} + game.save.options.textSpeed = 1 + + local box = Boxes.active(game.save) + for i = #box, 1, -1 do box[i] = nil end + box[1] = Pokemon.new(game.data, "RATTATA", 5) + box[2] = Pokemon.new(game.data, "PIDGEY", 6) + box[3] = Pokemon.new(game.data, "CATERPIE", 4) + check(#box == 3, "seeded 3 boxed mons") + + game.stack:push(BoxMenu.new(game)) + U.wait(4) + U.tap(game, "down"); U.wait(1) + U.tap(game, "down"); U.wait(1) + U.tap(game, "a") -- RELEASE + check(mash("a", function() return topIs(ListMenu) end, 20), "RELEASE list opened") + U.shot(game, DIR .. "/pc_release_0_list.png") + + local function releaseOne(label) + check(topIs(ListMenu), label .. ": still on release list") + local before = #Boxes.active(game.save) + U.tap(game, "a") -- pick current row + check(mash("a", function() return topIs(ChoiceBox) end), label .. ": confirm choice") + U.shot(game, DIR .. "/pc_release_" .. label .. "_confirm.png") + U.tap(game, "up"); U.wait(1) -- defaultNo -> YES + U.tap(game, "a") + check(mash("a", function() + return topIs(ListMenu) or (#game.stack.states > 0 and topIs(TextBox) == false) + end), label .. ": bye text advancing") + check(mash("a", function() return topIs(ListMenu) end), label .. ": back on list") + local after = #Boxes.active(game.save) + check(after == before - 1, label .. ": box count " .. before .. " -> " .. after) + U.shot(game, DIR .. "/pc_release_" .. label .. "_list.png") + end + + releaseOne("1st") + releaseOne("2nd") + + check(#Boxes.active(game.save) == 1, "one mon remains after two releases") + check(topIs(ListMenu), "still in RELEASE list without re-entering") + U.shot(game, DIR .. "/pc_release_done.png") + + U.log(pass and "RESULT: ALL PASS" or "RESULT: SEE FAILURES ABOVE") + love.event.quit(pass and 0 or 1) +end diff --git a/tests/drivers/rival_walkoff_test.lua b/tests/drivers/rival_walkoff_test.lua new file mode 100644 index 00000000..58467f40 --- /dev/null +++ b/tests/drivers/rival_walkoff_test.lua @@ -0,0 +1,203 @@ +-- Driver: #154 Route 22 rival exit direction + #168 Cerulean Bill text +-- and southbound walk-off. +-- +-- SHOT_DIR=/tmp/rival_walkoff POKEPORT_SPEED=8 \ +-- POKEPORT_DRIVER=tests/drivers/rival_walkoff_test.lua love . + +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/rival_walkoff" + os.execute("mkdir -p " .. DIR) + + local Pokemon = require("src.pokemon.Pokemon") + local Flags = require("src.script.Flags") + local Zoom = require("src.render.Zoom") + if game.save.options then game.save.options.zoom = 0 end + Zoom.reset() + + local function tank() + local mon = Pokemon.new(game.data, "MEWTWO", 100) + mon.moves = { + { id = "PSYCHIC_M", pp = 99 }, + { id = "THUNDERBOLT", pp = 99 }, + { id = "ICE_BEAM", pp = 99 }, + { id = "RECOVER", pp = 99 }, + } + return mon + end + + game.save.party = { tank() } + game.save.player = game.save.player or {} + game.save.player.name = "RED" + game.save.player.rival = "BLUE" + Flags.set(game.save, "EVENT_CHOSE_SQUIRTLE") + + local function idle(ow) + return game.stack:top() == ow and not ow.runner:isRunning() + and #ow.scriptMoves == 0 and not ow.transitioning + end + + local function mashUntil(cond, label, cap) + for _ = 1, cap or 800 do + if cond() then return true end + if game.stack:top() ~= game.overworld then U.tap(game, "a") end + U.wait(2) + end + U.log("TIMEOUT waiting for " .. label) + return false + end + + local function rivalNpc() + local ow = game.overworld + for _, npc in ipairs(ow.npcs or {}) do + if npc.def and npc.def.name and npc.def.name:find("RIVAL") then + return npc + end + end + end + + -- Fight until the battle state leaves the stack. Do NOT mash the + -- post-battle script text -- the caller screenshots that. + local function winBattle(label) + local ow = game.overworld + local sawBattle = false + for frames = 1, 5000 do + local top = game.stack:top() + if top and top.phase then + sawBattle = true + if top.phase == "menu" then + top.menuIndex = 1 + elseif top.phase == "moveSelect" then + top.moveIndex = 1 + end + U.tap(game, "a") + if frames > 2400 and top.onFinish then + U.log("force-finishing stalled", label) + top.onFinish("win") + if game.stack:top() == top then game.stack:pop() end + end + elseif sawBattle then + -- battle popped: either a TextBox or the overworld runner + return true + elseif top ~= ow then + U.tap(game, "a") -- pre-battle dialogue + end + U.wait(2) + end + U.log("TIMEOUT winBattle", label) + return false + end + + -- ---------------------------------------------------------- #154 + -- Route 22 optional post-lab rival: after the fight he must walk + -- toward Viridian (right / down), not back toward the League. + Flags.set(game.save, "EVENT_GOT_POKEDEX") + Flags.clear(game.save, "EVENT_BEAT_BROCK") + Flags.clear(game.save, "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE") + Flags.clear(game.save, "EVENT_BEAT_GIOVANNI") + Flags.clear(game.save, "EVENT_BEAT_ROUTE22_RIVAL_2ND_BATTLE") + + U.teleport(game, "ROUTE_22", 30, 5, "left") + local ow = game.overworld + U.shot(game, DIR .. "/r22_0_before.png") + U.hold(game, "left", 20) + + mashUntil(function() + return ow.runner:isRunning() or game.stack:top() ~= ow + end, "route22 ambush", 200) + U.shot(game, DIR .. "/r22_1_ambush.png") + mashUntil(function() return game.stack:top() ~= ow end, "route22 battle", 800) + winBattle("route22") + + local sawExit, exitFacing, exitX, exitY = false, nil, nil, nil + for _ = 1, 600 do + if game.stack:top() ~= ow then U.tap(game, "a") end + local r = rivalNpc() + if r and game.save.flags.EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE then + if (r.facing == "right" or r.facing == "down" or r.cellX > 28) + and not sawExit then + sawExit = true + exitFacing, exitX, exitY = r.facing, r.cellX, r.cellY + U.shot(game, DIR .. "/r22_2_exit.png") + U.log("r22 exit:", exitFacing, exitX, exitY) + elseif sawExit and (r.facing == "down" or r.cellY > 5) then + U.shot(game, DIR .. "/r22_3_exit_mid.png") + U.log("r22 exit mid:", r.facing, r.cellX, r.cellY) + break + end + end + if idle(ow) and game.save.flags.EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE then + break + end + U.wait(2) + end + mashUntil(function() return idle(ow) end, "route22 done", 800) + U.shot(game, DIR .. "/r22_4_done.png") + U.log("r22 beat:", tostring(game.save.flags.EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE), + "exitFacing:", tostring(exitFacing)) + + -- ---------------------------------------------------------- #168 + -- Nugget Bridge: Bill dialogue, then walk into Cerulean (south). + Flags.clear(game.save, "EVENT_BEAT_CERULEAN_RIVAL") + game.save.party = { tank() } + + U.teleport(game, "CERULEAN_CITY", 20, 7, "up") + ow = game.overworld + U.shot(game, DIR .. "/cer_0_before.png") + U.hold(game, "up", 20) + + mashUntil(function() + return ow.runner:isRunning() or game.stack:top() ~= ow + end, "cerulean ambush", 200) + U.shot(game, DIR .. "/cer_1_ambush.png") + mashUntil(function() return game.stack:top() ~= ow end, "cerulean battle", 800) + winBattle("cerulean") + + local function boxText(top) + if not top or type(top.pages) ~= "table" then return "" end + local parts = {} + for _, page in ipairs(top.pages) do + if type(page) == "table" then + for _, line in ipairs(page) do parts[#parts + 1] = tostring(line) end + end + end + return table.concat(parts, " ") + end + + local billShot, exitShot, lastBox, postBoxes = false, false, nil, 0 + for _ = 1, 2000 do + local top = game.stack:top() + if top ~= ow then + local t = boxText(top) + if game.save.flags.EVENT_BEAT_CERULEAN_RIVAL and t ~= "" and top ~= lastBox then + lastBox = top + postBoxes = postBoxes + 1 + U.log("post-battle box", postBoxes, t:sub(1, 80)) + -- Bill line is the second post-flag text (after DefeatedText) + if not billShot and (t:find("BILL") or t:find("Storage") + or t:find("invented") or t:find("MANIAC") or t:find("pages") + or t:find("guess what") or postBoxes >= 2) then + billShot = true + for _ = 1, 30 do U.wait(2); U.tap(game, "a") end -- advance into Bill body + U.wait(20) + U.shot(game, DIR .. "/cer_2_bill.png") + U.log("bill text:", boxText(game.stack:top()):sub(1, 160)) + end + end + U.tap(game, "a") + elseif game.save.flags.EVENT_BEAT_CERULEAN_RIVAL then + local r = rivalNpc() + if r and r.cellY and r.cellY >= 6 and r.facing == "down" and not exitShot then + exitShot = true + U.shot(game, DIR .. "/cer_3_exit.png") + U.log("cer exit:", r.facing, r.cellX, r.cellY) + end + if idle(ow) then break end + end + U.wait(2) + end + mashUntil(function() return idle(ow) end, "cerulean done", 800) + U.shot(game, DIR .. "/cer_4_done.png") + U.log("cer beat:", tostring(game.save.flags.EVENT_BEAT_CERULEAN_RIVAL), + "billShot:", tostring(billShot), "exitShot:", tostring(exitShot)) +end diff --git a/tests/drivers/route.lua b/tests/drivers/route.lua index 69dd7c67..40aadd12 100644 --- a/tests/drivers/route.lua +++ b/tests/drivers/route.lua @@ -731,6 +731,11 @@ local function switchTo(battle, slot) end local t = replacementMenu() if not t or t.index ~= slot then break end + press("a") -- select mon -> SWITCH / STATS / CANCEL + U.wait(8) + t = replacementMenu() + if not (t and t.submenu) then break end + -- SWITCH is first (SwitchStatsCancelText) press("a") U.wait(10) ok = true diff --git a/tests/drivers/ss_anne_kitchen_trash_test.lua b/tests/drivers/ss_anne_kitchen_trash_test.lua new file mode 100644 index 00000000..c9605d2c --- /dev/null +++ b/tests/drivers/ss_anne_kitchen_trash_test.lua @@ -0,0 +1,60 @@ +-- Driver: S.S. Anne kitchen PrintTrashText bins (#188). +-- Teleports beside each bin, interacts, screenshots the dialogue. + +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local TextBox = require("src.render.TextBox") + + local pass = true + local function check(cond, msg) + if cond then U.log("PASS: " .. msg) else pass = false; U.log("FAIL: " .. msg) end + end + local function topIs(mt) return getmetatable(game.stack:top()) == mt end + + local bins = game.data.field.hiddenExtras + and game.data.field.hiddenExtras.printTrash + and game.data.field.hiddenExtras.printTrash.SS_ANNE_KITCHEN + check(bins and #bins == 2, "SS_ANNE_KITCHEN has 2 PrintTrashText bins") + + for i, bin in ipairs(bins or {}) do + -- stand west of the bin facing right (cooks occupy the column) + U.teleport(game, "SS_ANNE_KITCHEN", bin.x - 1, bin.y, "right") + local ow = game.overworld + local fx, fy = ow.player:facingCell() + U.log(("bin %d: at (%d,%d) facing %s -> (%d,%d)"):format( + i, ow.player.cellX, ow.player.cellY, ow.player.facing, fx, fy)) + check(fx == bin.x and fy == bin.y, ("facing kitchen trash at (%d,%d)"):format(bin.x, bin.y)) + + U.tap(game, "a") + local opened = false + for _ = 1, 60 do + if topIs(TextBox) then opened = true; break end + U.wait(2) + end + check(opened, ("bin %d opened a TextBox"):format(i)) + if opened then + local box = game.stack:top() + for _ = 1, 180 do + if not topIs(TextBox) then break end + if box.waiting or box.done then break end + U.wait(1) + end + U.wait(4) + local page = box.pages and box.pages[1] + local body = type(page) == "table" and table.concat(page, "\n") + or (type(page) == "string" and page or "") + U.log("dialogue:", body:gsub("\n", "\\n"):sub(1, 80)) + check(body:find("only trash here", 1, true) ~= nil, + ("bin %d shows trash dialogue"):format(i)) + U.shot(game, DIR .. ("/ss_anne_kitchen_trash_%d.png"):format(i)) + for _ = 1, 40 do + if not topIs(TextBox) then break end + U.tap(game, "a") + U.wait(2) + end + end + end + + U.log(pass and "RESULT: ALL PASS" or "RESULT: SEE FAILURES ABOVE") +end diff --git a/tests/drivers/ss_anne_rooms_order_test.lua b/tests/drivers/ss_anne_rooms_order_test.lua new file mode 100644 index 00000000..a85690c0 --- /dev/null +++ b/tests/drivers/ss_anne_rooms_order_test.lua @@ -0,0 +1,49 @@ +-- Driver: #189 S.S. Anne 1F door→room order. Enter each hallway cabin +-- door left→right; screenshot room contents (SHOT_DIR). +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots-189" + + local doors = {} + for i, w in ipairs(game.data.maps.SS_ANNE_1F.warps) do + if w.destMap == "SS_ANNE_1F_ROOMS" then + doors[#doors + 1] = { index = i, x = w.x, y = w.y, destWarp = w.destWarp } + end + end + table.sort(doors, function(a, b) return a.x < b.x end) + + U.teleport(game, "SS_ANNE_1F_ROOMS", 12, 8, "down") + U.shot(game, DIR .. "/anne189_rooms_overview.png") + + U.teleport(game, "SS_ANNE_1F", 20, 10, "up") + U.shot(game, DIR .. "/anne189_hall_overview.png") + + for n, d in ipairs(doors) do + U.teleport(game, "SS_ANNE_1F", d.x, d.y + 1, "up") + U.shot(game, string.format("%s/anne189_door%d_hall_x%d.png", DIR, n, d.x)) + U.hold(game, "up", 24) + local ow = game.overworld + for _ = 1, 120 do + U.wait(1) + if ow.map.id == "SS_ANNE_1F_ROOMS" and not ow.transitioning + and #(ow.scriptMoves or {}) == 0 and not ow.player.moving then + break + end + end + U.wait(10) + local names = {} + local col = math.floor(ow.player.cellX / 10) + local row = math.floor(ow.player.cellY / 10) + for _, npc in ipairs(ow.npcs or {}) do + if math.floor(npc.cellX / 10) == col and math.floor(npc.cellY / 10) == row then + names[#names + 1] = npc.def.name or "?" + end + end + table.sort(names) + U.log(string.format( + "door%d hall(%d,%d)#%d destWarp=%d -> %s (%d,%d) cell=%d,%d npcs: %s", + n, d.x, d.y, d.index, d.destWarp, ow.map.id, + ow.player.cellX, ow.player.cellY, col, row, table.concat(names, ","))) + U.shot(game, string.format("%s/anne189_door%d_room.png", DIR, n)) + end +end diff --git a/tests/drivers/status_poison_test.lua b/tests/drivers/status_poison_test.lua new file mode 100644 index 00000000..f2a57680 --- /dev/null +++ b/tests/drivers/status_poison_test.lua @@ -0,0 +1,84 @@ +-- Driver for #169: poison status icon must appear AFTER move anim + +-- "was poisoned!" text, not during the "used POISONPOWDER!" announce. +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 mon = Pokemon.new(game.data, "BULBASAUR", 12) + mon.moves[1] = { id = "POISONPOWDER", pp = 20 } + mon.moves[2] = { id = "TACKLE", pp = 20 } + table.insert(game.save.party, 1, mon) + U.teleport(game, "ROUTE_1", 5, 5, "down") + local ow = game.overworld + + local battle = BattleState.newWild(game, "PIDGEY", 8) + battle.onFinish = function() end + -- force hit + make the foe never act (skip second half of the turn) + battle.rng = function(a, b) return a or 0 end + battle.enemyAction = function() return nil end + ow:pushBattle(battle) + + local function waitFor(cond, max) + for _ = 1, max or 600 do + if cond() then return true end + U.wait(1) + end + return false + end + local function mashUntil(cond, max) + for _ = 1, max or 80 do + if cond() then return true end + U.tap(game, "a") + U.wait(4) + end + return false + end + + mashUntil(function() return battle.phase == "menu" end) + U.shot(game, DIR .. "/poison_0_menu.png") + + U.tap(game, "a"); U.wait(8) -- FIGHT + U.tap(game, "a"); U.wait(2) -- POISONPOWDER + + -- announce: "BULBASAUR used POISONPOWDER!" — no PSN on HUD yet + waitFor(function() + return battle.current and battle.current.text + and battle.current.text:find("POISONPOWDER", 1, true) + end, 120) + U.shot(game, DIR .. "/poison_1_announce.png") + U.log(("announce shownStatus=%s mon.status=%s"):format( + tostring(battle.enemy.shownStatus), tostring(battle.enemy.mon.status))) + + -- mash through announce into the move anim / poisoned text + local sawAnim, sawText = false, false + for _ = 1, 200 do + if battle.animPlaying or battle.animName == "POISONPOWDER" then + if not sawAnim then + sawAnim = true + U.shot(game, DIR .. "/poison_2_anim.png") + U.log(("anim shownStatus=%s"):format(tostring(battle.enemy.shownStatus))) + end + end + if battle.current and battle.current.text + and battle.current.text:find("poisoned", 1, true) then + if not sawText then + sawText = true + U.shot(game, DIR .. "/poison_3_text.png") + U.log(("text shownStatus=%s"):format(tostring(battle.enemy.shownStatus))) + end + end + if battle.enemy.shownStatus == "PSN" and sawText then + U.shot(game, DIR .. "/poison_4_icon.png") + U.log("HUD PSN revealed after poisoned text") + break + end + U.tap(game, "a") + U.wait(2) + end + + waitFor(function() return battle.phase == "menu" end, 200) + U.shot(game, DIR .. "/poison_5_menu.png") + U.log(("final shownStatus=%s"):format(tostring(battle.enemy.shownStatus))) +end diff --git a/tests/drivers/tm_battle_test.lua b/tests/drivers/tm_battle_test.lua new file mode 100644 index 00000000..33b29c66 --- /dev/null +++ b/tests/drivers/tm_battle_test.lua @@ -0,0 +1,60 @@ +-- Driver: try a TM from the battle bag; Gen 1 refuses with Oak's +-- "This isn't the time to use that!" (ItemUseTMHM -> ItemUseNotTime). +-- +-- SHOT_DIR=/tmp/tm_battle_shots POKEPORT_DRIVER=tests/drivers/tm_battle_test.lua 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 Bag = require("src.inventory.Bag") + local TextBox = require("src.render.TextBox") + local ListMenu = require("src.ui.ListMenu") + + table.insert(game.save.party, Pokemon.new(game.data, "CHARMANDER", 12)) + Bag.add(game.save, "TM_TOXIC", 1) + U.teleport(game, "ROUTE_1", 5, 5, "down") + local ow = game.overworld + + local BattleState = require("src.battle.BattleState") + local battle = BattleState.newWild(game, "PIDGEY", 8) + battle.onFinish = function() end + ow:pushBattle(battle) + + local function mashUntil(cond, max) + for _ = 1, max or 80 do + if cond() then return true end + U.tap(game, "a") + U.wait(4) + end + return false + end + + local function pageText(box) + if not box or getmetatable(box) ~= TextBox then return "" end + return table.concat(box.pages[box.pageIndex] or {}, "\n") + end + + U.log("to menu:", mashUntil(function() return battle.phase == "menu" end)) + U.shot(game, DIR .. "/tm_battle_0_menu.png") + + -- FIGHT/PKMN / ITEM/RUN: down to ITEM, then A + U.tap(game, "down"); U.wait(4) + U.tap(game, "a"); U.wait(12) + U.shot(game, DIR .. "/tm_battle_1_bag.png") + U.log("bag open:", getmetatable(game.stack:top()) == ListMenu) + + U.tap(game, "a") + mashUntil(function() + local top = game.stack:top() + return getmetatable(top) == TextBox and top.waiting + and pageText(top):find("isn't the", 1, true) + end, 40) + U.shot(game, DIR .. "/tm_battle_2_reject.png") + + local top = game.stack:top() + local text = pageText(top) + U.log("reject TextBox:", getmetatable(top) == TextBox) + U.log("oak text:", text:find("isn't the", 1, true) and "ok" or text) + U.log("tm still in bag:", (game.save.inventory.TM_TOXIC or 0) >= 1) + U.log("not booted:", not text:find("Booted", 1, true)) +end diff --git a/tests/drivers/trade_anim_test.lua b/tests/drivers/trade_anim_test.lua new file mode 100644 index 00000000..3363a9f1 --- /dev/null +++ b/tests/drivers/trade_anim_test.lua @@ -0,0 +1,155 @@ +-- Driver: NPC in-game trade cable animation (InternalClockTradeAnim). +-- Vermilion Trade House: SPEAROW -> FARFETCH'D (DUX). +-- +-- SHOT_DIR=/tmp/trade_shots POKEPORT_DRIVER=tests/drivers/trade_anim_test.lua love . + +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/trade_shots" + local Pokemon = require("src.pokemon.Pokemon") + local TradeAnim = require("src.ui.TradeAnim") + local TextBox = require("src.render.TextBox") + local PartyMenu = require("src.ui.PartyMenu") + local ChoiceBox = require("src.ui.ChoiceBox") + + local function topIs(cls) + return getmetatable(game.stack:top()) == cls + end + + game.save.party = { Pokemon.new(game.data, "SPEAROW", 10) } + U.teleport(game, "VERMILION_TRADE_HOUSE", 3, 6, "up") + U.wait(5) + U.shot(game, DIR .. "/trade_00_house.png") + + U.tap(game, "a") + U.wait(10) + for _ = 1, 200 do + if topIs(ChoiceBox) then break end + U.tap(game, "a") + U.wait(2) + end + U.shot(game, DIR .. "/trade_01_offer.png") + U.tap(game, "a") -- YES + U.wait(6) + + for _ = 1, 60 do + if topIs(PartyMenu) then break end + U.wait(1) + end + U.log("party menu:", topIs(PartyMenu)) + U.shot(game, DIR .. "/trade_02_party.png") + U.tap(game, "a") + U.wait(6) + + for _ = 1, 200 do + if topIs(TradeAnim) then break end + U.tap(game, "a") + U.wait(2) + end + local anim = game.stack:top() + if getmetatable(anim) ~= TradeAnim then + U.log("FAIL: TradeAnim never appeared") + return + end + U.log("TradeAnim phase:", anim.phase) + + local function ffUntil(phase, cap) + for _ = 1, cap or 3000 do + if anim.phase == phase or anim.phase == "done" then break end + if anim.waitingText or topIs(TextBox) then + U.wait(1) + else + anim:update(1 / 60) + end + end + U.wait(1) + end + + -- hold mid show_player (scx settled, mon visible) + ffUntil("show_player", 1) + while anim.phase == "show_player" and (anim.sub ~= "hold" or anim.scx > 0) do + anim:update(1 / 60) + end + for _ = 1, 10 do anim:update(1 / 60) end + U.wait(1) + U.shot(game, DIR .. "/trade_03_show_player.png") + U.log("show_player sub=", anim.sub, "scx=", anim.scx) + + ffUntil("open_cable", 500) + -- mid scroll-in + while anim.phase == "open_cable" and anim.scx > 40 do + anim:update(1 / 60) + end + U.wait(1) + U.shot(game, DIR .. "/trade_04_open_cable.png") + U.log("open_cable scx=", anim.scx) + + ffUntil("ball_enter", 200) + while anim.phase == "ball_enter" and anim.ballX < 0x80 do + anim:update(1 / 60) + end + U.wait(1) + U.shot(game, DIR .. "/trade_05_ball_enter.png") + U.log("ball at", anim.ballX, anim.ballY) + + ffUntil("transfer_lr", 400) + for _ = 1, 24 do anim:update(1 / 60) end + U.wait(1) + U.shot(game, DIR .. "/trade_06_transfer_lr.png") + + ffUntil("went_to", 800) + for _ = 1, 600 do + if topIs(TextBox) then + local tb = game.stack:top() + if tb.done then break end + -- finish typewriter so both lines are visible + if tb.codes then tb.charIndex = #tb.codes end + if tb.pages and tb.pageIndex and tb.lineIndex then + local page = tb.pages[tb.pageIndex] + if page and tb.lineIndex < #page then + tb.lineIndex = #page + tb.shown = {} + for _, line in ipairs(page) do + local codes = require("src.render.Font").encode(line) + tb.shown[#tb.shown + 1] = codes + end + tb.charIndex = #(tb.shown[#tb.shown] or {}) + tb.done = true + end + end + break + end + U.wait(1) + end + U.wait(2) + U.shot(game, DIR .. "/trade_07_went_to.png") + U.log("went-to:", topIs(TextBox)) + + ffUntil("transfer_rl", 2500) + for _ = 1, 16 do anim:update(1 / 60) end + U.wait(1) + U.shot(game, DIR .. "/trade_08_transfer_rl.png") + + ffUntil("show_enemy", 800) + while anim.phase == "show_enemy" and not anim.monVisible do + anim:update(1 / 60) + end + for _ = 1, 30 do anim:update(1 / 60) end + U.wait(1) + U.shot(game, DIR .. "/trade_09_show_enemy.png") + + for _ = 1, 5000 do + if game.stack:top() == game.overworld then break end + local top = game.stack:top() + if getmetatable(top) == TradeAnim and not top.waitingText then + top:update(1 / 60) + end + U.tap(game, "a") + U.wait(1) + end + U.shot(game, DIR .. "/trade_10_done.png") + local mon = game.save.party[1] + U.log("party:", mon and mon.species, "nick:", mon and mon.nickname, + "ot:", mon and mon.ot) + U.log("done") +end diff --git a/tests/drivers/trainer_card_test.lua b/tests/drivers/trainer_card_test.lua new file mode 100644 index 00000000..7c3dcc62 --- /dev/null +++ b/tests/drivers/trainer_card_test.lua @@ -0,0 +1,34 @@ +-- Driver: trainer card with empty badge slots (gym leader faces) and a +-- partial set (face/badge mix), matching DrawBadges in pokered. +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + U.teleport(game, "PALLET_TOWN", 10, 8, "down") + game.save.player.name = "RED" + game.save.money = 3000 + game.save.playTime = 3661 + game.save.inventory = game.save.inventory or {} + + local TrainerCard = require("src.ui.TrainerCard") + + -- no badges: every slot should show the gym leader face + for _, id in ipairs({ + "BOULDERBADGE", "CASCADEBADGE", "THUNDERBADGE", "RAINBOWBADGE", + "SOULBADGE", "MARSHBADGE", "VOLCANOBADGE", "EARTHBADGE", + }) do + game.save.inventory[id] = nil + end + game.stack:push(TrainerCard.new(game)) + U.wait(5) + U.shot(game, DIR .. "/trainer_card_0_faces.png") + U.tap(game, "b"); U.wait(3) + + -- boulder + cascade owned: first two slots swap to badges + game.save.inventory.BOULDERBADGE = true + game.save.inventory.CASCADEBADGE = true + game.stack:push(TrainerCard.new(game)) + U.wait(5) + U.shot(game, DIR .. "/trainer_card_1_partial.png") + U.tap(game, "b"); U.wait(3) + U.log("TRAINER_CARD_DRIVER: done") +end diff --git a/tests/drivers/trainer_sight_walls_test.lua b/tests/drivers/trainer_sight_walls_test.lua new file mode 100644 index 00000000..3b27fedb --- /dev/null +++ b/tests/drivers/trainer_sight_walls_test.lua @@ -0,0 +1,70 @@ +-- Driver: trainer sight must not engage off the GB screen (#153/#183). +-- Oracle: engine/overworld/trainer_sight.asm TrainerEngage (IMAGEINDEX=$ff) +-- + movement.asm CheckSpriteAvailability (10x9 window). 8-bit pixel +-- distance wraps at ~12/28 tiles; without the on-screen gate, Viridian +-- Forest / Victory Road trainers aggro through trees and walls. +-- +-- Scene A: VIRIDIAN_FOREST (18,33), west of tree wall vs Bug Catcher +-- (30,33) LEFT range 4 — dx=12 pixel-wrap must NOT engage. +-- Scene B: same trainer at (26,33) on-screen — MUST engage + walk-up. +-- Scene C: VICTORY_ROAD_2F (0,9) vs Hiker (12,9) — dx=12 must NOT engage. + +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local function trainerByIndex(ow, index) + for _, npc in ipairs(ow.npcs) do + if npc.def.index == index then return npc end + end + end + + -- --- A: Viridian Forest wrap through trees (dx=12, walkable west of wall) --- + U.teleport(game, "VIRIDIAN_FOREST", 18, 33, "right") + local ow = game.overworld + local t = trainerByIndex(ow, 2) + U.wait(10) + U.log("VF far: player", ow.player.cellX, ow.player.cellY, + "trainer", t and t.cellX, t and t.cellY, + "engaging", tostring(ow.engaging)) + assert(t and t.cellX == 30 and t.cellY == 33, "VF Bug Catcher at (30,33)") + assert(not ow.engaging, "VF: off-screen trainer must not engage through trees") + assert(t.cellX == 30 and t.cellY == 33, "VF: trainer must not walk through trees") + U.shot(game, DIR .. "/153_vf_far_no_aggro.png") + + -- --- B: real on-screen sight line (same corridor, no tree wall) --- + U.teleport(game, "VIRIDIAN_FOREST", 26, 33, "right") + ow = game.overworld + t = trainerByIndex(ow, 2) + local guard = 0 + while not ow.engaging and guard < 30 do + guard = guard + 1 + U.wait(1) + end + U.log("VF near: engaging", tostring(ow.engaging), + "emote", tostring(ow.emote and ow.emote.frames), + "trainer", t and t.cellX, t and t.cellY) + assert(ow.engaging, "VF: distance-4 on-screen must engage") + U.shot(game, DIR .. "/153_vf_near_engage.png") + -- EmotionBubble 60f + (dist-1)*16 walk frames + U.wait(60 + 3 * 16 + 10) + U.log("VF walkup: trainer", t.cellX, t.cellY, "player", ow.player.cellX, ow.player.cellY) + assert(t.cellY == 33 and t.cellX >= 26 and t.cellX <= 30, + "VF: walk-up stays on the open corridor") + assert(t.cellX == 27, "VF: trainer stops adjacent after distance-1 walk-up") + U.shot(game, DIR .. "/153_vf_near_walkup.png") + + -- --- C: Victory Road 2F wrap (dx=12) --- + U.teleport(game, "VICTORY_ROAD_2F", 0, 9, "right") + ow = game.overworld + t = trainerByIndex(ow, 1) + U.wait(10) + U.log("VR2 far: player", ow.player.cellX, ow.player.cellY, + "trainer", t and t.cellX, t and t.cellY, + "engaging", tostring(ow.engaging)) + assert(t and t.cellX == 12 and t.cellY == 9, "VR2 Hiker at (12,9)") + assert(not ow.engaging, "VR2: off-screen trainer must not engage") + assert(t.cellX == 12 and t.cellY == 9, "VR2: trainer must not walk through walls") + U.shot(game, DIR .. "/183_vr2_far_no_aggro.png") + + U.log("trainer_sight_walls_test OK") +end diff --git a/tests/drivers/util.lua b/tests/drivers/util.lua index 4f843460..9ab46208 100644 --- a/tests/drivers/util.lua +++ b/tests/drivers/util.lua @@ -33,7 +33,15 @@ end function U.shot(game, path) game.capturePath = path - U.wait(2) -- let the capture flush + -- love.draw consumes capturePath once per rendered frame, but fast runs + -- (POKEPORT_SPEED) step the driver many times per render; spin until the + -- capture lands so later actions can't outrun it + for _ = 1, 120 do + if not game.capturePath then break end + frame = frame + 1 + coroutine.yield() + end + U.wait(1) end -- skip the intro movie + title into a fresh overworld game diff --git a/tests/engine/launcher_mods_tests.lua b/tests/engine/launcher_mods_tests.lua new file mode 100644 index 00000000..0f1babbf --- /dev/null +++ b/tests/engine/launcher_mods_tests.lua @@ -0,0 +1,193 @@ +-- Love-free coverage for the pure halves of src/mods/LauncherMods.lua: the +-- status derivation (deriveList) over a synthetic manifest list + options +-- table, and the archive-root location logic (locateRoot). The discovery and +-- installZip paths need love.filesystem and are exercised by the launcher; the +-- decision logic under them lives here so a bad range/conflict/root call fails +-- one line instead of the app. +-- luajit tests/engine/launcher_mods_tests.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +local Manifest = require("src.mods.Manifest") +local Version = require("src.core.Version") +local LauncherMods = require("src.mods.LauncherMods") + +-- validated manifests are the exact shape deriveList/resolveToggle read +local function mf(raw) + return Manifest.validate(raw) +end + +-- index a deriveList result by mod id for assertions +local function byId(list) + local m = {} + for _, row in ipairs(list) do m[row.id] = row end + return m +end + +-- ------- badge derivation: category, then profile, then MOD (uppercased) + +do + local list = LauncherMods.deriveList({ + mf({ id = "cat", name = "Cat Mod", version = "1.0.0", entry = "m.lua", + category = "gameplay" }), + mf({ id = "prof", name = "Prof Mod", version = "1.0.0", entry = "m.lua", + profile = "overhaul" }), + mf({ id = "plain", name = "Plain", version = "1.0.0", entry = "m.lua" }), + }, { mods = {} }) + local m = byId(list) + eq(m.cat.badge, "GAMEPLAY", "badge uses the manifest category, uppercased") + eq(m.prof.badge, "OVERHAUL", "badge falls back to the profile when no category") + -- no category field, so the fallback reaches the profile default ("content") + eq(m.plain.badge, "CONTENT", "bare manifest badge falls back to the profile") + eq(#list, 3, "every discovered manifest yields one row") + check(m.cat.id < m.plain.id and m.plain.id < m.prof.id, + "rows come back sorted by id (cat < plain < prof)") +end + +-- ------- enabled defaults to true; a false entry disables + +do + local manifests = { + mf({ id = "aaa", name = "A", version = "1.0.0", entry = "m.lua" }), + mf({ id = "bbb", name = "B", version = "1.0.0", entry = "m.lua" }), + } + local m = byId(LauncherMods.deriveList(manifests, { mods = { bbb = false } })) + check(m.aaa.enabled, "a mod with no options entry defaults to enabled") + check(not m.bbb.enabled, "an explicit false disables the mod") + eq(m.aaa.status, "ok", "a healthy enabled mod is ok") + eq(m.aaa.statusDetail, "Ready", "ok detail reads Ready") +end + +-- ------- conflict: only when this mod is enabled and the other is too + +do + local manifests = { + mf({ id = "alpha", name = "Alpha", version = "1.0.0", entry = "m.lua", + conflicts = { "beta" } }), + mf({ id = "beta", name = "Beta", version = "1.0.0", entry = "m.lua" }), + } + -- both enabled: the declaring side (and, symmetrically, the other) conflict + local both = byId(LauncherMods.deriveList(manifests, { mods = {} })) + eq(both.alpha.status, "conflict", "enabled mod conflicting with an enabled mod") + check(both.alpha.statusDetail:find("Beta", 1, true) ~= nil, + "conflict detail names the other mod") + eq(both.beta.status, "conflict", + "resolveToggle conflict is bidirectional: the target is flagged too") + + -- disable beta: alpha no longer conflicts (nothing enabled to conflict with) + local off = byId(LauncherMods.deriveList(manifests, { mods = { beta = false } })) + eq(off.alpha.status, "ok", "no conflict once the other side is disabled") + eq(off.beta.status, "ok", "a disabled mod is never a conflict") +end + +-- ------- warn: unsatisfied game_version range against Version.engine + +do + -- a range the -dev engine cannot satisfy (needs a released >=1.0.0) + local manifests = { + mf({ id = "future", name = "Future", version = "1.0.0", entry = "m.lua", + game_version = ">=1.0.0" }), + } + local m = byId(LauncherMods.deriveList(manifests, { mods = {} })) + eq(m.future.status, "warn", "engine outside the game_version range warns") + check(m.future.statusDetail:find(">=1.0.0", 1, true) ~= nil, + "version warn detail quotes the required range") + check(m.future.statusDetail:find(Version.engine, 1, true) ~= nil, + "version warn detail quotes the engine version") +end + +-- ------- warn: hard dependency missing, disabled, or wrong version + +do + local base = { id = "base", name = "Base", version = "1.0.0", entry = "m.lua" } + local needsMissing = { id = "needy", name = "Needy", version = "1.0.0", + entry = "m.lua", dependencies = { "ghost" } } + local m = byId(LauncherMods.deriveList({ mf(needsMissing) }, { mods = {} })) + eq(m.needy.status, "warn", "a missing hard dependency warns") + check(m.needy.statusDetail:find("not installed", 1, true) ~= nil, + "missing-dep detail says not installed") + + -- present but disabled + local m2 = byId(LauncherMods.deriveList( + { mf(base), mf({ id = "needy", name = "Needy", version = "1.0.0", + entry = "m.lua", dependencies = { "base" } }) }, + { mods = { base = false } })) + eq(m2.needy.status, "warn", "a disabled hard dependency warns") + check(m2.needy.statusDetail:find("disabled", 1, true) ~= nil, + "disabled-dep detail says disabled") + + -- present, enabled, but the version is out of range + local m3 = byId(LauncherMods.deriveList( + { mf(base), mf({ id = "needy", name = "Needy", version = "1.0.0", + entry = "m.lua", dependencies = { "base@>=2.0.0" } }) }, + { mods = {} })) + eq(m3.needy.status, "warn", "a dependency below the required range warns") + eq(m3.base.status, "ok", "the satisfied dependency itself stays ok") + + -- the same dep satisfied: needy is ok + local m4 = byId(LauncherMods.deriveList( + { mf(base), mf({ id = "needy", name = "Needy", version = "1.0.0", + entry = "m.lua", dependencies = { "base@>=1.0.0" } }) }, + { mods = {} })) + eq(m4.needy.status, "ok", "a satisfied dependency clears the warn") +end + +-- ------- conflict outranks warn when a mod trips both + +do + local manifests = { + mf({ id = "alpha", name = "Alpha", version = "1.0.0", entry = "m.lua", + conflicts = { "beta" }, game_version = ">=1.0.0" }), + mf({ id = "beta", name = "Beta", version = "1.0.0", entry = "m.lua" }), + } + local m = byId(LauncherMods.deriveList(manifests, { mods = {} })) + eq(m.alpha.status, "conflict", + "conflict is reported ahead of a version warn on the same mod") +end + +-- ------- locateRoot: manifest at the archive root + +do + local root, err = LauncherMods.locateRoot({ "manifest.json", "main.lua" }) + eq(root, "", "a root-level manifest.json resolves to the empty prefix") + eq(err, nil, "no error for a root-level manifest") +end + +-- ------- locateRoot: manifest inside a single top-level folder + +do + local root = LauncherMods.locateRoot({ + "mymod/manifest.json", "mymod/main.lua", "mymod/assets/x.png" }) + eq(root, "mymod", "a single wrapping folder resolves to that folder name") +end + +-- ------- locateRoot: no manifest anywhere + +do + local root, err = LauncherMods.locateRoot({ "readme.txt", "stuff/x.lua" }) + eq(root, nil, "an archive with no manifest.json resolves to nil") + check(err:find("no manifest.json", 1, true) ~= nil, + "the no-manifest reason is user-presentable") +end + +-- ------- locateRoot: multiple top-level folders is ambiguous + +do + local root, err = LauncherMods.locateRoot({ + "one/manifest.json", "two/manifest.json" }) + eq(root, nil, "two candidate mod folders resolves to nil") + check(err:find("single mod folder", 1, true) ~= nil, + "the ambiguous reason asks for a single mod folder") +end + +-- ------- locateRoot: a lone folder without a manifest is not a root + +do + local root, err = LauncherMods.locateRoot({ "assets/x.png" }) + eq(root, nil, "a single folder with no manifest is not a mod root") + check(err ~= nil, "the no-root case carries a reason") +end + +T.finish("launcher_mods") diff --git a/tests/engine/pc_menu_sides.lua b/tests/engine/pc_menu_sides.lua new file mode 100644 index 00000000..93b8bc3d --- /dev/null +++ b/tests/engine/pc_menu_sides.lua @@ -0,0 +1,33 @@ +-- Bill's PC vs player's PC top-menu origin/size (#176). +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local T = require("tests.harness") +local Data = require("src.core.Data") +if not (Data.pokemon and next(Data.pokemon)) then Data:load() end + +local SaveData = require("src.core.SaveData") +local BoxMenu = require("src.ui.BoxMenu") +local PlayerPC = require("src.ui.PlayerPC") + +local game = { + data = Data, + save = SaveData.newGame(), + stack = { push = function() end }, +} + +local bills = BoxMenu.new(game) +local player = PlayerPC.new(game) + +T.eq(bills.tx, 0, "Bill's PC TextBoxBorder at x=0") +T.eq(player.tx, 0, "Player's PC TextBoxBorder at x=0") +T.eq(player.ty, 0, "Player's PC at y=0") +T.eq(player.tw, 16, "Player's PC width (players_pc.asm c=$e +2)") +T.eq(player.th, 10, "Player's PC height (players_pc.asm b=$8 +2)") +T.check(player.tx == bills.tx and player.ty == bills.ty, + "both PC menus share the same top-left origin") +local labelTiles = 2 + #"WITHDRAW ITEM" +T.check(player.tx + labelTiles <= player.tx + player.tw - 1, + "WITHDRAW ITEM fits inside the Player's PC border") + +T.finish("pc_menu_sides") diff --git a/tests/engine/pc_release.lua b/tests/engine/pc_release.lua new file mode 100644 index 00000000..bfa9d39b --- /dev/null +++ b/tests/engine/pc_release.lua @@ -0,0 +1,92 @@ +-- Headless regression: two Bill's PC releases in one list session (#171). +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local T = require("tests.harness") +local Data = require("src.core.Data") +if not (Data.pokemon and Data.pokemon.RATTATA) then Data:load() end +require("src.render.Font").load(Data) + +local Pokemon = require("src.pokemon.Pokemon") +local Boxes = require("src.pokemon.Boxes") +local BoxMenu = require("src.ui.BoxMenu") +local ListMenu = require("src.ui.ListMenu") +local ChoiceBox = require("src.ui.ChoiceBox") +local SaveData = require("src.core.SaveData") +local Sound = require("src.core.Sound") + +local realCry, realPlay = Sound.playCry, Sound.play +Sound.playCry = function() end +Sound.play = function() end + +local stack = { states = {} } +function stack:push(s) self.states[#self.states + 1] = s end +function stack:pop() + local t = self.states[#self.states] + self.states[#self.states] = nil + return t +end +function stack:top() return self.states[#self.states] end +function stack:update(dt) + local t = self:top() + if t and t.update then t:update(dt) end +end + +local pressed = {} +local game = { + data = Data, + save = SaveData.newGame(), + stack = stack, + input = { + wasPressed = function(_, key) return pressed[key] or false end, + isDown = function() return false end, + }, +} +game.save.options = game.save.options or {} +game.save.options.textSpeed = 1 + +local box = Boxes.active(game.save) +box[1] = Pokemon.new(Data, "RATTATA", 5) +box[2] = Pokemon.new(Data, "PIDGEY", 6) +box[3] = Pokemon.new(Data, "CATERPIE", 4) + +local function press(btn) + pressed = { [btn] = true } + stack:update(1 / 60) + pressed = {} +end + +local function topMt() return getmetatable(stack:top()) end +local function mash(btn, cond, n) + for _ = 1, (n or 400) do + if cond() then return true end + press(btn) + end + return false +end + +stack:push(BoxMenu.new(game)) +press("down"); press("down"); press("a") +T.check(topMt() == ListMenu, "RELEASE opens the box list") +T.eq(#box, 3, "box still has 3 before releases") + +local function releaseCurrent() + local before = #box + press("a") + T.check(mash("a", function() return topMt() == ChoiceBox end), + "confirm choice opens") + press("up") -- defaultNo -> YES + press("a") + T.check(mash("a", function() return topMt() == ListMenu end), + "returns to RELEASE list") + T.eq(#box, before - 1, "one mon removed from the box") +end + +releaseCurrent() +releaseCurrent() +T.eq(#box, 1, "two releases leave one mon") +T.check(topMt() == ListMenu, "still on RELEASE list after the second") +T.eq(box[1].species, "CATERPIE", "remaining mon is the third seeded one") + +Sound.playCry, Sound.play = realCry, realPlay +T.finish("pc_release") diff --git a/tests/engine/save_file_io_tests.lua b/tests/engine/save_file_io_tests.lua new file mode 100644 index 00000000..1cb765ae --- /dev/null +++ b/tests/engine/save_file_io_tests.lua @@ -0,0 +1,233 @@ +-- Launcher save Import/Export glue (src/import/SaveFileIO.lua): the end-to-end +-- importToSlot -> listSlots roundtrip and the exportActiveSlot output-byte +-- sanity check, driven love-free through the same in-memory filesystem stub +-- tests/engine/save_slots.lua uses. A synthetic 32KB SRAM image is built via +-- GenSave.encode (no real save checked in); a fixture-gated case exercises the +-- real .sav when POKEPORT_SAV_FIXTURE points at one. +-- luajit tests/engine/save_file_io_tests.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") + +local GenSave = require("src.save_convert.GenSave") +local SaveConvert = require("src.save_convert.SaveConvert") +local SaveData = require("src.core.SaveData") +local GameVersion = require("src.core.GameVersion") +local SaveFileIO = require("src.import.SaveFileIO") + +local realFS = love.filesystem + +-- A love.filesystem stub keyed by full path, extended past save_slots' memfs +-- with the export surface SaveFileIO reaches for (createDirectory / +-- getSaveDirectory). A directory key is implied by any file under it. +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 + local prefix = path .. "/" + for key in pairs(files) do + if key:sub(1, #prefix) == prefix then return { type = "directory" } end + end + return nil + end, + createDirectory = function() return true end, + getSaveDirectory = function() return "/fake/save" end, + } +end + +local function fresh() + local files = {} + love.filesystem = memfs(files) + SaveData.resetSlotState() + GameVersion.set("red") + return files +end + +-- ---- crosswalk data + synthetic 32KB save (built the way the codec tests do) + +GenSave.setCharmap(loadfile("src/save_convert/data/charmap.lua")()) +local data = { + pokemon = loadfile("data/generated/pokemon.lua")(), + moves = loadfile("data/generated/moves.lua")(), + items = loadfile("data/generated/items.lua")(), + maps = loadfile("data/generated/maps.lua")(), + eventFlags = loadfile("src/save_convert/data/event_flags.lua")(), +} + +-- independent checksum re-derivation (complement of the additive byte sum) so +-- the export sanity check does not trust the encoder that wrote it +local bit = require("bit") +local OFF = GenSave.OFFSETS +local function rawChecksum(bytes, from, to) + local sum = 0 + for i = from, to - 1 do sum = bit.band(sum + bytes:byte(i + 1), 0xFF) end + return bit.band(bit.bnot(sum), 0xFF) +end +local function mainChecksumValid(bytes) + return rawChecksum(bytes, OFF.checksumStart, OFF.checksumEnd) + == bytes:byte(OFF.mainChecksum + 1) +end + +local function syntheticSave(name) + local seed = SaveData.newGame({ playerName = name, rivalName = "BLUE" }) + seed.money = 4321 + seed.inventory = { POTION = 2, POKE_BALL = 7, BOULDERBADGE = 1 } + seed.bagOrder = { "POTION", "POKE_BALL" } + seed.party = { { + species = "SQUIRTLE", level = 6, exp = 200, + dvs = { hp = 1, attack = 2, defense = 3, speed = 4, special = 5 }, + statExp = { hp = 0, attack = 0, defense = 0, speed = 0, special = 0 }, + stats = { hp = 22, attack = 12, defense = 13, speed = 11, special = 12 }, + hp = 22, status = nil, + moves = { { id = "TACKLE", pp = 35, ppUps = 0 } }, + nickname = "SQ", ot = name, otId = seed.player.id, catchRate = 45, + } } + return GenSave.encode(seed, data, nil) +end + +-- ---------------------------------------------- importToSlot -> listSlots + +do + fresh() + local bytes = syntheticSave("IMP") + eq(#bytes, GenSave.SAVE_SIZE, "the synthetic save is 32768 bytes") + + local ok, slotId = SaveFileIO.importToSlot(bytes, "red") + eq(ok, true, "importToSlot succeeds on a valid 32KB save") + eq(slotId, "slot1", "the first import registers slot1") + + local slots = SaveData.listSlots("red") + eq(#slots, 1, "the imported save shows up as exactly one slot") + eq(slots[1].id, "slot1", "the listed slot is slot1") + eq(slots[1].exists, true, "the imported slot reports a save present") + eq(slots[1].name, "IMP", "the imported slot surfaces the decoded player name") + eq(SaveData.activeSlot("red"), "slot1", "the imported slot is made active") + + -- the slot loads cleanly (meta re-stamped from gen1_import to the numeric + -- format, so runMigrations does not choke) + local loaded = SaveData.load("red") + check(loaded ~= nil, "the imported slot loads back") + eq(loaded and loaded.player.name, "IMP", "loaded save keeps the player name") + eq(loaded and loaded.money, 4321, "loaded save keeps the money") + eq(loaded and #loaded.party, 1, "loaded save keeps the party") + + -- a second import allocates a fresh slot and makes it active + local ok2, slot2 = SaveFileIO.importToSlot(syntheticSave("TWO"), "red") + eq(ok2, true, "a second import succeeds") + eq(slot2, "slot2", "the second import allocates slot2") + eq(#SaveData.listSlots("red"), 2, "both imported slots are listed") + eq(SaveData.activeSlot("red"), "slot2", "the newest import becomes active") +end + +-- ---------------------------------------------- exportActiveSlot byte sanity + +do + local files = fresh() + SaveFileIO.importToSlot(syntheticSave("EXP"), "red") + + local ok, path = SaveFileIO.exportActiveSlot("red") + eq(ok, true, "exportActiveSlot succeeds for an active slot with a save") + eq(path, "/fake/save/exports/gen1recomp-red-slot1.sav", + "the export path is absolute and names the version + slot") + + local outBytes = files["exports/gen1recomp-red-slot1.sav"] + check(outBytes ~= nil, "the export file lands in the save-dir exports/ folder") + eq(outBytes and #outBytes, GenSave.SAVE_SIZE, "the export is exactly 32768 bytes") + check(outBytes and mainChecksumValid(outBytes), + "the export carries a valid main-data checksum") + + -- the export re-imports to an equivalent save + local re = SaveConvert.importSav(outBytes, "red") + check(re ~= nil, "the export re-imports through SaveConvert") + eq(re and re.player and re.player.name, "EXP", "the export round-trips the player name") + eq(re and re.party[1] and re.party[1].species, "SQUIRTLE", + "the export round-trips the party") + eq(re and re.inventory and re.inventory.BOULDERBADGE, 1, + "the export round-trips a badge") +end + +-- ---------------------------------------------- failure UX (never raises) + +do + fresh() + -- wrong size via a DroppedFile-shaped source (100 bytes) + local shortFile = { + _bytes = string.rep("\0", 100), + open = function() return true end, + getSize = function(self) return #self._bytes end, + read = function(self) return self._bytes end, + close = function() return true end, + } + local ok, err = SaveFileIO.importToSlot(shortFile, "red") + eq(ok, false, "a wrong-size save is rejected, not imported") + check(type(err) == "string" and err:find("32", 1, true) ~= nil, + "the wrong-size error names the required size") + eq(#SaveData.listSlots("red"), 0, "a rejected import creates no slot") + + -- bad checksum: flip a modeled byte in an otherwise valid image + local good = syntheticSave("BAD") + local corrupt = good:sub(1, OFF.money) + .. string.char((good:byte(OFF.money + 1) + 1) % 256) + .. good:sub(OFF.money + 2) + local okc, errc = SaveFileIO.importToSlot(corrupt, "red") + eq(okc, false, "a bad-checksum save is rejected") + check(type(errc) == "string" and errc:find("checksum", 1, true) ~= nil, + "the bad-checksum error mentions the checksum") + eq(#SaveData.listSlots("red"), 0, "a rejected checksum creates no slot") + + -- export with nothing to export + local oke, erre = SaveFileIO.exportActiveSlot("red") + eq(oke, false, "exportActiveSlot fails cleanly when there is no save") + check(type(erre) == "string", "the empty-export failure carries a message") +end + +-- ---------------------------------------------- fixture-gated real save + +do + local fixturePath = os.getenv("POKEPORT_SAV_FIXTURE") + local fixtureBytes + if fixturePath then + local ff = io.open(fixturePath, "rb") + if ff then + fixtureBytes = ff:read("*a") + ff:close() + if #fixtureBytes ~= GenSave.SAVE_SIZE then fixtureBytes = nil end + end + end + if not fixtureBytes then + print("save_file_io fixture case skipped (set POKEPORT_SAV_FIXTURE to a 32KB .sav)") + else + local files = fresh() + local ok, slotId = SaveFileIO.importToSlot(fixtureBytes, "red") + eq(ok, true, "fixture: a real .sav imports to a slot") + check(slotId ~= nil, "fixture: the import returns a slot id") + + local slots = SaveData.listSlots("red") + eq(#slots, 1, "fixture: the real save shows as one slot") + check(slots[1].exists and type(slots[1].name) == "string" and #slots[1].name > 0, + "fixture: the imported slot has a non-empty player name") + + local loaded = SaveData.load("red") + check(loaded ~= nil and #loaded.party >= 1 and #loaded.party <= 6, + "fixture: the imported slot loads with a 1..6 party") + + local eok, path = SaveFileIO.exportActiveSlot("red") + eq(eok, true, "fixture: the imported real save exports") + local rel = path:gsub("^/fake/save/", "") + local outBytes = files[rel] + eq(outBytes and #outBytes, GenSave.SAVE_SIZE, "fixture: the export is 32768 bytes") + check(outBytes and mainChecksumValid(outBytes), + "fixture: the export has a valid main-data checksum") + end +end + +love.filesystem = realFS + +T.finish("save_file_io") diff --git a/tests/engine/save_slots.lua b/tests/engine/save_slots.lua new file mode 100644 index 00000000..9f12626d --- /dev/null +++ b/tests/engine/save_slots.lua @@ -0,0 +1,211 @@ +-- Save-slot backend (src/core/SaveData.lua): legacy migration, the slot +-- registry in options.lua, listSlots/setActiveSlot/createSlot, and the +-- active-slot resolution behind saveNames/save/load. Self-contained: it +-- installs the love stub only for a swappable in-memory filesystem, the +-- same way tests/mod_save_tests isolates its save round-trips. + +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 + +-- an isolated love.filesystem: keys are full paths, so "saves/red/slot1.lua" +-- needs no directory support (createDirectory is deliberately absent, which +-- is exactly what the ensureParentDir no-op path handles) +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 + +-- a fresh filesystem + cleared process globals: each scenario is a first boot +local function fresh() + local files = {} + love.filesystem = memfs(files) + SaveData.resetSlotState() + GameVersion.set("red") + return files +end + +-- a minimal but fully decodable Red save +local function legacySave(name, dexOwned, badges, playTime) + local owned = {} + for _, id in ipairs(dexOwned or {}) do owned[id] = true end + local inv = {} + for _, id in ipairs(badges or {}) do inv[id] = true end + return { + version = "red", + player = { name = name, map = "PALLET_TOWN", x = 1, y = 1 }, + pokedex = { seen = {}, owned = owned }, + inventory = inv, + playTime = playTime or 0, + } +end + +-- ---------------------------------------------- slotSummary (pure) + +do + local name, meta = SaveData.slotSummary( + legacySave("ASH", { "PIKACHU", "PIDGEY", "RATTATA" }, + { "BOULDERBADGE", "CASCADEBADGE" }, 3661)) + T.eq(name, "ASH", "slotSummary reads the player name") + T.eq(meta.dexCount, 3, "slotSummary counts owned dex entries") + T.eq(meta.badges, 2, "slotSummary counts vanilla badges from inventory") + T.eq(meta.timeText, "1:01", "slotSummary formats playTime as H:MM") + + local n2, m2 = SaveData.slotSummary(nil) + T.eq(n2, nil, "slotSummary of an empty slot has no name") + T.eq(m2, nil, "slotSummary of an empty slot has no meta") +end + +-- ---------------------------------------------- legacy migration happy path + +do + local files = fresh() + files["save.lua"] = SaveSerializer.encode( + legacySave("RED", { "BULBASAUR", "CHARMANDER" }, { "BOULDERBADGE" }, 7325)) + + local slots = SaveData.listSlots("red") + T.eq(#slots, 1, "legacy save migrates into exactly one slot") + T.eq(slots[1].id, "slot1", "the migrated slot is slot1") + T.eq(slots[1].exists, true, "the migrated slot reports a save present") + T.eq(slots[1].name, "RED", "the migrated slot surfaces the player name") + T.eq(slots[1].meta.badges, 1, "migrated slot meta carries the badge count") + T.eq(slots[1].meta.dexCount, 2, "migrated slot meta carries the dex count") + T.eq(slots[1].meta.timeText, "2:02", "migrated slot meta carries the time") + + T.eq(files["save.lua"], nil, "the flat legacy file is removed after migration") + T.check(files["saves/red/slot1.lua"] ~= nil, "the slot file now holds the save") + + local opts = SaveSerializer.decode(files["options.lua"]) + T.eq(opts.saveSlots.red.active, "slot1", "options registers slot1 as active") + T.eq(opts.saveSlots.red.list[1], "slot1", "options lists the migrated slot") + + -- load() now resolves the active slot and reads the migrated save + local loaded = SaveData.load("red") + T.check(loaded and loaded.player.name == "RED", "load reads the active slot") +end + +-- ---------------------------------------------- migration idempotence + +do + local files = fresh() + files["save.lua"] = SaveSerializer.encode(legacySave("ONCE", { "MEW" }, {}, 0)) + SaveData.listSlots("red") -- first boot: migrates + local slotBytes = files["saves/red/slot1.lua"] + + -- a second boot: registry exists, no flat file, so nothing re-migrates + SaveData.resetSlotState() + local slots = SaveData.listSlots("red") + T.eq(#slots, 1, "a re-boot does not duplicate the migrated slot") + T.eq(files["save.lua"], nil, "no flat file reappears on re-boot") + T.eq(files["saves/red/slot1.lua"], slotBytes, "the slot bytes are untouched") + local opts = SaveSerializer.decode(files["options.lua"]) + T.eq(#opts.saveSlots.red.list, 1, "the registry still lists exactly one slot") +end + +-- ---------------------------------------------- mixed real / empty slots + +do + local files = fresh() + files["save.lua"] = SaveSerializer.encode( + legacySave("REAL", { "EEVEE" }, { "BOULDERBADGE" }, 60)) + SaveData.listSlots("red") -- slot1 = the migrated real save + local empty = SaveData.createSlot("red") + T.eq(empty, "slot2", "createSlot allocates slot2 alongside the migrated slot1") + + local slots = SaveData.listSlots("red") + T.eq(#slots, 2, "both the real and empty slots are listed") + T.eq(slots[1].exists, true, "the migrated slot still reports a save") + T.eq(slots[1].name, "REAL", "the real slot keeps its name") + T.eq(slots[2].exists, false, "the freshly created slot is empty") + T.eq(slots[2].name, nil, "an empty slot has no name") + T.eq(slots[2].meta, nil, "an empty slot has no meta") +end + +-- ---------------------------------------------- setActiveSlot persistence + +do + local files = fresh() + SaveData.createSlot("red") -- slot1 + SaveData.createSlot("red") -- slot2 + SaveData.setActiveSlot("red", "slot2") + + local opts = SaveSerializer.decode(files["options.lua"]) + T.eq(opts.saveSlots.red.active, "slot2", "setActiveSlot persists the active id") + T.eq(opts.saveSlots.red.list[1], "slot1", "the slot list is preserved") + T.eq(opts.saveSlots.red.list[2], "slot2", "the target slot stays in the list") + + -- selecting a slot that was never registered adds it + SaveData.setActiveSlot("red", "slot7") + opts = SaveSerializer.decode(files["options.lua"]) + T.eq(opts.saveSlots.red.active, "slot7", "an unregistered active slot is added") + T.eq(opts.saveSlots.red.list[3], "slot7", "the added slot lands in the list") +end + +-- ---------------------------------------------- createSlot id allocation + +do + fresh() + T.eq(SaveData.createSlot("red"), "slot1", "first slot is slot1") + T.eq(SaveData.createSlot("red"), "slot2", "second slot is slot2") + T.eq(SaveData.createSlot("red"), "slot3", "ids increment past the highest") +end + +-- ---------------------------------------------- saveNames follows the slot + +do + local files = fresh() + SaveData.createSlot("red") -- slot1 + SaveData.createSlot("red") -- slot2 + SaveData.setActiveSlot("red", "slot2") + + local save = SaveData.newGame() + save.player.name = "SLOT2" + T.check(SaveData.save(save), "save writes to the active slot") + T.check(files["saves/red/slot2.lua"] ~= nil, "bytes land in slot2's file") + T.eq(files["saves/red/slot1.lua"], nil, "slot1 is untouched by a slot2 save") + T.eq(files["save.lua"], nil, "no flat file is written once a slot is active") + + local loaded = SaveData.load("red") + T.check(loaded and loaded.player.name == "SLOT2", "load reads back from slot2") +end + +-- ---------------------------------------------- a version with no slots + +do + local files = fresh() + local slots = SaveData.listSlots("red") + T.eq(#slots, 0, "a fresh install with no legacy save lists no slots") + + -- with nothing registered, save/load use the flat legacy path, exactly + -- as they did before slots existed + local save = SaveData.newGame() + save.player.name = "FLAT" + T.check(SaveData.save(save), "a slotless version saves to the flat file") + T.check(files["save.lua"] ~= nil, "the flat save.lua is written") + T.eq(files["saves/red/slot1.lua"], nil, "no slot directory is created") + + local loaded = SaveData.load("red") + T.check(loaded and loaded.player.name == "FLAT", "load reads the flat file") + + T.eq(SaveData.saveFilename("red"), "save.lua", + "saveFilename still resolves the flat name with no slot in use") +end + +love.filesystem = realFS + +T.finish("save_slots") diff --git a/tests/engine/trade_anim_tests.lua b/tests/engine/trade_anim_tests.lua new file mode 100644 index 00000000..373d867f --- /dev/null +++ b/tests/engine/trade_anim_tests.lua @@ -0,0 +1,58 @@ +-- TradeAnim InternalClockTradeFuncSequence completes under A-skip and +-- exposes the cable-trade phases (engine/movie/trade.asm). +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 S = require("tests.harness").suite("trade anim") +local check, eq = S.check, S.eq + +local Game = require("src.core.Game") +local Input = require("src.core.Input") +local StateStack = require("src.core.StateStack") +local SaveData = require("src.core.SaveData") +local Pokemon = require("src.pokemon.Pokemon") +local TradeAnim = require("src.ui.TradeAnim") + +Game.data = Data +Game.input = Input; Input:init() +Game.stack = StateStack; StateStack:init() +Game.save = SaveData.newGame() +require("src.render.Font").load(Data) + +local sent = Pokemon.new(Data, "SPEAROW", 10) +local recv = Pokemon.new(Data, "FARFETCHD", 10) +recv.nickname = "DUX" +recv.ot = "TRAINER" +recv.otId = 8193 + +local done = false +local anim = TradeAnim.new(Game, { + sent = sent, received = recv, enemyName = "TRAINER", + onDone = function() done = true end, +}) +Game.stack:push(anim) +if anim.enter then anim:enter() end + +local seen = {} +local guard = 0 +while not done and guard < 20000 do + guard = guard + 1 + seen[anim.phase] = true + Input.pressed = { a = true } + StateStack:update(1 / 60) + Input.pressed = {} +end + +check(done, "TradeAnim reaches onDone") +for _, phase in ipairs({ + "show_player", "open_cable", "ball_enter", "transfer_lr", + "went_to", "transfer_rl", "show_enemy", +}) do + check(seen[phase], "saw phase " .. phase) +end +eq(Game.stack:top(), nil, "TradeAnim pops itself") + +S.finish() diff --git a/tests/engine/update_check_tests.lua b/tests/engine/update_check_tests.lua new file mode 100644 index 00000000..9196e3e6 --- /dev/null +++ b/tests/engine/update_check_tests.lua @@ -0,0 +1,65 @@ +-- Pure-surface coverage for src/update/Check.lua (the self-update release +-- check / payload download module). The network, hashing and archive-probe +-- logic lives in src/update/check_worker.lua and needs love + curl; these are +-- the love-free extraction/parsing seams the worker and UI both trust. +-- luajit tests/engine/update_check_tests.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +local Check = require("src.update.Check") +local Json = require("src.link.Json") + +-- releaseUrl is the fixed public landing page the UI links on needs_full +eq(Check.releaseUrl(), + "https://github.com/bryanthaboi/pokemon-gen1-recomp-project/releases/latest", + "releaseUrl points at the repo's latest release") + +-- parseRelease: a well-formed release with the .love payload and its sums +local body = Json.encode({ + tag_name = "v1.4.2", + assets = { + { name = "gen1recomp-1.4.2-macos.zip", browser_download_url = "http://x/mac", size = 10 }, + { name = "gen1recomp-1.4.2.love", browser_download_url = "http://x/love", size = 12345 }, + { name = "sha256sums.txt", browser_download_url = "http://x/sums", size = 99 }, + }, +}) +local rel = Check.parseRelease(body) +check(rel ~= nil, "parseRelease accepts a valid release") +eq(rel.version, "1.4.2", "leading v stripped from tag_name") +eq(rel.payloadName, "gen1recomp-1.4.2.love", "payload name derived from version") +eq(rel.payload.url, "http://x/love", "payload asset url picked") +eq(rel.payload.size, 12345, "payload asset size picked") +eq(rel.sums.url, "http://x/sums", "sums asset url picked") + +-- a newer release that ships no .love yet: parses, but the payload/sums are nil +-- so the worker will route to needs_full rather than an in-place update +local noPayload = Check.parseRelease(Json.encode({ tag_name = "2.0.0", assets = {} })) +check(noPayload ~= nil, "parseRelease accepts a payload-less release") +eq(noPayload.version, "2.0.0", "version parsed without assets") +eq(noPayload.payload, nil, "no payload asset -> nil") +eq(noPayload.sums, nil, "no sums asset -> nil") + +-- rejects: non-semver tag, and a document with no tag at all +local bad, badErr = Check.parseRelease(Json.encode({ tag_name = "nightly" })) +eq(bad, nil, "non-X.Y.Z tag rejected") +check(badErr ~= nil, "rejection carries an error string") +eq(Check.parseRelease(Json.encode({ foo = 1 })), nil, "missing tag_name rejected") + +-- parseSums: shasum -a 256 format, tolerating the '*' binary marker, a './' +-- prefix and CRLF line endings; unrelated lines are skipped +local sums = + "aaaa1111 gen1recomp-1.4.2.love\n" .. + "BBBB2222 *./sha256sums.txt\r\n" .. + "not a checksum line\n" +local map = Check.parseSums(sums) +eq(map["gen1recomp-1.4.2.love"], "aaaa1111", "bare-name sum parsed") +eq(map["sha256sums.txt"], "bbbb2222", "* marker and ./ prefix stripped, lowered") +eq(Check.parseSums(sums, "gen1recomp-1.4.2.love"), "aaaa1111", "targeted lookup returns the hash") +eq(Check.parseSums(sums, "missing.love"), nil, "targeted lookup misses cleanly") + +-- pickAsset guards a non-table assets field +eq(Check.pickAsset(nil, "x"), nil, "pickAsset tolerates a nil asset list") + +T.finish("update_check") diff --git a/tests/engine/update_tests.lua b/tests/engine/update_tests.lua new file mode 100644 index 00000000..0ef7eeff --- /dev/null +++ b/tests/engine/update_tests.lua @@ -0,0 +1,300 @@ +-- Pure-logic coverage for the self-updater (src/update/*). Every export +-- exercised here is love-free: Semver's parse/compare, Boot's select() +-- decision function, and Check's release-JSON / sha256sums / asset parsers. +-- The love-bound halves (Boot.run's mount+chainload, Check's thread worker, +-- curl, hashing) need a real LOVE process and are covered elsewhere; this +-- suite is the plain-Lua seam the whole updater trusts. +-- luajit tests/engine/update_tests.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +local Semver = require("src.update.Semver") +local Boot = require("src.update.Boot") +local Check = require("src.update.Check") +local Json = require("src.link.Json") + +-- --------------------------------------------------------------------------- +-- Semver.parse +-- --------------------------------------------------------------------------- + +-- valid triples decode to numeric fields, not strings +local p = Semver.parse("1.2.3") +check(p ~= nil, "parse accepts a plain X.Y.Z") +eq(p.major, 1, "parse major") +eq(p.minor, 2, "parse minor") +eq(p.patch, 3, "parse patch") +eq(type(p.major), "number", "parse yields numbers, not strings") + +local zero = Semver.parse("0.0.0") +eq(zero.major, 0, "parse zeros: major") +eq(zero.patch, 0, "parse zeros: patch") + +local big = Semver.parse("10.20.30") +eq(big.major, 10, "parse multi-digit major") +eq(big.minor, 20, "parse multi-digit minor") +eq(big.patch, 30, "parse multi-digit patch") + +-- an optional leading lowercase "v" is stripped +local v = Semver.parse("v2.5.9") +check(v ~= nil, "parse accepts a leading v") +eq(v.major, 2, "leading v: major") +eq(v.minor, 5, "leading v: minor") +eq(v.patch, 9, "leading v: patch") + +-- rejects: partial versions, extra components, non-numeric parts, suffixes, +-- a bare v, whitespace, empties, and non-string inputs -- all return nil, not +-- a raise (the safe answer for the updater is "not a real version") +eq(Semver.parse("1.2"), nil, "parse rejects a two-part version") +eq(Semver.parse("1"), nil, "parse rejects a one-part version") +eq(Semver.parse("1.2.3.4"), nil, "parse rejects a four-part version") +eq(Semver.parse("1.2.x"), nil, "parse rejects a non-numeric part") +eq(Semver.parse("1.2.3-dev"), nil, "parse rejects a pre-release suffix") +eq(Semver.parse("0.0.0-dev"), nil, "parse rejects the working-tree placeholder") +eq(Semver.parse("v"), nil, "parse rejects a bare v") +eq(Semver.parse(" 1.2.3"), nil, "parse rejects leading whitespace (anchored)") +eq(Semver.parse("1.2.3 "), nil, "parse rejects trailing whitespace (anchored)") +eq(Semver.parse(""), nil, "parse rejects the empty string") +eq(Semver.parse("nightly"), nil, "parse rejects a non-numeric tag") +eq(Semver.parse(nil), nil, "parse rejects nil") +eq(Semver.parse(123), nil, "parse rejects a number") +eq(Semver.parse({}), nil, "parse rejects a table") + +-- --------------------------------------------------------------------------- +-- Semver.compare +-- --------------------------------------------------------------------------- + +-- ordering is major, then minor, then patch +eq(Semver.compare("2.0.0", "1.9.9"), 1, "compare: major dominates (a > b)") +eq(Semver.compare("1.0.0", "2.0.0"), -1, "compare: major dominates (a < b)") +eq(Semver.compare("1.2.0", "1.1.9"), 1, "compare: minor breaks a major tie (a > b)") +eq(Semver.compare("1.1.0", "1.2.0"), -1, "compare: minor breaks a major tie (a < b)") +eq(Semver.compare("1.1.2", "1.1.1"), 1, "compare: patch breaks a minor tie (a > b)") +eq(Semver.compare("1.1.1", "1.1.2"), -1, "compare: patch breaks a minor tie (a < b)") + +-- equality +eq(Semver.compare("1.2.3", "1.2.3"), 0, "compare: identical versions are equal") +eq(Semver.compare("v1.2.3", "1.2.3"), 0, "compare: leading v does not change value") + +-- string and already-parsed-table inputs interoperate on either side +eq(Semver.compare(Semver.parse("1.2.3"), "1.2.4"), -1, "compare: parsed-table a vs string b") +eq(Semver.compare("1.3.0", Semver.parse("1.2.9")), 1, "compare: string a vs parsed-table b") +eq(Semver.compare({ major = 2, minor = 0, patch = 0 }, + { major = 1, minor = 9, patch = 9 }), 1, "compare: raw tables on both sides") +eq(Semver.compare(Semver.parse("4.4.4"), Semver.parse("4.4.4")), 0, "compare: equal parsed tables") + +-- an unparseable side sorts as the lowest possible version, so a bogus value +-- never wins a "newer" test; two unparseable sides are equal +eq(Semver.compare("garbage", "1.0.0"), -1, "compare: unparseable a loses to a real version") +eq(Semver.compare("1.0.0", "garbage"), 1, "compare: a real version beats an unparseable b") +eq(Semver.compare("garbage", "junk"), 0, "compare: two unparseable sides are equal") +eq(Semver.compare(nil, "1.0.0"), -1, "compare: nil a sorts lowest") +eq(Semver.compare("1.0.0", nil), 1, "compare: nil b sorts lowest") + +-- --------------------------------------------------------------------------- +-- Boot.select (pure: no love.*) +-- --------------------------------------------------------------------------- + +-- membership helper: toDelete order is deterministic, but assert on the set so +-- the tests document intent rather than iteration accidents +local function nameSet(list) + local s = {} + for _, n in ipairs(list) do s[n] = true end + return s +end + +-- empty candidate list: nothing to run, nothing to delete +do + local chosen, del = Boot.select({}, "1.0.0", 1) + eq(chosen, nil, "select: empty candidate list picks nothing") + eq(#del, 0, "select: empty candidate list deletes nothing") +end + +-- picks the highest eligible payload and marks the lower runnable ones (still +-- newer than bundled, but superseded by the winner) for deletion +do + local candidates = { + { name = "a.love", engine = "1.1.0" }, -- no minShell -> defaults to 1 + { name = "b.love", engine = "1.3.0", minShell = 1 }, + { name = "c.love", engine = "1.2.0", minShell = 1 }, + } + local chosen, del = Boot.select(candidates, "1.0.0", 1) + eq(chosen, "b.love", "select: picks the highest eligible engine") + local d = nameSet(del) + eq(#del, 2, "select: both losers are marked for deletion") + check(d["a.love"] and d["c.love"], "select: superseded runnable payloads are deleted") + check(not d["b.love"], "select: the chosen payload is never deleted") +end + +-- skips payloads whose minShell is above the bundled shell, and KEEPS an +-- otherwise-newer one for a future shell upgrade instead of deleting it +do + local candidates = { + { name = "future.love", engine = "2.0.0", minShell = 2 }, -- unrunnable at shell 1 + { name = "ok.love", engine = "1.5.0", minShell = 1 }, + } + local chosen, del = Boot.select(candidates, "1.0.0", 1) + eq(chosen, "ok.love", "select: skips a payload whose minShell exceeds the bundled shell") + eq(#del, 0, "select: a newer-but-unrunnable payload is kept, not deleted") + check(not nameSet(del)["future.love"], "select: unrunnable-newer payload survives") +end + +-- skips payloads not strictly newer than bundled (older AND equal) and marks +-- them stale for deletion +do + local candidates = { + { name = "old.love", engine = "0.9.0", minShell = 1 }, -- older than bundled + { name = "same.love", engine = "1.0.0", minShell = 1 }, -- equal to bundled + { name = "new.love", engine = "1.1.0", minShell = 1 }, -- the only real update + } + local chosen, del = Boot.select(candidates, "1.0.0", 1) + eq(chosen, "new.love", "select: only a strictly-newer payload is eligible") + local d = nameSet(del) + eq(#del, 2, "select: older and equal payloads are both stale") + check(d["old.love"], "select: an older payload is deleted") + check(d["same.love"], "select: a same-version payload is deleted") + check(not d["new.love"], "select: the winner is not in the delete list") +end + +-- no eligible payload at all (all older or equal): pick nothing, delete every +-- stale candidate +do + local candidates = { + { name = "old.love", engine = "0.5.0", minShell = 1 }, + { name = "same.love", engine = "1.0.0", minShell = 1 }, + } + local chosen, del = Boot.select(candidates, "1.0.0", 1) + eq(chosen, nil, "select: no strictly-newer payload -> nothing chosen") + eq(#del, 2, "select: every stale candidate is cleaned up when nothing wins") +end + +-- the full mix in one pass: a superseded runnable one and a stale old one are +-- deleted; the chosen winner and a newer-but-unrunnable payload both survive +do + local candidates = { + { name = "sup.love", engine = "1.2.0", minShell = 1 }, -- newer, runnable, < winner + { name = "win.love", engine = "1.4.0", minShell = 1 }, -- the winner + { name = "future.love", engine = "2.0.0", minShell = 5 }, -- newer than winner, unrunnable + { name = "old.love", engine = "0.1.0", minShell = 1 }, -- stale + } + local chosen, del = Boot.select(candidates, "1.1.0", 1) + eq(chosen, "win.love", "select(mix): highest runnable-newer engine wins") + local d = nameSet(del) + eq(#del, 2, "select(mix): exactly the superseded and stale payloads are deleted") + check(d["sup.love"], "select(mix): a runnable payload below the winner is superseded") + check(d["old.love"], "select(mix): a stale payload is cleaned up") + check(not d["future.love"], "select(mix): a newer-but-unrunnable payload is kept") + check(not d["win.love"], "select(mix): the winner is kept") +end + +-- --------------------------------------------------------------------------- +-- Check.pickAsset +-- --------------------------------------------------------------------------- + +local assets = { + { name = "gen1recomp-1.4.2-macos.zip", browser_download_url = "http://x/mac", size = 10 }, + { name = "gen1recomp-1.4.2.love", browser_download_url = "http://x/love", size = 12345 }, + { name = "sha256sums.txt", browser_download_url = "http://x/sums", size = 99 }, +} +local picked = Check.pickAsset(assets, "gen1recomp-1.4.2.love") +check(picked ~= nil, "pickAsset finds an asset by exact name") +eq(picked.url, "http://x/love", "pickAsset returns the download url") +eq(picked.size, 12345, "pickAsset returns the numeric size") +eq(Check.pickAsset(assets, "does-not-exist.love"), nil, "pickAsset misses cleanly on an unknown name") + +-- coerces a string size to a number and tolerates non-table junk entries mixed +-- into the asset list +local coerced = Check.pickAsset({ "junk", 42, { name = "w", browser_download_url = "U", size = "7" } }, "w") +eq(coerced.size, 7, "pickAsset coerces a string size to a number") +eq(type(coerced.size), "number", "pickAsset size is a number after coercion") + +-- guards a non-table / nil assets field instead of raising +eq(Check.pickAsset(nil, "x"), nil, "pickAsset tolerates a nil asset list") +eq(Check.pickAsset("nope", "x"), nil, "pickAsset tolerates a non-table asset list") + +-- --------------------------------------------------------------------------- +-- Check.parseRelease (release-JSON extraction) +-- --------------------------------------------------------------------------- + +-- a well-formed release: version (leading v stripped), derived payload name, +-- and both the .love payload and its sums asset with url + size +local body = Json.encode({ + tag_name = "v1.4.2", + assets = assets, +}) +local rel = Check.parseRelease(body) +check(rel ~= nil, "parseRelease accepts a valid release") +eq(rel.version, "1.4.2", "parseRelease strips the leading v from tag_name") +eq(rel.payloadName, "gen1recomp-1.4.2.love", "parseRelease derives the payload name from the version") +eq(rel.payload.url, "http://x/love", "parseRelease picks the payload asset url") +eq(rel.payload.size, 12345, "parseRelease picks the payload asset size") +eq(rel.sums.url, "http://x/sums", "parseRelease picks the sums asset url") +eq(rel.sums.size, 99, "parseRelease picks the sums asset size") + +-- a release with no .love yet still parses; payload/sums are nil so the worker +-- routes to a full reinstall rather than an in-place update +local noPayload = Check.parseRelease(Json.encode({ tag_name = "2.0.0", assets = {} })) +check(noPayload ~= nil, "parseRelease accepts a payload-less release") +eq(noPayload.version, "2.0.0", "parseRelease reads the version without any assets") +eq(noPayload.payload, nil, "parseRelease reports a missing payload asset as nil") +eq(noPayload.sums, nil, "parseRelease reports a missing sums asset as nil") + +-- rejections carry an error string and never raise +local badTag, badTagErr = Check.parseRelease(Json.encode({ tag_name = "nightly" })) +eq(badTag, nil, "parseRelease rejects a non-X.Y.Z tag") +check(badTagErr ~= nil, "parseRelease rejection carries an error string") + +local noTag, noTagErr = Check.parseRelease(Json.encode({ foo = 1 })) +eq(noTag, nil, "parseRelease rejects a document with no tag_name") +check(noTagErr ~= nil, "parseRelease missing-tag rejection carries an error string") + +-- malformed input returns nil rather than raising (Json.decode yields nil, and +-- a bare non-object literal has no tag_name) +local ok1, garbage = pcall(Check.parseRelease, "this is not json {{{") +check(ok1, "parseRelease does not raise on unparseable JSON") +eq(garbage, nil, "parseRelease returns nil on unparseable JSON") +local ok2, empty = pcall(Check.parseRelease, "") +check(ok2, "parseRelease does not raise on empty input") +eq(empty, nil, "parseRelease returns nil on empty input") +local ok3, literal = pcall(Check.parseRelease, "42") +check(ok3, "parseRelease does not raise on a bare JSON literal") +eq(literal, nil, "parseRelease returns nil on a non-object JSON literal") + +-- --------------------------------------------------------------------------- +-- Check.parseSums (shasum -a 256 line parsing) +-- --------------------------------------------------------------------------- + +-- standard " " lines, the '*' binary marker, a './' prefix, CRLF +-- endings and mixed hash case; junk lines are skipped +local sums = + "aaaa1111 gen1recomp-1.4.2.love\n" .. + "BBBB2222 *./sha256sums.txt\r\n" .. + "deadBEEF ./nested.love\n" .. + "not a checksum line at all\n" +local map = Check.parseSums(sums) +eq(map["gen1recomp-1.4.2.love"], "aaaa1111", "parseSums reads a bare-name line") +eq(map["sha256sums.txt"], "bbbb2222", "parseSums strips the * marker and ./ prefix and lowercases") +eq(map["nested.love"], "deadbeef", "parseSums lowercases a mixed-case hash and strips ./") +eq(map["not a checksum line at all"], nil, "parseSums skips lines that are not checksums") + +-- the target form returns just that file's hash (hit / miss) +eq(Check.parseSums(sums, "gen1recomp-1.4.2.love"), "aaaa1111", "parseSums(target) returns the matching hash") +eq(Check.parseSums(sums, "missing.love"), nil, "parseSums(target) misses cleanly on an unknown file") + +-- degenerate inputs: empty text yields an empty map, a targeted miss is nil, +-- and a nil text does not raise +local emptyMap = Check.parseSums("") +eq(type(emptyMap), "table", "parseSums('') returns an (empty) table") +eq(next(emptyMap), nil, "parseSums('') has no entries") +eq(Check.parseSums(nil, "anything"), nil, "parseSums(nil, target) returns nil without raising") + +-- --------------------------------------------------------------------------- +-- Check.releaseUrl (the fixed public landing page) +-- --------------------------------------------------------------------------- + +eq(Check.releaseUrl(), + "https://github.com/bryanthaboi/pokemon-gen1-recomp-project/releases/latest", + "releaseUrl points at the repo's latest release") + +T.finish("update") diff --git a/tests/love_stub.lua b/tests/love_stub.lua index 80f5fc35..77dc8b12 100644 --- a/tests/love_stub.lua +++ b/tests/love_stub.lua @@ -28,6 +28,17 @@ end local files = {} -- in-memory love.filesystem +-- Minimal graphics-state tracking so push("all")/pop actually save and +-- restore, and getShader/getCanvas/etc can be read back. The render-pipeline +-- fold fences each mod callback between push("all")/pop so a callback that +-- dirties state cannot leak into the engine composite; mod_render_tests +-- asserts exactly that, which needs the stub to model the save/restore rather +-- than no-op it. Plain push()/pop() (the tilt upright pass) ride the same +-- stack and restore the same fields, which for those call sites is a no-op. +local gstate = { shader = nil, canvas = nil, blend = "alpha", + color = { 1, 1, 1, 1 } } +local gstack = {} + stub.graphics = { newImage = function(path) local w, h = pngSize(path) @@ -44,13 +55,37 @@ stub.graphics = { function batch:setTexture(tex) self.texture = tex end return batch end, - draw = noop, rectangle = noop, setColor = noop, clear = noop, - setCanvas = noop, setDefaultFilter = noop, print = noop, + draw = noop, rectangle = noop, clear = noop, + setDefaultFilter = noop, print = noop, + setColor = function(r, g, b, a) gstate.color = { r, g, b, a } end, + getColor = function() + local c = gstate.color + return c[1], c[2], c[3], c[4] + end, + setCanvas = function(c) gstate.canvas = c or nil end, + getCanvas = function() return gstate.canvas end, + setShader = function(s) gstate.shader = s or nil end, + getShader = function() return gstate.shader end, + setBlendMode = function(m) gstate.blend = m or "alpha" end, + getBlendMode = function() return gstate.blend end, -- coordinate-transform + state stack used by the tilt-mode upright pass - -- (billboards); plain no-ops here (tests that need to observe them swap + -- (billboards) and the render-pipeline fold; push snapshots the tracked + -- state, pop restores it (tests that need to observe the transforms swap -- in their own recorders, e.g. tests/parity_tilt.lua) - push = noop, pop = noop, translate = noop, scale = noop, - rotate = noop, origin = noop, setShader = noop, setScissor = noop, + push = function() + gstack[#gstack + 1] = { shader = gstate.shader, canvas = gstate.canvas, + blend = gstate.blend, color = gstate.color } + end, + pop = function() + local s = gstack[#gstack] + if s then + gstack[#gstack] = nil + gstate.shader, gstate.canvas = s.shader, s.canvas + gstate.blend, gstate.color = s.blend, s.color + end + end, + translate = noop, scale = noop, + rotate = noop, origin = noop, setScissor = noop, getDimensions = function() return 640, 576 end, -- dpi=1 desktop default; issue #87 tests override these for Android density getPixelDimensions = function() return 640, 576 end, @@ -68,6 +103,7 @@ stub.math = { stub.filesystem = { write = function(name, content) files[name] = content return true end, read = function(name) return files[name] end, + remove = function(name) files[name] = nil return true end, -- directories are implied by key prefixes ("mods/x/manifest.json") getInfo = function(name) if files[name] then return { type = "file" } end @@ -83,15 +119,27 @@ stub.filesystem = { end, getDirectoryItems = function(name) local seen, items = {}, {} - local prefix = name .. "/" - for key in pairs(files) do - if key:sub(1, #prefix) == prefix then - local child = key:sub(#prefix + 1):match("^[^/]+") + name = name or "" + -- "" / "/" = save-dir root (RomImporter Android ROM scan) + if name == "" or name == "/" then + for key in pairs(files) do + local child = key:match("^[^/]+") if child and not seen[child] then seen[child] = true items[#items + 1] = child end end + else + local prefix = name .. "/" + 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 end table.sort(items) return items diff --git a/tests/mod_battle_scale_tests.lua b/tests/mod_battle_scale_tests.lua new file mode 100644 index 00000000..173f4253 --- /dev/null +++ b/tests/mod_battle_scale_tests.lua @@ -0,0 +1,250 @@ +-- The battle-sprite-scale seam, exercised through the public mod API. +-- +-- Modders scale battle pics per species (pokemon.battleScaleFront / +-- battleScaleBack) or per image path (the battle_sprite_scales registry, +-- the only handle on non-species pics like the trainer back). The +-- properties worth pinning: the schema rejects out-of-range scales and a +-- pathless record, image-level beats species-level beats the vanilla +-- default, and above all the pic stays GROUNDED -- feet on the text-box +-- top, bottom edge in its slot -- at every scale and through the send-out +-- grow. The placement math and the scale resolver are pure (no love.*), +-- so the grounding contract is asserted directly. +package.path = "./?.lua;./?/init.lua;" .. package.path + +if not _G.love then _G.love = require("tests.love_stub") end + +local Loader = require("src.mods.Loader") +local Schemas = require("src.mods.Schemas") +local BattleState = require("src.battle.BattleState") + +local S = require("tests.harness").suite("mod battle scale") +local check, eq = S.check, S.eq + +local function memfs(files) + return { + read = function(path) return files[path] 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, + 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, + } +end + +local function manifest(id, extra) + return ('{"id":"%s","name":"%s","version":"1.0.0","api":2,' .. + '"entry":"main.lua"%s}'):format(id, id, extra or "") +end + +-- a minimal, internally consistent base so a pokemon patch has something +-- to fold onto; the cross-reference pass skips registries no mod touched, +-- so the untouched type/move refs on these records never surface +local function baseData() + return { + pokemon = { + PIKACHU = { id = "PIKACHU", name = "PIKACHU", dex = 25, + types = { "ELECTRIC" }, + baseStats = { hp = 35, attack = 55, defense = 30, speed = 90, special = 50 }, + catchRate = 190, baseExp = 82, level1Moves = { "THUNDERSHOCK" }, + growthRate = "MEDIUM_FAST", learnset = {}, evolutions = {}, + spriteFront = "pikachu_front.png", spriteBack = "pikachu_back.png", + frontSize = 5 }, + RAICHU = { id = "RAICHU", name = "RAICHU", dex = 26, + types = { "ELECTRIC" }, + baseStats = { hp = 60, attack = 90, defense = 55, speed = 110, special = 90 }, + catchRate = 75, baseExp = 122, level1Moves = { "THUNDERSHOCK" }, + growthRate = "MEDIUM_FAST", learnset = {}, evolutions = {}, + spriteFront = "raichu_front.png", spriteBack = "raichu_back.png", + frontSize = 6 }, + }, + moves = { + THUNDERSHOCK = { id = "THUNDERSHOCK", name = "THUNDERSHOCK", + type = "ELECTRIC", power = 40, accuracy = 100, pp = 30, + effect = "PARALYZE_SIDE_EFFECT1" }, + }, + } +end + +-- ------- schema: the battle_sprite_scales registry + +do + local spec = Schemas.REGISTRIES.battle_sprite_scales + check(spec ~= nil, "the battle_sprite_scales registry is in the catalog") + eq(spec.semantics, "record", "battle_sprite_scales merges as records") + eq(spec.target, "battle_sprite_scales", + "battle_sprite_scales writes to its own namespace") + + local ok = Schemas.check(spec, "battle_sprite_scales", "abra_back", + { path = "assets/generated/battle/back/abrab.png", scale = 1.5 }, "register") + check(ok, "a path + in-range scale validates") + + -- the boundaries are inclusive + check(Schemas.check(spec, "battle_sprite_scales", "lo", + { path = "x.png", scale = 0.25 }, "register"), "scale 0.25 is accepted") + check(Schemas.check(spec, "battle_sprite_scales", "hi", + { path = "x.png", scale = 4.0 }, "register"), "scale 4.0 is accepted") + + local tooBig, bigErr = Schemas.check(spec, "battle_sprite_scales", "big", + { path = "x.png", scale = 5 }, "register") + check(not tooBig, "a scale above 4.0 is rejected") + check(tostring(bigErr):find("0.25", 1, true) ~= nil, + "the rejection names the range it wanted: " .. tostring(bigErr)) + + check(not Schemas.check(spec, "battle_sprite_scales", "small", + { path = "x.png", scale = 0.1 }, "register"), + "a scale below 0.25 is rejected") + + local noPath, pathErr = Schemas.check(spec, "battle_sprite_scales", "nopath", + { scale = 1 }, "register") + check(not noPath, "a record with no path is rejected") + check(tostring(pathErr):find("path", 1, true) ~= nil, + "the rejection names the missing path: " .. tostring(pathErr)) + + check(not Schemas.check(spec, "battle_sprite_scales", "emptypath", + { path = "", scale = 1 }, "register"), "an empty path is rejected") +end + +-- ------- schema: the per-species scale fields + +do + local spec = Schemas.REGISTRIES.pokemon + check(Schemas.check(spec, "pokemon", "PIKACHU", + { battleScaleBack = 3, battleScaleFront = 0.5 }, "patch"), + "in-range species scale overrides validate as a patch") + local bad, err = Schemas.check(spec, "pokemon", "PIKACHU", + { battleScaleBack = 5 }, "patch") + check(not bad, "an out-of-range species scale is rejected") + check(tostring(err):find("battleScaleBack", 1, true) ~= nil, + "the rejection names the field: " .. tostring(err)) +end + +-- ------- the full merge: a mod patches a species and registers an image + +local FILES = { + ["mods/biggun/manifest.json"] = manifest("biggun"), + ["mods/biggun/main.lua"] = [[ + local mod = ... + -- species-level: PIKACHU's back pic at 3x + mod.content.pokemon:patch("PIKACHU", { battleScaleBack = 3 }) + -- image-level, keyed by path: overrides the species scale for this pic + mod.content.battle_sprite_scales:register("pika_back", { + path = "pikachu_back.png", scale = 1.25, + }) + -- and a bare, non-species pic (a trainer back) reachable only here + mod.content.battle_sprite_scales:register("hero_back", { + path = "assets/generated/battle/back/redb.png", scale = 1.5, + }) + ]], +} + +local data = baseData() +local loader = Loader.new({ fs = memfs(FILES) }) +local okLoad = loader:load(data) +check(okLoad, "the scale mod loads clean: " .. table.concat(loader.errors, "; ")) + +eq(data.pokemon.PIKACHU.battleScaleBack, 3, + "the species patch reached the merged data") +check(type(data.battle_sprite_scales) == "table", + "the merge created the battle_sprite_scales namespace") + +-- image-level beats species-level for the same pic +eq(BattleState.resolveBattleScale(data, "back", "pikachu_back.png", "PIKACHU"), + 1.25, "an image-level entry overrides the species scale for its path") +-- a different pic of the same species falls through to the species scale +eq(BattleState.resolveBattleScale(data, "back", "raichu_back.png", "PIKACHU"), + 3, "a species with an override but no image entry uses the species scale") +-- the non-species trainer back is reachable only by path +eq(BattleState.resolveBattleScale(data, "back", + "assets/generated/battle/back/redb.png", nil), + 1.5, "a bare pic is scaled by its image-level entry with no species") +-- an unregistered species, unregistered path: the vanilla side defaults +eq(BattleState.resolveBattleScale(data, "front", "raichu_front.png", "RAICHU"), + 1, "enemy front defaults to 1x when nothing is registered") +eq(BattleState.resolveBattleScale(data, "back", "raichu_back.png", "RAICHU"), + 2, "player back defaults to 2x when nothing is registered") + +-- ------- default unchanged with no registry at all + +do + local bare = { pokemon = { PIKACHU = {} } } + eq(BattleState.resolveBattleScale(bare, "front", "any.png", "PIKACHU"), 1, + "front default holds with no battle_sprite_scales table") + eq(BattleState.resolveBattleScale(bare, "back", "any.png", "PIKACHU"), 2, + "back default holds with no battle_sprite_scales table") + eq(BattleState.resolveBattleScale({}, "back", nil, nil), 2, + "back default holds with empty data and no path or species") +end + +-- ------- placement math: feet stay pinned at every scale + +local W, H, PAD, PADL = 56, 40, 3, 2 + +do + for _, s in ipairs({ 0.5, 1, 2, 3 }) do + local x, y, sc = BattleState.backPlacement(W, H, PAD, PADL, s) + eq(sc, s, "back placement returns the scale (scale " .. s .. ")") + eq(y + (H - PAD) * s, 96, + "player feet stay on the text-box top at scale " .. s) + eq(x, 8 - PADL * s, + "player left pad is pulled back proportionally at scale " .. s) + end + + local ex, ey = 100, 20 + for _, s in ipairs({ 0.5, 1, 2, 3 }) do + local x, y = BattleState.frontPlacement(ex, ey, W, H, s) + eq(y + H * s, ey + H, + "enemy bottom edge stays pinned to its slot at scale " .. s) + eq(x + W * s / 2, ex + W / 2, + "enemy horizontal centre stays pinned at scale " .. s) + end + + -- the s=1 case is the vanilla draw exactly: no shift + local x1, y1 = BattleState.frontPlacement(ex, ey, W, H, 1) + check(x1 == ex and y1 == ey, "scale 1 front placement is the untouched slot") +end + +-- ------- composition with the send-out grow + +do + -- growInScale returns the AnimateSendingOutMon stages; a mod scale + -- composes multiplicatively, and the composed pic is still grounded + local moddedBack = BattleState.resolveBattleScale( + { pokemon = { GROWMON = { battleScaleBack = 1.5 } } }, "back", nil, "GROWMON") + eq(moddedBack, 1.5, "species back override resolved for the grow test") + + for _, gs in ipairs({ 3 / 7, 5 / 7, 1 }) do + local eff = moddedBack * gs + local _, y = BattleState.backPlacement(W, H, PAD, PADL, eff) + eq(y + (H - PAD) * eff, 96, + "player feet stay pinned through grow stage " .. gs) + + local ex, ey = 100, 20 + local _, ey2 = BattleState.frontPlacement(ex, ey, W, H, eff) + eq(ey2 + H * eff, ey + H, + "enemy bottom stays pinned through grow stage " .. gs) + end +end + +S.finish() diff --git a/tests/mod_manifest_tests.lua b/tests/mod_manifest_tests.lua index db505b24..95941361 100644 --- a/tests/mod_manifest_tests.lua +++ b/tests/mod_manifest_tests.lua @@ -160,7 +160,7 @@ check(not pcall(Manifest.validate, { local versionLoader = Loader.new({ fs = memfs({ ["mods/future/manifest.json"] = manifestJson("future", { game_version = '">=2.0"' }), ["mods/future/main.lua"] = "return function(mod) mod.content.items:register('NOPE', {}) end", - ["mods/current/manifest.json"] = manifestJson("current", { game_version = '">=1.0 <2.0"' }), + ["mods/current/manifest.json"] = manifestJson("current", { game_version = '">=0.0.0-0 <2.0"' }), ["mods/current/main.lua"] = NOOP, }) }) local versionData = { items = {} } diff --git a/tests/mod_render_tests.lua b/tests/mod_render_tests.lua new file mode 100644 index 00000000..871abca2 --- /dev/null +++ b/tests/mod_render_tests.lua @@ -0,0 +1,352 @@ +-- The rendering-pipeline seam, exercised through the public mod API. +-- +-- A render pipeline is the one extension point that owns part of the +-- frame, so the properties worth pinning are the ones a mod cannot be +-- trusted to honor on its own: that a pipeline nobody switched on costs +-- nothing, that its callbacks are dispatched in priority order, and above +-- all that a mod which throws mid-frame degrades to the vanilla 2D path +-- instead of taking the frame down with it. +package.path = "./?.lua;./?/init.lua;" .. package.path + +if not _G.love then _G.love = require("tests.love_stub") end + +local Loader = require("src.mods.Loader") +local Schemas = require("src.mods.Schemas") +local Pipelines = require("src.render.Pipelines") +local Tilt = require("src.render.Tilt") + +local S = require("tests.harness").suite("mod render") +local check, eq = S.check, S.eq + +local function memfs(files) + return { + read = function(path) return files[path] 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, + 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, + } +end + +local function manifest(id, extra) + local body = ('{"id":"%s","name":"%s","version":"1.0.0","api":2,' .. + '"entry":"main.lua"%s}'):format(id, id, extra or "") + return body +end + +-- ------- schema: a record must actually do something + +do + local spec = Schemas.REGISTRIES.render_pipelines + check(spec ~= nil, "the render_pipelines registry is in the catalog") + eq(spec.semantics, "record", "render_pipelines merges as records") + eq(spec.target, "render_pipelines", "render_pipelines writes to its own namespace") + + local ok = Schemas.check(spec, "render_pipelines", "good", + { label = "GOOD", drawWorld = function() end }, "register") + check(ok, "a drawWorld-only record validates") + + local okPresent = Schemas.check(spec, "render_pipelines", "grade", + { label = "GRADE", present = function() end }, "register") + check(okPresent, "a present-only record validates") + + -- the whole point of the record is to draw something + local bad, err = Schemas.check(spec, "render_pipelines", "inert", + { label = "INERT" }, "register") + check(not bad, "a record with no draw callback is rejected") + check(tostring(err):find("drawWorld", 1, true) ~= nil, + "the rejection names the callbacks it wanted: " .. tostring(err)) + + local wrong = Schemas.check(spec, "render_pipelines", "typo", + { label = "T", drawWorld = "not a function" }, "register") + check(not wrong, "a non-function draw callback is rejected") +end + +-- ------- a mod registers two pipelines and the engine dispatches them + +local trace = {} + +local FILES = { + ["mods/painter/manifest.json"] = manifest("painter", ',"priority":10'), + ["mods/painter/main.lua"] = [[ + local mod = ... + local T = _G.__RENDER_TEST + mod.content.render_pipelines:register("diorama", { + label = "DIORAMA", + levels = { "OFF", "LOW", "HIGH" }, + hotkey = "7", + priority = 20, + available = function() return T.available end, + update = function(dt, level) T.trace[#T.trace + 1] = "update:" .. level end, + -- the folds composite only a real Canvas, so the mod hands back the + -- canvases the test pre-created (see T.worldOut / T.blurOut / T.gradeOut) + drawWorld = function(ctx) + T.trace[#T.trace + 1] = "world:" .. tostring(ctx.tag) + return T.worldOut + end, + worldPresent = function(canvas) + T.trace[#T.trace + 1] = "worldPresent" + return T.blurOut + end, + }) + mod.content.render_pipelines:register("grade", { + label = "GRADE", + priority = 5, + present = function(canvas) + T.trace[#T.trace + 1] = "present" + return T.gradeOut + end, + }) + ]], +} + +_G.__RENDER_TEST = { trace = trace, available = true } +-- the world/present folds accept only a real Canvas, so give the mod concrete +-- ones to return and pin identity through the dispatch +_G.__RENDER_TEST.worldOut = love.graphics.newCanvas(2, 2) +_G.__RENDER_TEST.blurOut = love.graphics.newCanvas(2, 2) +_G.__RENDER_TEST.gradeOut = love.graphics.newCanvas(2, 2) + +local data = {} +local loader = Loader.new({ fs = memfs(FILES) }) +local okLoad = loader:load(data) +check(okLoad, "the pipeline mod loads clean: " .. table.concat(loader.errors, "; ")) +Pipelines.install(data) + +check(type(data.render_pipelines) == "table", + "the merge created the render_pipelines namespace") +eq(data.render_pipelines._owners.diorama, "painter", + "the merge stamped the owning mod for runtime attribution") + +-- priority order, highest first, is what selection and the folds walk +local list = Pipelines.list() +eq(#list, 2, "both pipelines are catalogued") +eq(list[1].id, "diorama", "the higher-priority pipeline sorts first") +eq(list[2].id, "grade", "the lower-priority pipeline sorts second") +check(list[1].id ~= "_owners" and list[2].id ~= "_owners", + "the provenance key is not mistaken for a pipeline") + +-- ------- switched off costs nothing + +eq(Pipelines.worldPipeline(), nil, "nothing owns the world while off") +eq(Pipelines.wantsPresent(), false, "no present pass is wanted while off") +eq(Pipelines.present("frame"), "frame", "present is identity while off") +eq(Pipelines.worldPresent("frame"), "frame", "worldPresent is identity while off") +eq(#trace, 0, "no callback ran for a switched-off pipeline") + +-- update ticks every pipeline regardless, so a mode easing out still eases +Pipelines.update(0.016) +eq(trace[1], "update:0", "update ticks a switched-off pipeline") + +-- ------- switched on, the callbacks dispatch + +trace[1] = nil +Pipelines.setLevel("diorama", 2) +Pipelines.setLevel("grade", 1) + +eq(Pipelines.worldPipeline(), "diorama", + "the eligible world pipeline claims the world pass") +eq(Pipelines.drawWorld("diorama", { tag = "ctx" }), _G.__RENDER_TEST.worldOut, + "drawWorld returns the mod's canvas") +eq(trace[#trace], "world:ctx", "drawWorld received the frame context") + +eq(Pipelines.worldPresent(_G.__RENDER_TEST.worldOut), _G.__RENDER_TEST.blurOut, + "worldPresent folds its canvas over the world image") +eq(Pipelines.wantsPresent(), true, "a live present pass asks for the canvas") +eq(Pipelines.present(_G.__RENDER_TEST.gradeOut), _G.__RENDER_TEST.gradeOut, + "present folds its canvas over the finished composite") + +-- ------- the hardware gate + +_G.__RENDER_TEST.available = false +eq(Pipelines.worldPipeline(), nil, + "an unavailable pipeline never takes the world pass") +eq(Pipelines.worldPresent("world-canvas"), "world-canvas", + "an unavailable pipeline's worldPresent is skipped") +_G.__RENDER_TEST.available = true +eq(Pipelines.worldPipeline(), "diorama", "availability is re-read each frame") + +-- ------- the gate governs input, never the draw +-- +-- Regression: gating the DRAW on the free-roam state made the world drop +-- to the flat 2D path for the handful of frames a warp is transitioning, +-- so walking through a door flashed 2D before snapping back to 3D. A mode +-- that is on renders until it is off; the gate only stops the player +-- CHANGING it at a bad moment. + +Pipelines.setLevel("diorama", 2) + +-- a state that every free-roam gate refuses: mid-warp, and running a script +local warping = { transitioning = true } +local overworld = warping +eq(Pipelines.canToggle("diorama", warping, overworld), false, + "the gate refuses a mode change mid-warp") +eq(Pipelines.worldPipeline(), "diorama", + "but the mode keeps rendering through the warp -- no 2D flash") + +local scripted = { runner = { isRunning = function() return true end } } +eq(Pipelines.canToggle("diorama", scripted, scripted), false, + "the gate refuses a mode change mid-cutscene") +eq(Pipelines.worldPipeline(), "diorama", + "and the mode keeps rendering through the cutscene") + +-- a menu on top of the overworld is not the overworld, so the gate refuses +-- there too -- and the world beneath it must still be the 3D one +eq(Pipelines.canToggle("diorama", { menu = true }, overworld), false, + "the gate refuses a mode change from a menu") +eq(Pipelines.worldPipeline(), "diorama", + "the world under an open menu keeps rendering in the pipeline") + +eq(Pipelines.hotkey("7", warping, overworld), nil, + "a hotkey press mid-warp is refused") +eq(Pipelines.level("diorama"), 2, "and the refused press changed no level") + +-- ------- mutual exclusion + +Tilt.setLevel(3) +Pipelines.setLevel("diorama", 1) +eq(Tilt.level, 0, "a world pipeline switches the engine's TILT off") +Tilt.setLevel(0) + +-- ------- a throwing mod loses its pipeline, not the frame + +local BOOM = { + ["mods/boom/manifest.json"] = manifest("boom"), + ["mods/boom/main.lua"] = [[ + local mod = ... + mod.content.render_pipelines:register("boom", { + label = "BOOM", + drawWorld = function() error("pipeline exploded", 0) end, + }) + ]], +} +local boomData = {} +local boomLoader = Loader.new({ fs = memfs(BOOM) }) +boomLoader:load(boomData) +Pipelines.install(boomData) +Pipelines.setLevel("boom", 1) + +eq(Pipelines.worldPipeline(), "boom", "the pipeline is eligible before it throws") +eq(Pipelines.drawWorld("boom", {}), nil, + "a throwing drawWorld yields nil, so the caller falls back to 2D") +eq(Pipelines.worldPipeline(), nil, + "a pipeline that threw is retired rather than retried every frame") + +-- the failure has to reach the feed the mod manager shows, named after the +-- mod that owns it -- a console line alone leaves the player with a world +-- that silently stopped being 3D and nothing to disable +local blamed = nil +for _, message in ipairs(boomLoader.errors) do + if message:find("boom:", 1, true) and message:find("pipeline exploded", 1, true) then + blamed = message + end +end +check(blamed ~= nil, + "the runtime failure is attributed to its mod in the manager's error feed") + +-- ------- a non-canvas return is ignored, and a dirty callback cannot leak +-- +-- The fold composites only a real Canvas, so a present that forgets its +-- return -- or hands back a truthy shade string, flag or number -- must leave +-- the composite untouched rather than blank or crash the frame. Unlike a +-- throw, a clean-but-useless return is NOT a crash, so the pipeline stays +-- eligible instead of being retired. Separately, a callback that returns +-- cleanly while leaving a shader bound or the canvas redirected must not leak +-- that state into the engine composite that follows: the fold fences each +-- dispatch in push("all")/pop. + +local SLOPPY = { + ["mods/sloppy/manifest.json"] = manifest("sloppy"), + ["mods/sloppy/main.lua"] = [[ + local mod = ... + local T = _G.__SLOPPY + mod.content.render_pipelines:register("sloppy", { + label = "SLOPPY", + present = function(canvas) + T.ran = (T.ran or 0) + 1 + return T.ret + end, + }) + mod.content.render_pipelines:register("dirty", { + label = "DIRTY", + present = function(canvas) + love.graphics.setShader("mod-shader") + love.graphics.setCanvas("mod-canvas") + love.graphics.setColor(0.1, 0.2, 0.3, 0.4) + love.graphics.setBlendMode("add") + return canvas + end, + }) + ]], +} +_G.__SLOPPY = { ran = 0 } +local sloppyData = {} +local sloppyLoader = Loader.new({ fs = memfs(SLOPPY) }) +sloppyLoader:load(sloppyData) +Pipelines.install(sloppyData) + +local composite = love.graphics.newCanvas(4, 4) +Pipelines.setLevel("sloppy", 1) +for _, bad in ipairs({ "just-a-string", true, 42 }) do + _G.__SLOPPY.ret = bad + eq(Pipelines.present(composite), composite, + "a present returning a " .. type(bad) .. " leaves the composite untouched") +end +check(_G.__SLOPPY.ran == 3, "the present callback still ran each frame") +check(Pipelines.eligible("sloppy") == true, + "a non-canvas return does not retire the pipeline as broken") +Pipelines.setLevel("sloppy", 0) + +love.graphics.setShader("engine-shader") +love.graphics.setCanvas("engine-canvas") +love.graphics.setBlendMode("alpha") +love.graphics.setColor(1, 1, 1, 1) +Pipelines.setLevel("dirty", 1) +eq(Pipelines.present(composite), composite, + "a dirty present that returns its input leaves the composite unchanged") +eq(love.graphics.getShader(), "engine-shader", + "a present that bound a shader cannot leak it past the fold") +eq(love.graphics.getCanvas(), "engine-canvas", + "a present that redirected the canvas cannot leak it past the fold") +eq(love.graphics.getBlendMode(), "alpha", + "a present that changed blend mode cannot leak it past the fold") +Pipelines.setLevel("dirty", 0) +_G.__SLOPPY = nil + +Pipelines.reset() +Pipelines.install(nil) +_G.__RENDER_TEST = nil + +-- ------- and with no mods at all, the whole subsystem is inert + +eq(#Pipelines.list(), 0, "a mod-free boot registers no pipelines") +eq(Pipelines.worldPipeline(), nil, "a mod-free boot draws the vanilla world") +eq(Pipelines.wantsPresent(), false, "a mod-free boot allocates no present canvas") +eq(Pipelines.present("frame"), "frame", "a mod-free present is the identity") +eq(#Pipelines.rows({}), 0, "a mod-free options menu gains no rows") +eq(Pipelines.hotkey("6", nil, nil), nil, "a mod-free build claims no hotkeys") + +S.finish() diff --git a/tests/mod_runtime_tests.lua b/tests/mod_runtime_tests.lua index e66db768..4dbaf2d6 100644 --- a/tests/mod_runtime_tests.lua +++ b/tests/mod_runtime_tests.lua @@ -4,6 +4,7 @@ local Registry = require("src.mods.Registry") local Events = require("src.mods.Events") local Hooks = require("src.mods.Hooks") local Manifest = require("src.mods.Manifest") +local Semver = require("src.mods.Semver") local Logger = require("src.core.Logger") local Version = require("src.core.Version") local Runtime = require("src.mods.Runtime") @@ -141,8 +142,8 @@ check(manifest.id == "test_mod" and manifest.path == "mods/test_mod", "manifest validation") check(type(Version.engine) == "string" - and Version.engine:match("^%d+%.%d+%.%d+$") ~= nil, - "engine version is a semver triple") + and Semver.parse(Version.engine) ~= nil, + "engine version parses as a semver (triple, optionally with a pre-release)") check(Version.modApi == 2, "mod api version is 2") check(Version.title("X") == "X v" .. Version.engine, "window title carries the engine version") diff --git a/tests/mod_ui_tests.lua b/tests/mod_ui_tests.lua index 0b40ac6b..1f92c6c3 100644 --- a/tests/mod_ui_tests.lua +++ b/tests/mod_ui_tests.lua @@ -423,6 +423,38 @@ check(#pm.subItems == 2, "a non-table submenu result keeps the vanilla list") hooks:removeOwner("bad") pm.submenu = nil +-- ------- battle PKMN: SWITCH / STATS / CANCEL (#180) +local switched +local bgame = partyGame() +local bpm = PartyMenu.new(bgame, { + battle = {}, + onSwitch = function(mon) switched = mon end, +}) +bpm.game = bgame +bgame.stack:push(bpm) +press(bpm, "a") +check(bpm.submenu and #bpm.subItems == 3 + and bpm.subItems[1].label == "SWITCH" + and bpm.subItems[2].label == "STATS" + and bpm.subItems[3].label == "CANCEL", + "voluntary battle party opens SWITCH/STATS/CANCEL") +press(bpm, "a") -- SWITCH +check(switched == bgame.save.party[1], "battle SWITCH hands the mon to onSwitch") +check(bgame.stack:top() ~= bpm, "battle SWITCH closes the party menu") + +local forced +local fgame = partyGame() +local fpm = PartyMenu.new(fgame, { + battle = {}, + forceSwitch = true, + onSwitch = function(mon) forced = mon end, +}) +fpm.game = fgame +fgame.stack:push(fpm) +press(fpm, "a") +check(not fpm.submenu and forced == fgame.save.party[1], + "forceSwitch still picks immediately (ChooseNextMon / SHIFT)") + -- ------- mod.ui helpers and theme defaults local items = { { label = "A" }, { label = "B" } } ModUI.insertAfter(items, "A", { label = "X" }) @@ -440,9 +472,9 @@ check(type(ModUI.push) == "function", "mod.ui.push opens screens") check(Theme.cursor == 0xED and Theme.cursorHollow == 0xEC and Theme.moreArrow == 0xEE, "theme defaults are the old literals") -check(Theme.choiceBox.tx == 0 and Theme.choiceBox.ty == 7 +check(Theme.choiceBox.tx == 14 and Theme.choiceBox.ty == 7 and Theme.choiceBox.tw == 6 and Theme.choiceBox.th == 5, - "choice box geometry keeps its vanilla tiles") + "choice box geometry keeps its vanilla tiles (InitYesNoTextBoxParameters hlcoord 14,7)") Theme.load({ field = { theme = { cursor = 0xAA } } }) check(Theme.cursor == 0xAA, "field.theme restyles the cursor glyph") Theme.cursor = 0xED diff --git a/tests/parity_A.lua b/tests/parity_A.lua index 09c6786d..7c8bd473 100644 --- a/tests/parity_A.lua +++ b/tests/parity_A.lua @@ -312,10 +312,15 @@ do local Renderer = require("src.render.Renderer") local SaveData = require("src.core.SaveData") local OW = require("src.world.OverworldController") - local realTB = package.loaded["src.render.TextBox"] - package.loaded["src.render.TextBox"] = { - new = function(game, text, done) return { text = text, onDone = done } end, - } + local function stackedDialogue() + local top = Game.stack:top() + if not (top and top.pages) then return "" end + local parts = {} + for _, page in ipairs(top.pages) do + parts[#parts + 1] = table.concat(page, "\n") + end + return table.concat(parts, "\n") + end Game.data = Data Game.input = Input; Input:init() Game.renderer = Renderer; Renderer:init() @@ -328,10 +333,17 @@ do Game.stack:push(OW, "PEWTER_GYM", 4, 13, "up") local ow = Game.stack:top() ow:checkVictoryRewards("OPP_BROCK", 1) + local brockText = stackedDialogue() check(Game.save.flags.EVENT_BEAT_BROCK, "Brock victory sets EVENT_BEAT_BROCK") check(Game.save.inventory.BOULDERBADGE == 1, "Brock victory awards BOULDERBADGE") check(Game.save.flags.EVENT_BEAT_PEWTER_GYM_TRAINER_0, "Brock victory deactivates the Pewter Gym Cooltrainer") + check(brockText:find("I took", 1, true), + "Brock victory dialogue starts with ReceivedBoulderBadge speech") + check(brockText:find("FLASH", 1, true), + "Brock victory dialogue includes BoulderBadge FLASH explanation") + check(brockText:find("BIDE", 1, true), + "Brock victory dialogue includes TM34 BIDE explanation") local pewterNpc for _, npc in ipairs(ow.npcs) do if npc.def.index == 2 then pewterNpc = npc break end @@ -348,6 +360,19 @@ do and Game.save.objectToggles.ROUTE_22.ROUTE22_RIVAL1 == false, "Brock victory hides ROUTE22_RIVAL1") + while Game.stack:top() do Game.stack:pop() end + Game.save = SaveData.newGame() + Game.save.flags = {} + Game.save.inventory = {} + Game.save.defeatedTrainers = {} + Game.stack:push(OW, "CERULEAN_GYM", 4, 10, "up") + ow = Game.stack:top() + ow:checkVictoryRewards("OPP_MISTY", 1) + local mistyText = stackedDialogue() + check(mistyText:find("CUT", 1, true), + "Misty victory dialogue includes CascadeBadge CUT explanation") + check(Game.save.inventory.CASCADEBADGE == 1, "Misty victory awards CASCADEBADGE") + while Game.stack:top() do Game.stack:pop() end Game.save = SaveData.newGame() Game.save.flags = {} @@ -368,7 +393,6 @@ do "unfought Cinnabar trainer is defeated via seeded header event") while Game.stack:top() do Game.stack:pop() end - package.loaded["src.render.TextBox"] = realTB end S.finish() diff --git a/tests/parity_F.lua b/tests/parity_F.lua index 691375e5..18f440fb 100644 --- a/tests/parity_F.lua +++ b/tests/parity_F.lua @@ -67,6 +67,8 @@ check(Game.save.party[1].nickname == "AAAAAAAAAA", "starter nickname prompt accepted (AskName / #137)") -- === 2) the parcel/pokedex beat still doesn't grant POKé BALLs === +-- OaksLabOak1Text.got_parcel requires EVENT_BATTLED_RIVAL_IN_OAKS_LAB +Flags.set(Game.save, "EVENT_BATTLED_RIVAL_IN_OAKS_LAB") check(runScript(mapScripts.talkScript("VIRIDIAN_MART", "TEXT_VIRIDIANMART_CLERK")), "mart clerk script completes") eq(Game.save.inventory.OAKS_PARCEL, 1, "clerk hands over Oak's Parcel") @@ -84,8 +86,8 @@ check(labToggles and labToggles.OAKSLAB_POKEDEX2 == false, "POKEDEX2 hidden after receiving Pokédex") eq(Game.save.inventory.POKE_BALL, nil, "still no POKe BALLs at the pokedex beat") --- talking to Oak again before beating the Route 22 rival should fall --- into the RaiseYourYoungPokemon branch, not give balls +-- talking to Oak again before beating the Route 22 rival is the +-- .mon_around_the_world branch (not the poke-ball grant) check(runScript(mapScripts.talkScript("OAKS_LAB", "TEXT_OAKSLAB_OAK1")), "Oak talk (pre-Route22-win) script completes") eq(Game.save.inventory.POKE_BALL, nil, "still no POKe BALLs before the Route 22 rival is beaten") diff --git a/tests/parity_ai_switch_faint.lua b/tests/parity_ai_switch_faint.lua index db128f57..da66bdbb 100644 --- a/tests/parity_ai_switch_faint.lua +++ b/tests/parity_ai_switch_faint.lua @@ -111,4 +111,62 @@ do eq(b.afterQueue, "finish", "victory finishes after the queue") end +-- #162: SHIFT style with 2+ party slots announces the next mon and +-- offers a free switch (TrainerAboutToUseText + YES/NO). Party count +-- gates the offer (pokered wPartyCount), not living-HP count — a fainted +-- reserve still unlocks the prompt. +do + local Game = freshGame() + Game.save.options.battleStyle = "shift" + local reserve = Pokemon.new(Data, "SQUIRTLE", 40) + reserve.hp = 0 + table.insert(Game.save.party, reserve) + local b = BattleState.newTrainer(Game, "OPP_YOUNGSTER", 1) + check(#b.enemyParty >= 2, "youngster has a second mon") + b.enemyParty[1].hp = 0 + b.enemyIndex = 1 + b.enemy.mon = b.enemyParty[1] + b.participants = { [Game.save.party[1]] = true } + b:enemyMonFainted() + local about, willChoice, sent = false, false, false + local n = 0 + while #b.queue > 0 and n < 200 do + n = n + 1 + local item = table.remove(b.queue, 1) + if item.fn then + b.nextInsert = 0 + item.fn() + elseif item.text then + if item.text:find("about to use", 1, true) then about = true end + if item.text:find("change POKéMON", 1, true) and item.choice then + willChoice = true + item.choice(false) -- decline; enemy still sends out + end + if item.text:find("sent", 1, true) then sent = true end + end + end + check(about, "SHIFT announces trainer about-to-use") + check(willChoice, "SHIFT queues Will-change with YES/NO choice") + check(sent, "enemy send-out still follows after declining") + check(b.enemy.mon.hp > 0, "second enemy mon is out after the offer") +end + +-- SET style (and single-mon parties) skip the offer entirely. +do + local Game = freshGame() + Game.save.options.battleStyle = "set" + table.insert(Game.save.party, Pokemon.new(Data, "SQUIRTLE", 40)) + local b = BattleState.newTrainer(Game, "OPP_YOUNGSTER", 1) + b.enemyParty[1].hp = 0 + b.enemyIndex = 1 + b.enemy.mon = b.enemyParty[1] + b.participants = { [Game.save.party[1]] = true } + b:enemyMonFainted() + local about = false + for _, item in ipairs(b.queue) do + if item.text and item.text:find("about to use", 1, true) then about = true end + end + check(not about, "SET style skips about-to-use") +end + S.finish() diff --git a/tests/parity_cerulean_rocket.lua b/tests/parity_cerulean_rocket.lua new file mode 100644 index 00000000..9aaa076a --- /dev/null +++ b/tests/parity_cerulean_rocket.lua @@ -0,0 +1,74 @@ +-- Parity: Cerulean TM28 Rocket HideObject fade (#170). +-- +-- pokered CeruleanCityRocketText .Success → PrintText (ReceivedTM28 + +-- IBetterGetMoving) → farcall CeruleanHideRocket: +-- GBFadeOutToBlack → Show GUARD1 / Hide GUARD2 / Hide ROCKET → +-- GBFadeInFromBlack. +-- +-- Self-contained; run via `luajit tests/parity_cerulean_rocket.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("parity cerulean rocket") +local check, eq = S.check, S.eq + +local story5 = dofile("data/scripts/story5.lua") +local rows = story5.CERULEAN_CITY.talk.TEXT_CERULEANCITY_ROCKET +check(rows ~= nil, "Cerulean Rocket talk script exists") + +local function find(verb, arg) + for i, row in ipairs(rows) do + if row[1] == verb and (arg == nil or row[2] == arg) then + return i, row + end + end +end + +local iMoving = find("show_text", "_CeruleanCityRocketIBetterGetMovingText") +local iFadeOut = find("fade", "out") +local iGuard1 = find("show_object", "CERULEAN_CITY") +local iGuard2 = find("hide_object", "CERULEAN_CITY") +local iRocket, rocketRow +for i, row in ipairs(rows) do + if row[1] == "hide_object" and row[3] == "CERULEANCITY_ROCKET" then + iRocket, rocketRow = i, row + break + end +end +local iFadeIn = find("fade", "in") + +check(iMoving, "prints IBetterGetMovingText before hide") +check(iFadeOut and iFadeOut == iMoving + 1, + "GBFadeOutToBlack follows IBetterGetMovingText") +check(iGuard1 and rows[iGuard1][3] == "CERULEANCITY_GUARD1" + and iGuard1 > iFadeOut, + "ShowObject GUARD1 runs under the black fade") +check(iGuard2 and rows[iGuard2][3] == "CERULEANCITY_GUARD2" + and iGuard2 > iGuard1, + "HideObject GUARD2 follows GUARD1 (pokered CeruleanHideRocket order)") +check(iRocket and iRocket > iGuard2 and iRocket < iFadeIn, + "HideObject ROCKET runs while screen is black") +eq(rocketRow[2], "CERULEAN_CITY", "Rocket hide targets CERULEAN_CITY") +check(iFadeIn and iFadeIn == iRocket + 1, + "GBFadeInFromBlack follows the three object toggles") + +-- loss must not land on a hide/show row +local iBattleJump +for _, row in ipairs(rows) do + if row[1] == "jump_if_false" then iBattleJump = row break end +end +check(iBattleJump and iBattleJump[2] == "end", + "battle loss jumps to end (not into CeruleanHideRocket)") + +-- GOT_TM28 re-entry jumps at the fade (idempotent HideRocket) +local iGotJump +for _, row in ipairs(rows) do + if row[1] == "jump_if_true" then iGotJump = row break end +end +check(iGotJump and iGotJump[2] == iFadeOut, + "EVENT_GOT_TM28 jumps to CeruleanHideRocket fade-out") + +local ScriptRunner = require("src.script.ScriptRunner") +local problems = ScriptRunner.validate(rows) +eq(#problems, 0, "Rocket script validates: " .. table.concat(problems, "; ")) + +S.finish() diff --git a/tests/parity_daycare.lua b/tests/parity_daycare.lua index eb4fa328..c11d70fb 100644 --- a/tests/parity_daycare.lua +++ b/tests/parity_daycare.lua @@ -180,5 +180,43 @@ do eq(game.save.daycare.mon.level, 5, "decline restores mon.level to depositLevel") end +-- === 6) skipped level-up moves granted on retrieve (#184 / WriteMonMoves) === +do + local game = newGame() + local mon = boardAtLevel(game, "MAGIKARP", 13, "FISHY") + game.save.daycare.depositLevel = 13 + local hasTackle = false + for _, mv in ipairs(mon.moves) do + if mv.id == "TACKLE" then hasTackle = true break end + end + check(not hasTackle, "Magikarp Lv13 does not already know Tackle") + game.save.daycare.steps = stepsForLevels("MAGIKARP", 13, 6) -- → 19 + choiceAnswer = true + check(talk(game), "Magikarp retrieve talk completes") + local back = game.save.party[1] + eq(back.level, 19, "Magikarp raised 13→19 on retrieve") + local moveIds = {} + for _, mv in ipairs(back.moves) do moveIds[#moveIds + 1] = mv.id end + local joined = table.concat(moveIds, ",") + check(joined:find("TACKLE", 1, true), + "Magikarp learns Tackle at 15 via daycare skip (#184): " .. joined) + check(joined:find("SPLASH", 1, true), "Splash retained alongside Tackle") +end + +-- === 7) decline does not teach skipped moves === +do + local game = newGame() + local mon = boardAtLevel(game, "MAGIKARP", 13) + game.save.daycare.depositLevel = 13 + game.save.daycare.steps = stepsForLevels("MAGIKARP", 13, 6) + choiceAnswer = false + talk(game) + local hasTackle = false + for _, mv in ipairs(game.save.daycare.mon.moves) do + if mv.id == "TACKLE" then hasTackle = true break end + end + check(not hasTackle, "decline leaves moves unchanged (no Tackle yet)") +end + package.loaded["src.render.TextBox"] = realTextBox S.finish() diff --git a/tests/parity_marowak.lua b/tests/parity_marowak.lua index d3d957ec..bcfa9c2b 100644 --- a/tests/parity_marowak.lua +++ b/tests/parity_marowak.lua @@ -24,7 +24,10 @@ local S = require("tests.harness").suite("parity ghost marowak") local check, eq = S.check, S.eq -- Real TextBoxes want a Font atlas; the decision under test is which --- branch runs, not how the text renders. +-- branch runs, not how the text renders. Both stubs are restored at the +-- bottom for the suites run_tests.lua chains after this file. +local realTextBox = package.loaded["src.render.TextBox"] +local realBattleState = package.loaded["src.battle.BattleState"] package.loaded["src.render.TextBox"] = { new = function(_, text, done) return { text = text, done = done } end, } @@ -158,4 +161,7 @@ do eq(0, #moved, "no scripted step on a loss") end +package.loaded["src.render.TextBox"] = realTextBox +package.loaded["src.battle.BattleState"] = realBattleState + S.finish() diff --git a/tests/parity_rival_walkoff.lua b/tests/parity_rival_walkoff.lua new file mode 100644 index 00000000..2c78a864 --- /dev/null +++ b/tests/parity_rival_walkoff.lua @@ -0,0 +1,148 @@ +-- Parity: Route 22 + Cerulean rival post-battle exits (#154 / #168). +-- +-- pokered Route22Rival{1,2}ExitMovementData* and CeruleanCityMovement3/4 +-- plus CeruleanCityRivalText's Bill line after EVENT_BEAT_CERULEAN_RIVAL. +-- +-- luajit tests/parity_rival_walkoff.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("parity rival walkoff") +local check, eq = S.check, S.eq + +-- restored at the bottom for the suites run after this file +local realMusic = package.loaded["src.core.Music"] +local realCommands = package.loaded["src.script.Commands"] +local realPicBox = package.loaded["src.ui.PicBox"] +package.loaded["src.core.Music"] = { + play = function() end, playOnce = function() return true end, stop = function() end, +} +package.loaded["src.script.Commands"] = { + hide_object = function() end, +} +package.loaded["src.ui.PicBox"] = { new = function() return {} end } + +local story5 = dofile("data/scripts/story5.lua") +local story = dofile("data/scripts/story.lua") + +local function dirsEqual(a, b) + if type(a) ~= "table" or #a ~= #b then return false end + for i = 1, #b do if a[i] ~= b[i] then return false end end + return true +end + +local function findWalk(rows) + for _, r in ipairs(rows) do + if r[1] == "walk_npc" then return r end + end +end + +local function texts(rows) + local out = {} + for _, r in ipairs(rows) do + if r[1] == "show_text" then out[#out + 1] = r[2] end + end + return out +end + +local function capture(mapMod, game, x, y) + local rows + local ow = { + runner = { + isRunning = function() return false end, + run = function(_, r) rows = r end, + }, + player = { facing = "down" }, + npcByIndex = function() return { def = { name = "X" } } end, + } + check(mapMod.onStep(game, ow, x, y), + ("onStep fires at (%d,%d)"):format(x, y)) + check(rows ~= nil, "onStep queued rows") + return rows +end + +-- Route22Rival1ExitMovementData1 / Data2 +local R1_Y5 = { "right", "right", "down", "down", "down", "down", "down" } +local R1_Y4 = { "up", "right", "right", "right", + "down", "down", "down", "down", "down", "down" } +-- Route22Rival2ExitMovementData1 falls through Data2 on y=5 +local R2_Y5 = { "left", "left", "left", "left" } +local R2_Y4 = { "left", "left", "left" } +local CER_X20 = { "right", "down", "down", "down", "down", "down", "down" } +local CER_X21 = { "left", "down", "down", "down", "down", "down", "down" } + +do + local game = { save = { flags = { EVENT_GOT_POKEDEX = true } }, data = {} } + local w5 = findWalk(capture(story5.ROUTE_22, game, 29, 5)) + local w4 = findWalk(capture(story5.ROUTE_22, game, 29, 4)) + check(dirsEqual(w5[3], R1_Y5), "Rival1 y=5 exits toward Viridian (R,R,D×5)") + check(dirsEqual(w4[3], R1_Y4), "Rival1 y=4 exits U,R×3,D×6") + local rows = capture(story5.ROUTE_22, + { save = { flags = { EVENT_GOT_POKEDEX = true } }, data = {} }, 29, 5) + for _, r in ipairs(rows) do + check(not (r[1] == "move_npc_to" and r[3] == 25 and r[4] == 5), + "Rival1 must not retreat to spawn (25,5)") + end +end + +do + local flags = { + EVENT_BEAT_BROCK = true, + EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE = true, + EVENT_BEAT_GIOVANNI = true, + } + local w5 = findWalk(capture(story5.ROUTE_22, { save = { flags = flags }, data = {} }, 29, 5)) + local w4 = findWalk(capture(story5.ROUTE_22, { save = { flags = flags }, data = {} }, 29, 4)) + check(dirsEqual(w5[3], R2_Y5), "Rival2 y=5 exits left×4 toward League") + check(dirsEqual(w4[3], R2_Y4), "Rival2 y=4 exits left×3 toward League") +end + +do + local rows = capture(story5.CERULEAN_CITY, { save = { flags = {} }, data = {} }, 20, 6) + local t = texts(rows) + local hasBill = false + for _, id in ipairs(t) do + if id == "_CeruleanCityRivalIWentToBillsText" then hasBill = true end + end + check(hasBill, "Cerulean post-battle includes Bill dialogue") + local order = { + "_CeruleanCityRivalPreBattleText", + "_CeruleanCityRivalDefeatedText", + "_CeruleanCityRivalIWentToBillsText", + } + local pi = 1 + for _, id in ipairs(t) do + if pi <= #order and id == order[pi] then pi = pi + 1 end + end + eq(pi, #order + 1, "Cerulean text order: pre, defeated, Bill") + check(dirsEqual(findWalk(rows)[3], CER_X20), + "Cerulean x=20 exit: right then into town") + for _, r in ipairs(rows) do + check(not (r[1] == "move_npc_to" and r[4] == 2), + "Cerulean must not walk back north to (20,2)") + end +end + +do + local rows = capture(story5.CERULEAN_CITY, { save = { flags = {} }, data = {} }, 21, 6) + check(dirsEqual(findWalk(rows)[3], CER_X21), + "Cerulean x=21 exit: left then into town") +end + +do + local talk = story.CERULEAN_CITY.talk.TEXT_CERULEANCITY_RIVAL + check(talk ~= nil, "talk script for CERULEANCITY_RIVAL exists") + local hasBill = false + for _, r in ipairs(talk) do + if r[1] == "show_text" and r[2] == "_CeruleanCityRivalIWentToBillsText" then + hasBill = true + end + end + check(hasBill, "talk path also exposes Bill dialogue") +end + +package.loaded["src.core.Music"] = realMusic +package.loaded["src.script.Commands"] = realCommands +package.loaded["src.ui.PicBox"] = realPicBox + +S.finish() diff --git a/tests/parity_ss_anne_guard.lua b/tests/parity_ss_anne_guard.lua index 5b115b80..23b36524 100644 --- a/tests/parity_ss_anne_guard.lua +++ b/tests/parity_ss_anne_guard.lua @@ -15,6 +15,16 @@ if not _G.love then _G.love = require("tests.love_stub") end local S = require("tests.harness").suite("parity ss anne guard") local check, eq = S.check, S.eq +-- restored at the bottom for the suites run after this file +local realTextBox = package.loaded["src.render.TextBox"] +local realMusic = package.loaded["src.core.Music"] +local realSound = package.loaded["src.core.Sound"] +-- Commands captures TextBox at module load; drop any already-loaded copy +-- so the script runner below binds the stub, not the real renderer +local realCommands = package.loaded["src.script.Commands"] +local realScriptRunner = package.loaded["src.script.ScriptRunner"] +package.loaded["src.script.Commands"] = nil +package.loaded["src.script.ScriptRunner"] = nil package.loaded["src.render.TextBox"] = { new = function(_, text, done) return { text = text, done = done } end, } @@ -196,4 +206,10 @@ do check(found, "captain script plays Music_PkmnHealed after the rub") end +package.loaded["src.render.TextBox"] = realTextBox +package.loaded["src.core.Music"] = realMusic +package.loaded["src.core.Sound"] = realSound +package.loaded["src.script.Commands"] = realCommands +package.loaded["src.script.ScriptRunner"] = realScriptRunner + S.finish() diff --git a/tests/parity_ss_anne_rooms.lua b/tests/parity_ss_anne_rooms.lua new file mode 100644 index 00000000..29095e65 --- /dev/null +++ b/tests/parity_ss_anne_rooms.lua @@ -0,0 +1,110 @@ +-- Parity: S.S. Anne 1F door→room contents match pokered; after layout +-- patch, L→R hallway doors also land in rooms-map reading order (#189). +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 ss anne rooms") +local check, eq = S.check, S.eq + +local maps = dofile("data/generated/maps.lua") +local SsAnneLayout = require("src.world.SsAnneLayout") +local Warp = require("src.world.Warp") + +-- OG occupants behind each hallway door, left → right (pokered + guides) +local OG_BY_DOOR = { + { "SSANNE1FROOMS_GENTLEMAN3" }, + { "SSANNE1FROOMS_YOUNGSTER", "SSANNE1FROOMS_COOLTRAINER_F", + "SSANNE1FROOMS_GIRL2", "SSANNE1FROOMS_TM_BODY_SLAM" }, + { "SSANNE1FROOMS_MIDDLE_AGED_MAN", "SSANNE1FROOMS_LITTLE_GIRL", + "SSANNE1FROOMS_WIGGLYTUFF" }, + { "SSANNE1FROOMS_GIRL1" }, + { "SSANNE1FROOMS_GENTLEMAN2" }, + { "SSANNE1FROOMS_GENTLEMAN1" }, +} + +local function copyMaps() + local ok, ser = pcall(require, "serpent") + if ok and ser and ser.dump and ser.load then + return ser.load(ser.dump(maps)) + end + -- deep-ish copy of the Anne maps only + local function deep(t) + if type(t) ~= "table" then return t end + local o = {} + for k, v in pairs(t) do o[k] = deep(v) end + return o + end + return { + SS_ANNE_1F = deep(maps.SS_ANNE_1F), + SS_ANNE_1F_ROOMS = deep(maps.SS_ANNE_1F_ROOMS), + } +end + +local function roomDoors(hall) + local doors = {} + for i, w in ipairs(hall.warps) do + if w.destMap == "SS_ANNE_1F_ROOMS" then + doors[#doors + 1] = { index = i, w = w } + end + end + table.sort(doors, function(a, b) return a.w.x < b.w.x end) + return doors +end + +local function objsAt(rooms, x, y) + local names = {} + local col, row = math.floor(x / 10), math.floor(y / 10) + for _, o in ipairs(rooms.objects) do + if math.floor(o.x / 10) == col and math.floor(o.y / 10) == row then + names[#names + 1] = o.name + end + end + table.sort(names) + return names +end + +local function hasAll(got, want) + local set = {} + for _, n in ipairs(got) do set[n] = true end + for _, n in ipairs(want) do + if not set[n] then return false, n end + end + return true +end + +-- raw ROM extract order: leftmost door → rooms warp 6 +do + local doors = roomDoors(maps.SS_ANNE_1F) + eq(doors[1].w.destWarp, 6, "raw extract: leftmost door → rooms #6") + eq(doors[#doors].w.destWarp, 1, "raw extract: rightmost door → rooms #1") +end + +local m = copyMaps() +check(SsAnneLayout.apply(m) == true, "layout patch applies once") +check(SsAnneLayout.apply(m) == false, "layout patch is idempotent") + +local data = { maps = m } +local doors = roomDoors(m.SS_ANNE_1F) +eq(#doors, 6, "six cabin doors") + +local prevCell +for i, d in ipairs(doors) do + eq(d.w.destWarp, i, ("L→R door %d → rooms warp %d"):format(i, i)) + local mid, x, y = Warp.destination(data, d.w, nil) + eq(mid, "SS_ANNE_1F_ROOMS", "door lands in 1F rooms") + local cell = math.floor(y / 10) * 3 + math.floor(x / 10) + if prevCell then + check(cell > prevCell, + ("door %d cell %d is after door %d cell %d"):format(i, cell, i - 1, prevCell)) + end + prevCell = cell + local got = objsAt(m.SS_ANNE_1F_ROOMS, x, y) + local ok, missing = hasAll(got, OG_BY_DOOR[i]) + check(ok, ("door %d keeps OG occupant %s (got %s)") + :format(i, tostring(missing), table.concat(got, ","))) +end + +-- return warps: rooms #1 exits to leftmost hall cabin door (warp 8) +eq(m.SS_ANNE_1F_ROOMS.warps[1].destWarp, 8, "rooms #1 returns to hall #8") +eq(m.SS_ANNE_1F_ROOMS.warps[6].destWarp, 3, "rooms #6 returns to hall #3") + +S.finish() diff --git a/tests/parity_trade_gift.lua b/tests/parity_trade_gift.lua index 340c4fb7..ab0383c4 100644 --- a/tests/parity_trade_gift.lua +++ b/tests/parity_trade_gift.lua @@ -254,11 +254,29 @@ shownIs({}, "old save: silent") eq(#Game.save.party, 0, "old save: no Eevee re-gift") eq(toggleOf(EEVEE_MAP, EEVEE_BALL), false, "old save: leftover ball hidden") --- === 11) GivePokemon failure (party + every box full): BoxIsFullText, +-- === 11) GivePokemon party full, box has room: AskName + SentToBoxText +-- (SendNewMonToBox), then script GotMonText; ball hidden === +Game.save = SaveData.newGame() +for i = 1, 6 do Game.save.party[i] = Pokemon.new(Data, "PIDGEY", 5) end +check(runScript(EEVEE_MAP, EEVEE_TEXT), "full-party Eevee script completes") +shownIs({ "_DoYouWantToNicknameText", "_SentToBoxText", "_GotMonText" }, + "full-party gift: nickname, sent-to-box, then GotMonText") +eq(#Game.save.party, 6, "full-party gift leaves party size at 6") +local Boxes = require("src.pokemon.Boxes") +local boxed = false +for _, box in ipairs(Boxes.ensure(Game.save)) do + for _, m in ipairs(box) do + if m.species == "EEVEE" then boxed = true end + end +end +check(boxed, "full-party Eevee lands in a box") +check(Flags.get(Game.save, "EVENT_GOT_EEVEE"), "full-party gift still sets flag") +eq(toggleOf(EEVEE_MAP, EEVEE_BALL), false, "full-party gift hides the ball") + +-- === 12) GivePokemon failure (party + every box full): BoxIsFullText, -- ball stays, flag unset -- the gift stays claimable === Game.save = SaveData.newGame() for i = 1, 6 do Game.save.party[i] = Pokemon.new(Data, "PIDGEY", 5) end -local Boxes = require("src.pokemon.Boxes") Boxes.ensure(Game.save) for b = 1, Boxes.COUNT do for s = 1, Boxes.CAPACITY do Game.save.boxes[b][s] = { species = "PIDGEY" } end @@ -268,7 +286,7 @@ shownIs({ "_BoxIsFullText" }, "full party+box shows BoxIsFullText") check(not Flags.get(Game.save, "EVENT_GOT_EEVEE"), "full party+box leaves flag unset") eq(toggleOf(EEVEE_MAP, EEVEE_BALL), nil, "full party+box leaves the ball visible") --- === 12) SS Anne rival ambush guard (scripts/SSAnne2F.asm: the +-- === 13) SS Anne rival ambush guard (scripts/SSAnne2F.asm: the -- wSSAnne2FCurScript NOOP progression <-> the port's -- EVENT_BEAT_SS_ANNE_RIVAL flag) === local ss = require("data.scripts.story5").SS_ANNE_2F diff --git a/tests/parity_trainer_sight.lua b/tests/parity_trainer_sight.lua index 9dbb9ee7..99128a52 100644 --- a/tests/parity_trainer_sight.lua +++ b/tests/parity_trainer_sight.lua @@ -218,4 +218,78 @@ do check(ow3.engaging, "distance 3 on the upper plateau still engages") end +-- === (5) off-screen sprites never engage (CheckSpriteAvailability) === +-- TrainerEngage bails when IMAGEINDEX=$ff. The 8-bit screen-pixel distance +-- wraps so a trainer ~12/28 tiles away can look in-range; the GB 10x9 window +-- (player±4/±5) is what stops that (GitHub #153 / #183). +local VF_BUG_CATCHER = 2 -- ViridianForest Youngster2 at (30,33) LEFT range 4 + +local function freshForest(px, py, facing) + while Game.stack:top() do Game.stack:pop() end + Game.save = SaveData.newGame() + Input:init() + OW.engaging = false + OW.emote = nil + Game.stack:push(OW, "VIRIDIAN_FOREST", px, py, facing) + local ow = Game.stack:top() + local trainer + for _, npc in ipairs(ow.npcs) do + if npc.def.index == VF_BUG_CATCHER then trainer = npc end + end + return ow, trainer +end + +do + -- (18,33) is walkable west of the tree wall; dx=12 to (30,33) wraps + -- the 8-bit pixel distance into range-4, but the sprite is off the GB screen. + local ow, trainer = freshForest(18, 33, "right") + check(trainer ~= nil and trainer.cellX == 30 and trainer.cellY == 33, + "Viridian Forest Bug Catcher stands at (30,33)") + eq(trainer.facing, "left", "Bug Catcher faces left") + local header = Data:trainerHeader("ViridianForest", VF_BUG_CATCHER) + eq(header and header.range, 4, "Bug Catcher sight range is 4") + + for _ = 1, 10 do frame(ow) end + check(not ow.engaging, + "through-trees tile (dx=12 wrap) must not engage: sprite off GB screen") + check(trainer.cellX == 30 and trainer.cellY == 33, + "off-screen trainer must not walk up through trees") + + -- same corridor as the trainer, distance 4, on the GB screen: must engage + local ow2, t2 = freshForest(26, 33, "right") + local guard = 0 + while not ow2.engaging and guard < 10 do guard = guard + 1; frame(ow2) end + check(ow2.engaging and t2.cellX == 30, + "distance 4 on-screen still engages (real sight line)") +end + +-- Victory Road 2F Hiker at (12,9) LEFT range 4: far west same row wraps +local VR_HIKER = 1 + +local function freshVR2(px, py, facing) + while Game.stack:top() do Game.stack:pop() end + Game.save = SaveData.newGame() + Input:init() + OW.engaging = false + OW.emote = nil + Game.stack:push(OW, "VICTORY_ROAD_2F", px, py, facing) + local ow = Game.stack:top() + local trainer + for _, npc in ipairs(ow.npcs) do + if npc.def.index == VR_HIKER then trainer = npc end + end + return ow, trainer +end + +do + local ow, trainer = freshVR2(0, 9, "right") + check(trainer ~= nil and trainer.cellX == 12 and trainer.cellY == 9, + "Victory Road 2F Hiker stands at (12,9)") + for _ = 1, 10 do frame(ow) end + check(not ow.engaging, + "Victory Road far same-row (dx=12 wrap) must not engage through walls") + check(trainer.cellX == 12 and trainer.cellY == 9, + "off-screen Victory Road trainer must stay put") +end + S.finish() diff --git a/tests/rom_importer_android_pick_test.lua b/tests/rom_importer_android_pick_test.lua new file mode 100644 index 00000000..fc0a77f5 --- /dev/null +++ b/tests/rom_importer_android_pick_test.lua @@ -0,0 +1,110 @@ +-- #167: Android second-ROM Choose must open the picker (or import a pending +-- opposite cart), not re-import a leftover picked_rom.gb from the first game. +-- Self-contained: `luajit tests/rom_importer_android_pick_test.lua`; also +-- dofile'd by tests/run_tests.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("rom importer android pick") +local eq = S.eq +local check = S.check + +local GameVersion = require("src.core.GameVersion") +local RomImporter = require("src.import.RomImporter") + +local MiB = 1024 * 1024 +local redData = string.rep("R", MiB) +local blueData = string.rep("B", MiB) + +-- Map our fake 1 MiB blobs onto the real Red/Blue SHA-1 ids without needing +-- a crypto library in the headless stub. +love.data = love.data or {} +love.system = love.system or {} +-- the love stub is shared with every later suite in run_tests.lua; +-- restore whatever this file overrides +local saved = { + hash = love.data.hash, + encode = love.data.encode, + getSaveDirectory = love.filesystem.getSaveDirectory, + getOS = love.system.getOS, + pickFile = love.system.pickFile, +} +love.data.hash = function(_, data) + return { tag = data:sub(1, 1) } +end +love.data.encode = function(_, _, digest) + if type(digest) == "table" and digest.tag == "R" then + return GameVersion.info("red").sha1 + end + if type(digest) == "table" and digest.tag == "B" then + return GameVersion.info("blue").sha1 + end + return "0000000000000000000000000000000000000000" +end + +love.filesystem.getSaveDirectory = function() return "/tmp/pokemon-love2d" end + +local pickCalls = 0 +love.system.getOS = function() return "Android" end +love.system.pickFile = function() + pickCalls = pickCalls + 1 + return true +end + +local function freshImporter(ready) + return setmetatable({ + android = true, + workState = nil, + ready = { + red = ready.red and true or false, + blue = ready.blue and true or false, + }, + notice = nil, + chooseVersion = nil, + startData = function(self, data, displayName) + self._started = { data = data, name = displayName } + end, + }, RomImporter) +end + +-- Stale Red pick still on disk, Red already ready → Choose Blue opens picker. +love.filesystem.write("picked_rom.gb", redData) +pickCalls = 0 +local ri = freshImporter({ red = true, blue = false }) +ri:choose("blue") +eq(pickCalls, 1, "choose opens picker when only an already-imported ROM remains") +eq(ri._started, nil, "choose does not re-import the stale Red pick") + +-- Same leftover must not re-import on focus either (picker cancelled). +ri = freshImporter({ red = true, blue = false }) +ri:focus(true) +eq(ri._started, nil, "focus ignores already-imported leftover picked_rom.gb") + +-- A pending Blue cart in the save dir imports without needing the picker. +love.filesystem.write("pokemon_blue.gb", blueData) +pickCalls = 0 +ri = freshImporter({ red = true, blue = false }) +ri:choose("blue") +eq(pickCalls, 0, "choose imports a pending opposite ROM from the save dir") +check(ri._started ~= nil, "choose started import for pending Blue") +eq(ri._started.name, "pokemon_blue.gb", "choose picked the pending Blue file") +eq(ri._started.data, blueData, "choose passed Blue ROM bytes") + +-- focus after SAF overwrite: pending Blue becomes importable. +love.filesystem.write("picked_rom.gb", blueData) +ri = freshImporter({ red = true, blue = false }) +ri:focus(true) +check(ri._started ~= nil, "focus imports pending Blue after picker returns") +eq(ri._started.name, "picked_rom.gb", "focus reads the SAF drop filename") + +-- Cleanup so other suites sharing the stub do not see leftover .gb files +-- or this file's function stubs. +love.filesystem.remove("picked_rom.gb") +love.filesystem.remove("pokemon_blue.gb") +love.data.hash = saved.hash +love.data.encode = saved.encode +love.filesystem.getSaveDirectory = saved.getSaveDirectory +love.system.getOS = saved.getOS +love.system.pickFile = saved.pickFile + +S.finish() diff --git a/tests/run_tests.lua b/tests/run_tests.lua index 5c56b70d..cf641279 100644 --- a/tests/run_tests.lua +++ b/tests/run_tests.lua @@ -89,6 +89,36 @@ local pages = TextBox.paginate("I'm raising\nPOKéMON too!\fWhen they get\nstron eq(#pages, 2, "two pages") eq(#pages[1], 2, "page 1 has two lines") eq(#pages[2], 3, "page 2 keeps scrolled line") +eq(pages.contBefore[1][1], false, "page1 line1 is not cont") +eq(pages.contBefore[1][2], false, "page1 line2 via \\n has no wait") +eq(pages.contBefore[2][3], true, "page2 line3 via \\v waits (cont)") +-- #163: ContText must set waiting + ▼ before scrolling the next line +do + local pressed = { a = true } + local box = TextBox.new({ + input = { + wasPressed = function(_, k) return pressed[k] end, + isDown = function() return false end, + }, + save = { options = { textSpeed = 1 }, player = { name = "RED" } }, + data = {}, + stringBuffer = "", + stack = { pop = function() end, push = function() end }, + }, "AAA\nBBB\vCCC") + pressed.a = false + for _ = 1, 80 do + if box.waiting or box.done then break end + box:update(0) + end + check(box.waiting and box.contAdvance, + "cont boundary waits for A with contAdvance") + check(not box.done, "cont wait is not the final done prompt") + eq(box.lineIndex, 2, "cont wait stays on the finished line until A") + pressed.a = true + box:update(0) + check(not box.waiting and not box.contAdvance, "A clears cont wait") + eq(box.lineIndex, 3, "A advances into the cont line") +end -- ---------------------------------------------------------------- stats & growth local Stats = require("src.pokemon.Stats") @@ -239,6 +269,14 @@ local r3 = ItemEffects.use(Data, save, "TM_TOXIC", pikachu) check(r3 == "learn", "Pikachu can learn Toxic (in tmhm list)") local r4 = ItemEffects.use(Data, save, "HM_SURF", pikachu) eq(r4, "failed", "Pikachu can't learn Surf") +do + local rBattle, oakMsg = ItemEffects.use(Data, save, "TM_TOXIC", hurt, {}) + eq(rBattle, "failed", "TM refuses mid-battle (ItemUseNotTime)") + check(oakMsg and oakMsg[1] and oakMsg[1]:find("isn't the", 1, true), + "TM mid-battle Oak text") + eq(ItemEffects.use(Data, save, "HM_SURF", pikachu, {}), "failed", + "HM refuses mid-battle") +end local r5, _, extra = ItemEffects.use(Data, save, "THUNDER_STONE", pikachu) eq(r5, "consumed", "Thunder Stone works on Pikachu") eq(extra.evolveTo, "RAICHU", "Thunder Stone evolves Pikachu to Raichu") @@ -494,7 +532,7 @@ local function runScript(script) local r = ScriptRunner.new(Game, nil) r:run(script, {}) local guard = 0 - while r:isRunning() and guard < 2000 do + while r:isRunning() and guard < 4000 do guard = guard + 1 Input.pressed = { a = true } StateStack:update(1 / 60) @@ -505,6 +543,7 @@ local function runScript(script) end Flags.set(Game.save, "EVENT_GOT_STARTER") +Flags.set(Game.save, "EVENT_BATTLED_RIVAL_IN_OAKS_LAB") check(runScript(mapScripts.talkScript("VIRIDIAN_MART", "TEXT_VIRIDIANMART_CLERK")), "mart clerk script completes") eq(Game.save.inventory.OAKS_PARCEL, 1, "clerk hands over Oak's Parcel") @@ -768,6 +807,10 @@ eq(Data.field.darkMaps.maps[1], "ROCK_TUNNEL_1F", "Rock Tunnel is dark") check(Data.field.hiddenExtras.trashCans.adjacent[0][1] == 1, "trash can adjacency table extracted") check(#Data.field.hiddenExtras.trashCans.cans == 15, "15 Vermilion trash cans") +check(#(Data.field.hiddenExtras.printTrash.SS_ANNE_KITCHEN or {}) == 2, + "SS Anne kitchen PrintTrashText bins") +check(#(Data.field.hiddenExtras.printTrash.VERMILION_GYM or {}) == 1, + "Vermilion Gym PrintTrashText bin") local tf = io.open(Data.field.title.logo.path, "rb") check(tf ~= nil, "title logo asset exists") if tf then tf:close() end @@ -882,6 +925,35 @@ do eq(sbst.player.stages.attack, 1, "SHARPEN skips the accuracy roll") end + -- #169: HUD status (shownStatus) stays hidden through announce/anim/ + -- inflict text, then syncs after Execute*Move (DrawHUDsAndHPBars) + do + Game.save.party = { Pokemon.new(Data, "BULBASAUR", 10) } + local pb = BattleState.newWild(Game, "RATTATA", 5) + pb.rng = function(a, b) return a or 0 end -- always hit + pb.phase = "messages" + pb:executeAction(pb.player, pb.enemy, { id = "POISONPOWDER", pp = 10 }) + eq(pb.enemy.mon.status, "PSN", "POISONPOWDER sets mon.status immediately") + eq(pb.enemy.shownStatus, nil, "HUD status deferred until after move queue") + local sawPoisonText, synced = false, false + local steps = 0 + while steps < 4000 and not synced do + steps = steps + 1 + if pb.enemy.shownStatus == "PSN" then synced = true; break end + if not sawPoisonText and pb.current and pb.current.text + and pb.current.text:find("poisoned", 1, true) then + sawPoisonText = true + eq(pb.enemy.shownStatus, nil, + "PSN icon still deferred while poisoned text shows") + end + Input.pressed = { a = true } + if not pb:updateQueue() then break end + Input.pressed = {} + end + check(sawPoisonText, "poisoned text appeared before HUD sync") + eq(pb.enemy.shownStatus, "PSN", "HUD status syncs after move completes") + end + -- HandleIfPlayerMoveMissed: skip PlayMoveAnimation on a miss -- (unless EXPLODE_EFFECT) do @@ -1256,6 +1328,23 @@ do "_ItemUseBallText08 before meeting Bill") check(not hasText(cb2, "New POKéDEX data"), "no dex page for an already-owned species") + -- #172: SendNewMonToBox still runs AskName before the PC text + local hasNickUi = false + for _, it in ipairs(cb2.queue) do + if it.ui then + local ui = it.ui() + if ui and ui.pages then + for _, page in ipairs(ui.pages) do + for _, line in ipairs(page) do + if type(line) == "string" and line:lower():find("nickname", 1, true) then + hasNickUi = true + end + end + end + end + end + end + check(hasNickUi, "full-party catch still asks for a nickname (#172)") Game.save.flags.EVENT_MET_BILL = true local cb3 = BattleState.newWild(Game, "EKANS", 5) cb3:storeCaughtMon() @@ -1269,7 +1358,9 @@ do tb.enemyIndex = #tb.enemyParty tb.enemy = { mon = tb.enemyParty[#tb.enemyParty], def = tb.enemy.def, name = tb.enemy.name, isPlayer = false } - tb.enemy.mon.hp = 0 + -- the whole team must be down: enemyMonFainted scans every slot for a + -- replacement (EnemySendOutFirstMon / AnyEnemyPokemonAliveCheck) + for _, m in ipairs(tb.enemyParty) do m.hp = 0 end tb:enemyMonFainted() check(hasText(tb, ("%s defeated\n%s!"):format(Game.save.player.name, tb.trainer.name)), @@ -1946,8 +2037,9 @@ end -- ---------------------------------------------------------------- heal machine cadence -- AnimateHealingMachine (engine/overworld/healing_machine.asm): one ball -- per party mon every 30 frames (SFX_HEALING_MACHINE each), then the --- healed jingle while the machine flashes 8 times (10 frames a toggle), --- then a 32-frame beat after the jingle ends. +-- healed jingle while the machine flashes 8 times (10 frames a toggle). +-- #157: fighting-fit text follows the flash immediately (no .waitLoop2 / +-- DelayFrames 32 hold after the machine). do local OW = require("src.world.OverworldController") local ha = { balls = 3, lit = 0, timer = 0, visible = true } @@ -1958,32 +2050,26 @@ do local ev = OW.stepHealAnim(ha) if ha.visible ~= wasVisible then toggles = toggles + 1 end if ev then events[#events + 1] = frame .. ev end - if ev == "jingle" then ha.jingleDone = true end -- headless: no audio if ev == "done" then break end end end) check(ok, "stepHealAnim runs") eq(table.concat(events, ","), - "1ball,31ball,61ball,91jingle,203done", + "1ball,31ball,61ball,91jingle,171done", "heal machine: a ball per mon every 30 frames, jingle, flash, done") eq(toggles, 8, "machine sprites flash 8 times") check(ha.visible, "machine sprites end visible") - -- the wait phase holds until the jingle actually finishes + -- unfinished jingle must not delay "done" past the flash (#157) local ha2 = { balls = 1, lit = 0, timer = 0, visible = true } - local doneEarly = false + local doneAt pcall(function() - for _ = 1, 300 do - if OW.stepHealAnim(ha2) == "done" then doneEarly = true end + for frame = 1, 300 do + if OW.stepHealAnim(ha2) == "done" then doneAt = frame break end end end) - check(not doneEarly, "heal machine waits for the jingle to end") - ha2.jingleDone = true - local extra = 0 - pcall(function() - repeat extra = extra + 1 until OW.stepHealAnim(ha2) == "done" or extra > 100 - end) - eq(extra, 32, "32-frame beat after the jingle") + -- 1 ball @1, jingle @31, 8x10 flash -> done @111 + eq(doneAt, 111, "fighting fit is not gated on the jingle ending") end -- ---------------------------------------------------------------- HP bar right cap @@ -2213,8 +2299,12 @@ do cb4.onFinish = function() end cb4.rng = function(a, b) return a end -- rng low: guaranteed capture local origStart = cb4.startMessage + local ballAtCaughtText cb4.startMessage = function(s, item) log[#log + 1] = "text:" .. item.text:gsub("\n.*", "") + if item.text:find("All right!", 1, true) then + ballAtCaughtText = cb4.lockedBall and #cb4.lockedBall > 0 + end return origStart(s, item) end cb4.queue = {} @@ -2223,8 +2313,11 @@ do while steps < 4000 do steps = steps + 1 Input.pressed = { a = true } - if not cb4:updateQueue() then break end + -- the #172 nickname ui row parks the queue on waitingUI (the battle + -- is not on the stack here); everything under test has run by then + if cb4.waitingUI or not cb4:updateQueue() then break end end + if cb4.waitingUI then StateStack:pop() end -- the pushed nickname TextBox Input.pressed = {} Sound.play = origPlay local caughtAt, fanfareAt, fanfares, tinks = nil, nil, 0, 0 @@ -2244,7 +2337,9 @@ do check(fanfareAt and caughtAt and fanfareAt < caughtAt, "Caught_Mon sounds with the caught text, not after its dismissal") eq(cb4.result, "caught", "the capture resolved the battle") - check(cb4.lockedBall and #cb4.lockedBall > 0, + -- the nickname AskName that follows clears it (ClearSprites), so the + -- assertion is sampled while the caught text is up + check(ballAtCaughtText, "the resting closed ball stays compiled for the caught text") Game.save.party = savedParty end @@ -2940,6 +3035,7 @@ runSuites(orderedGlob("tests/mod_*.lua tests/modkit_tests.lua", { "tests/mod_constants_tests.lua", "tests/mod_catalog_tests.lua", "tests/mod_audio_tests.lua", "tests/mod_world_tests.lua", "tests/mod_battle_tests.lua", "tests/mod_graphics_tests.lua", + "tests/mod_render_tests.lua", "tests/mod_battle_scale_tests.lua", "tests/mod_scripting_tests.lua", "tests/mod_ui_tests.lua", "tests/mod_save_tests.lua", "tests/modkit_tests.lua", }, { @@ -2963,6 +3059,9 @@ runSuites({ "tests/input_hold_test.lua" }) -- ---------------------------------------------- launcher cursor (#114) runSuites({ "tests/rom_importer_cursor_test.lua" }) +-- ---------------------------------------------- Android second ROM pick (#167) +runSuites({ "tests/rom_importer_android_pick_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 diff --git a/tests/save_convert_tests.lua b/tests/save_convert_tests.lua new file mode 100644 index 00000000..94cf92b2 --- /dev/null +++ b/tests/save_convert_tests.lua @@ -0,0 +1,476 @@ +-- Gen1 save transformer tests (src/save_convert/GenSave.lua): a +-- round-trip of a fresh SaveData.newGame() save (no real save data +-- checked in as a fixture), plus targeted checks for the checksum +-- routine, the species/move/item/map crosswalks, and name encoding. +-- +-- Run: luajit tests/save_convert_tests.lua + +package.path = "./?.lua;" .. package.path +_G.love = require("tests.love_stub") + +local GenSave = require("src.save_convert.GenSave") +local SaveConvert = require("src.save_convert.SaveConvert") +local SaveData = require("src.core.SaveData") + +local checks, failures = 0, 0 +local function check(cond, msg) + checks = checks + 1 + if not cond then + failures = failures + 1 + print("FAIL: " .. msg) + end +end + +GenSave.setCharmap(loadfile("src/save_convert/data/charmap.lua")()) +local data = { + pokemon = loadfile("data/generated/pokemon.lua")(), + moves = loadfile("data/generated/moves.lua")(), + items = loadfile("data/generated/items.lua")(), + maps = loadfile("data/generated/maps.lua")(), + eventFlags = loadfile("src/save_convert/data/event_flags.lua")(), +} + +-- Independent re-implementation of CalcCheckSum (complement of the additive +-- byte sum) so the export scenarios below can verify all three SRAM checksums +-- straight off the emitted bytes, without trusting GenSave's own writer. +local bit = require("bit") +local OFF = GenSave.OFFSETS +local function rawChecksum(bytes, from, to) + local sum = 0 + for i = from, to - 1 do sum = bit.band(sum + bytes:byte(i + 1), 0xFF) end + return bit.band(bit.bnot(sum), 0xFF) +end +local function checksumValid(bytes, from, to, storeOff) + return rawChecksum(bytes, from, to) == bytes:byte(storeOff + 1) +end +-- A box bank (2 or 3) holds 6 box regions, one one-byte checksum each, plus a +-- bank aggregate computed over the ENTIRE six-box region (pokered +-- engine/menus/save.asm: CalcCheckSum over all 6 x 1122 bytes), independently +-- re-derived here so this test cannot inherit an encoder bug. +local function boxBankChecksumValid(bytes, bankBase, aggOff, indivOff) + for b = 0, 5 do + local base = bankBase + b * GenSave.BOX_REGION_SIZE + local c = rawChecksum(bytes, base, base + GenSave.BOX_REGION_SIZE) + if c ~= bytes:byte(indivOff + b + 1) then return false end + end + local agg = rawChecksum(bytes, bankBase, bankBase + 6 * GenSave.BOX_REGION_SIZE) + return agg == bytes:byte(aggOff + 1) +end + +-- ------------------------------------------------------------------ +-- crosswalks: one species/move/item/map roundtrip each +-- ------------------------------------------------------------------ + +local cw = GenSave.crosswalks(data) +check(cw.pokemonIndex.MEW == 21, "MEW's internal ROM index is 21 (the classic MissingNo fact)") +check(cw.pokemonByIndex[21] == "MEW", "index 21 resolves back to MEW") +check(cw.pokemonDex.MEW == 151, "MEW's national dex number is 151 (BaseStats[151])") +check(cw.pokemonByDex[151] == "MEW", "dex 151 resolves back to MEW") +check(cw.pokemonDex.BULBASAUR == 1, "BULBASAUR is dex #1") + +check(cw.movesByIndex[cw.movesIndex.THUNDERBOLT] == "THUNDERBOLT", + "a move id round-trips through its index") +check(cw.itemsByIndex[cw.itemsIndex.POKE_BALL] == "POKE_BALL", + "an item id round-trips through its index") +check(cw.itemsByIndex[cw.itemsIndex.TM_THUNDER_WAVE] == "TM_THUNDER_WAVE", + "a TM item (no `index` field, only machine.number) round-trips via its derived item id") +check(cw.mapsIndex.PALLET_TOWN == 0, "PALLET_TOWN is map index 0") +check(cw.mapsByIndex[0] == "PALLET_TOWN", "map index 0 resolves back to PALLET_TOWN") + +-- ------------------------------------------------------------------ +-- a fresh new-game save round-trips through encode -> decode +-- ------------------------------------------------------------------ + +local fresh = SaveData.newGame({ playerName = "RED", rivalName = "BLUE" }) +-- newGame's party/boxes are empty and its map is an interior with no +-- gen1-save equivalent tileset concerns -- exactly the baseline this +-- codec needs to handle cleanly with no real playthrough data at all. +local bytes = GenSave.encode(fresh, data, nil) +check(#bytes == GenSave.SAVE_SIZE, "encode() produces exactly 32768 bytes") + +local decoded = GenSave.decode(bytes, data) +check(#decoded.warnings == 0, "a freshly-encoded save passes its own checksum") +check(decoded.player.name == "RED", "player name round-trips") +check(decoded.player.rival == "BLUE", "rival name round-trips") +check(decoded.player.map == fresh.player.map, "spawn map round-trips (" .. + tostring(decoded.player.map) .. " vs " .. tostring(fresh.player.map) .. ")") +check(decoded.player.x == fresh.player.x and decoded.player.y == fresh.player.y, + "spawn position round-trips") +check(decoded.money == fresh.money, "money round-trips") +check(#decoded.party == 0, "an empty party stays empty") + +-- ------------------------------------------------------------------ +-- a populated save: party, boxes, badges, bag, pokedex, flags +-- ------------------------------------------------------------------ + +local save = SaveData.newGame({ playerName = "ASH", rivalName = "GARY" }) +save.player.id = 12345 +save.money = 3000 +save.coins = 50 +save.inventory = { POKE_BALL = 5, BOULDERBADGE = 1, ANTIDOTE = 1 } +save.bagOrder = { "POKE_BALL", "ANTIDOTE" } +save.pcItems = { REVIVE = 2 } +save.pokedex = { seen = { MEW = true, PIKACHU = true }, owned = { PIKACHU = true } } +save.flags = { EVENT_GOT_STARTER = true, EVENT_GOT_POKEDEX = true } +save.boxes = {} +save.party = { + { + species = "MEW", level = 100, exp = 1059860, + dvs = { hp = 13, attack = 15, defense = 11, speed = 12, special = 15 }, + statExp = { hp = 65535, attack = 65535, defense = 65535, speed = 65535, special = 65535 }, + stats = { hp = 399, attack = 298, defense = 290, speed = 292, special = 298 }, + hp = 399, status = nil, + moves = { { id = "TRANSFORM", pp = 16, ppUps = 3 }, { id = "MEGA_PUNCH", pp = 16, ppUps = 3 } }, + nickname = "MEW", ot = "LtAsh", otId = 55721, catchRate = 45, + }, +} +for i = 1, 12 do save.boxes[i] = {} end +save.boxes[3] = { { + species = "PIKACHU", level = 10, exp = 1000, + dvs = { hp = 1, attack = 2, defense = 3, speed = 4, special = 5 }, + statExp = { hp = 0, attack = 0, defense = 0, speed = 0, special = 0 }, + hp = 30, status = "PSN", + moves = { { id = "THUNDERSHOCK", pp = 30, ppUps = 0 } }, + nickname = "PIKA", ot = "ASH", otId = 12345, catchRate = 190, +} } +save.currentBox = 1 + +local bytes2 = GenSave.encode(save, data, nil) +local decoded2 = GenSave.decode(bytes2, data) +check(#decoded2.warnings == 0, "a populated save passes its own checksum") +check(decoded2.player.id == 12345, "player ID round-trips") +check(decoded2.money == 3000 and decoded2.coins == 50, "money and coins round-trip") +check(decoded2.inventory.BOULDERBADGE == 1, "a badge round-trips as a truthy inventory entry") +check(decoded2.inventory.POKE_BALL == 5 and decoded2.inventory.ANTIDOTE == 1, + "bag items round-trip") +check(decoded2.inventory.POKE_BALL and not decoded2.pcItems.POKE_BALL, + "bag items don't leak into PC storage") +check(decoded2.pcItems.REVIVE == 2, "PC items round-trip") +check(decoded2.pokedex.seen.MEW and decoded2.pokedex.seen.PIKACHU and decoded2.pokedex.owned.PIKACHU, + "pokedex seen/owned round-trip") +check(not decoded2.pokedex.owned.MEW, "a species only marked seen doesn't also come back owned") +check(decoded2.flags.EVENT_GOT_STARTER and decoded2.flags.EVENT_GOT_POKEDEX, + "event flags round-trip") + +local mon1 = decoded2.party[1] +check(mon1 and mon1.species == "MEW", "party mon species round-trips") +check(mon1 and mon1.level == 100 and mon1.exp == 1059860, "party mon level/exp round-trip") +check(mon1 and mon1.hp == 399 and mon1.stats and mon1.stats.hp == 399, + "party mon current HP and max HP stat round-trip") +check(mon1 and mon1.dvs.attack == 15 and mon1.dvs.speed == 12, "party mon DVs round-trip") +check(mon1 and mon1.moves[1].id == "TRANSFORM" and mon1.moves[1].pp == 16 + and mon1.moves[1].ppUps == 3, "party mon move/PP/PP-Up round-trip") +check(mon1 and mon1.nickname == "MEW" and mon1.otId == 55721, "party mon nickname/OT ID round-trip") +check(mon1 and mon1.ot == "LtAsh", + "an OT name containing a bracketed charmap token (\"\") round-trips as one unit, ".. + "not per-byte \"?\" (got " .. tostring(mon1 and mon1.ot) .. ")") + +local box3mon = decoded2.boxes[3][1] +check(box3mon and box3mon.species == "PIKACHU" and box3mon.status == "PSN", + "a boxed mon's species and status condition round-trip") +check(box3mon and box3mon.moves[1].id == "THUNDERSHOCK", "a boxed mon's move round-trips") +check(decoded2.currentBox == 1, "current box selection round-trips") + +-- ------------------------------------------------------------------ +-- checksum: a corrupted byte is detected on decode (warned, not thrown -- +-- decode() must still succeed on a foreign save with a bad checksum) +-- ------------------------------------------------------------------ + +local O = GenSave.OFFSETS +local corrupted = bytes2:sub(1, O.money) .. + string.char((bytes2:byte(O.money + 1) + 1) % 256) .. + bytes2:sub(O.money + 2) +local decodedCorrupt = GenSave.decode(corrupted, data) +check(#decodedCorrupt.warnings == 1, "a corrupted byte trips the checksum warning") + +-- ------------------------------------------------------------------ +-- Scenario 2 (always-on): engine-origin export. A save that never came +-- from a real cartridge -- SaveData.newGame() plus a small party, bag, +-- PC and badge set built through the documented save shape -- must +-- encode with NO template into a structurally valid 32768-byte SRAM +-- image: exact size, all three SRAM checksums valid, and a clean +-- re-import that reproduces party / items / badges / name. (The vendor +-- gen1lib parse of this same image is exercised out-of-band under Lua +-- 5.4, since gen1lib cannot even be loaded by luajit.) +-- ------------------------------------------------------------------ + +local eng = SaveData.newGame({ playerName = "OAK", rivalName = "BLUE" }) +eng.money = 1234 +eng.inventory = { POTION = 3, POKE_BALL = 10, THUNDERBADGE = 1 } +eng.bagOrder = { "POTION", "POKE_BALL" } +eng.pcItems = { REVIVE = 1, FULL_RESTORE = 2 } +eng.pcOrder = { "REVIVE", "FULL_RESTORE" } +eng.party = { + { + species = "CHARMANDER", level = 5, exp = 135, + dvs = { hp = 8, attack = 9, defense = 10, speed = 11, special = 12 }, + statExp = { hp = 0, attack = 0, defense = 0, speed = 0, special = 0 }, + stats = { hp = 20, attack = 11, defense = 10, speed = 12, special = 11 }, + hp = 20, status = nil, + moves = { { id = "SCRATCH", pp = 35, ppUps = 0 }, { id = "GROWL", pp = 40, ppUps = 0 } }, + nickname = "CHAR", ot = "OAK", otId = eng.player.id, catchRate = 45, + }, + { + species = "PIDGEY", level = 4, exp = 64, + dvs = { hp = 1, attack = 2, defense = 3, speed = 4, special = 5 }, + statExp = { hp = 0, attack = 0, defense = 0, speed = 0, special = 0 }, + stats = { hp = 18, attack = 9, defense = 9, speed = 10, special = 8 }, + hp = 18, status = nil, + moves = { { id = "TACKLE", pp = 35, ppUps = 0 } }, + nickname = "PIDGE", ot = "OAK", otId = eng.player.id, catchRate = 255, + }, +} + +local engBytes = GenSave.encode(eng, data, nil) -- NO template: pure engine origin +check(#engBytes == GenSave.SAVE_SIZE, "engine-origin: encode is exactly 32768 bytes") +check(checksumValid(engBytes, OFF.checksumStart, OFF.checksumEnd, OFF.mainChecksum), + "engine-origin: main data checksum valid") +check(boxBankChecksumValid(engBytes, OFF.box1, OFF.boxBank2Checksum, OFF.boxBank2IndividualChecksums), + "engine-origin: bank 2 box checksums valid") +check(boxBankChecksumValid(engBytes, OFF.box7, OFF.boxBank3Checksum, OFF.boxBank3IndividualChecksums), + "engine-origin: bank 3 box checksums valid") + +local engDec = GenSave.decode(engBytes, data) +check(#engDec.warnings == 0, "engine-origin: re-import passes its own checksum") +check(engDec.player.name == "OAK", "engine-origin: player name reproduces") +check(#engDec.party == 2, "engine-origin: party size reproduces (got " .. #engDec.party .. ")") +check(engDec.party[1] and engDec.party[1].species == "CHARMANDER" and engDec.party[1].level == 5, + "engine-origin: party[1] species/level reproduce") +check(engDec.party[2] and engDec.party[2].species == "PIDGEY" + and engDec.party[2].moves[1] and engDec.party[2].moves[1].id == "TACKLE", + "engine-origin: party[2] species/move reproduce") +check(engDec.inventory.POTION == 3 and engDec.inventory.POKE_BALL == 10, + "engine-origin: bag items reproduce") +check(engDec.pcItems.REVIVE == 1 and engDec.pcItems.FULL_RESTORE == 2, + "engine-origin: PC items reproduce") +check(engDec.inventory.THUNDERBADGE == 1, "engine-origin: badge reproduces as an inventory entry") +check(not engDec.pcItems.THUNDERBADGE, "engine-origin: badge doesn't leak into PC items") +-- expose the image for the out-of-band gen1lib parse (scenario 2 oracle) +do local w = io.open("/tmp/engine_origin.sav", "wb"); if w then w:write(engBytes); w:close() end end + +-- ------------------------------------------------------------------ +-- SaveConvert: the runtime-facing module (moved paths + shared merge). +-- SaveConvert loads its OWN crosswalk data through `require` (the same +-- src/core/Data.lua pattern), independent of the `data` table above, so +-- these checks also prove the moved src/save_convert/{GenSave,data/*} paths +-- resolve. bytes2 (a valid populated image built earlier) is the input. +-- ------------------------------------------------------------------ + +local scSave, scErr = SaveConvert.importSav(bytes2, 2) +check(scSave ~= nil, "SaveConvert.importSav returns a save table (" .. tostring(scErr) .. ")") +check(scSave and scSave.player and scSave.player.id == 12345, + "SaveConvert.importSav: decoded player fields survive the merge") +check(scSave and scSave.inventory and scSave.inventory.POKE_BALL == 5, + "SaveConvert.importSav: decoded bag items survive the merge") +-- merge over new-game defaults +check(scSave and type(scSave.options) == "table" and scSave.options.ruleset == "gen1_faithful", + "SaveConvert.importSav: new-game default options merged in") +check(scSave and type(scSave.defeatedTrainers) == "table" and type(scSave.modData) == "table" + and scSave.repelSteps == 0, + "SaveConvert.importSav: default defeatedTrainers/modData/repelSteps merged in") +-- version tag +check(scSave and scSave.meta and scSave.meta.version == 2, + "SaveConvert.importSav: save is tagged with the requested version") +-- derived heal/outdoor anchors +check(scSave and scSave.lastHeal and scSave.lastHeal.map == scSave.player.map, + "SaveConvert.importSav: lastHeal derives from the decoded position") +check(scSave and scSave.lastOutdoor and scSave.lastOutdoor.id ~= nil, + "SaveConvert.importSav: lastOutdoor is set") +-- the import template + decode warnings never leak into the slot table +check(scSave and scSave.rawImport == nil and scSave.warnings == nil, + "SaveConvert.importSav: rawImport/warnings stripped from the returned table") + +-- size / type validation +local badSize, badSizeErr = SaveConvert.importSav("too short", 2) +check(badSize == nil and type(badSizeErr) == "string", + "SaveConvert.importSav: rejects a wrong-size input with an error") +local nilIn, nilInErr = SaveConvert.importSav(nil, 2) +check(nilIn == nil and type(nilInErr) == "string", + "SaveConvert.importSav: rejects a non-string input with an error") + +-- checksum validation: flip a modeled byte so the stored checksum no longer +-- matches -> importSav must reject (GenSave.decode alone only warns). +local scCorrupt = bytes2:sub(1, OFF.money) .. + string.char((bytes2:byte(OFF.money + 1) + 1) % 256) .. + bytes2:sub(OFF.money + 2) +local scBad, scBadErr = SaveConvert.importSav(scCorrupt, 2) +check(scBad == nil and type(scBadErr) == "string" and tostring(scBadErr):find("checksum"), + "SaveConvert.importSav: rejects a bad-checksum save with a checksum error") + +-- exportSav zero-fill path: a merged import table carries no template, so the +-- export must still be a structurally valid 32768-byte image. +local scOut, scOutErr = SaveConvert.exportSav(scSave) +check(scOut ~= nil and #scOut == GenSave.SAVE_SIZE, + "SaveConvert.exportSav: produces exactly 32768 bytes (" .. tostring(scOutErr) .. ")") +check(scOut and checksumValid(scOut, OFF.checksumStart, OFF.checksumEnd, OFF.mainChecksum), + "SaveConvert.exportSav: main data checksum valid on a templateless export") +local scRt = SaveConvert.importSav(scOut, 2) +check(scRt and scRt.party[1] and scRt.party[1].species == "MEW", + "SaveConvert import -> export -> import round-trips the party") +check(scRt and scRt.inventory.BOULDERBADGE == 1, + "SaveConvert round-trip preserves a badge") + +-- exportSav bad input +local scNilOut, scNilOutErr = SaveConvert.exportSav("not a table") +check(scNilOut == nil and type(scNilOutErr) == "string", + "SaveConvert.exportSav: rejects a non-table input with an error") + +-- exportSav template-aware path: a table still carrying the stashed import +-- template reproduces the source's UNMODELED regions byte-for-byte. Poke a +-- sentinel into the sprite-buffer region (inside the checksum window but not +-- written by encode), decode straight through GenSave (which keeps rawImport), +-- and confirm it survives on export while a templateless export zero-fills it. +local spriteOff = OFF.spriteData + 10 +local sentinel = 0xAB +local templateSrc = bytes2:sub(1, spriteOff) .. string.char(sentinel) .. bytes2:sub(spriteOff + 2) +local tmplSave = GenSave.decode(templateSrc, data) -- rawImport = templateSrc +local tmplOut, tmplErr = SaveConvert.exportSav(tmplSave) +check(tmplOut ~= nil and #tmplOut == GenSave.SAVE_SIZE, + "SaveConvert.exportSav: template-aware export is 32768 bytes (" .. tostring(tmplErr) .. ")") +check(tmplOut and tmplOut:byte(spriteOff + 1) == sentinel, + "SaveConvert.exportSav: template-aware export carries an unmodeled region byte through") +check(tmplOut and checksumValid(tmplOut, OFF.checksumStart, OFF.checksumEnd, OFF.mainChecksum), + "SaveConvert.exportSav: template-aware export still writes a valid checksum") +check(scOut and scOut:byte(spriteOff + 1) == 0, + "SaveConvert.exportSav: templateless export zero-fills the same unmodeled region") + +-- SaveConvert.loadData exposes the shared crosswalk set (require-loaded). +local scData = SaveConvert.loadData() +check(type(scData) == "table" and type(scData.pokemon) == "table" + and type(scData.eventFlags) == "table", + "SaveConvert.loadData: returns the crosswalk data set via require") + +-- ------------------------------------------------------------------ +-- Real-save import audit (fixture-gated). POKEPORT_SAV_FIXTURE must point +-- at a readable 32768-byte battery save (a personal .sav never checked in); +-- when it's unset or unusable the whole block skips with one notice, so the +-- suite stays green on any machine. When present, the full import is run and +-- audited for plausibility -- the same checks convert.lua's output has to +-- satisfy for SaveData.load to accept it without quarantine. +-- ------------------------------------------------------------------ + +local fixturePath = os.getenv("POKEPORT_SAV_FIXTURE") +local fixtureBytes +if fixturePath then + local ff = io.open(fixturePath, "rb") + if ff then + fixtureBytes = ff:read("*a") + ff:close() + if #fixtureBytes ~= GenSave.SAVE_SIZE then fixtureBytes = nil end + end +end + +if not fixtureBytes then + print("fixture checks skipped (set POKEPORT_SAV_FIXTURE to a 32KB .sav to run them)") +else + local rs = GenSave.decode(fixtureBytes, data) + + check(type(rs.player.name) == "string" and #rs.player.name > 0, + "fixture: player name decodes non-empty") + + local badges = 0 + for bit0 = 0, 7 do + local names = { "BOULDERBADGE", "CASCADEBADGE", "THUNDERBADGE", "RAINBOWBADGE", + "SOULBADGE", "MARSHBADGE", "VOLCANOBADGE", "EARTHBADGE" } + if rs.inventory[names[bit0 + 1]] then badges = badges + 1 end + end + check(badges >= 0 and badges <= 8, "fixture: badge count in 0..8 (got " .. badges .. ")") + + check(type(rs.money) == "number" and rs.money >= 0 and rs.money <= 999999, + "fixture: money is a sane BCD value (got " .. tostring(rs.money) .. ")") + check(type(rs.coins) == "number" and rs.coins >= 0 and rs.coins <= 9999, + "fixture: coins is a sane BCD value (got " .. tostring(rs.coins) .. ")") + + check(#rs.party >= 1 and #rs.party <= 6, + "fixture: party holds 1..6 mons (got " .. #rs.party .. ")") + for i, mon in ipairs(rs.party) do + check(data.pokemon[mon.species] ~= nil, + "fixture: party mon " .. i .. " has a known species (" .. tostring(mon.species) .. ")") + check(mon.level >= 2 and mon.level <= 100, + "fixture: party mon " .. i .. " level in 2..100 (got " .. tostring(mon.level) .. ")") + check(#mon.moves >= 1 and #mon.moves <= 4, + "fixture: party mon " .. i .. " has 1..4 moves (got " .. #mon.moves .. ")") + for _, mv in ipairs(mon.moves) do + check(data.moves[mv.id] ~= nil, + "fixture: party mon " .. i .. " move is known (" .. tostring(mv.id) .. ")") + check(mv.pp >= 0 and mv.pp <= 63, + "fixture: party mon " .. i .. " move PP in 0..63 (got " .. tostring(mv.pp) .. ")") + end + check(type(mon.exp) == "number" and mon.exp > 0, + "fixture: party mon " .. i .. " has nonzero EXP") + end + + local owned, seen = 0, 0 + for _ in pairs(rs.pokedex.owned) do owned = owned + 1 end + for _ in pairs(rs.pokedex.seen) do seen = seen + 1 end + check(owned <= seen and seen <= 151, + "fixture: pokedex owned <= seen <= 151 (owned " .. owned .. ", seen " .. seen .. ")") + + check(rs.player.map ~= nil and data.maps[rs.player.map] ~= nil, + "fixture: current map resolves to a real map id (" .. tostring(rs.player.map) .. ")") + check(type(rs.player.x) == "number" and type(rs.player.y) == "number" + and rs.player.x >= 0 and rs.player.y >= 0, + "fixture: player position is in-bounds non-negative") + + local boxed = 0 + for b = 1, 12 do boxed = boxed + #rs.boxes[b] end + check(boxed >= 0 and boxed <= 12 * 20, + "fixture: boxed mon count within 12 boxes x 20 (got " .. boxed .. ")") + + local nflags = 0 + for _ in pairs(rs.flags) do nflags = nflags + 1 end + check(nflags > 0, "fixture: at least one event flag populated (got " .. nflags .. ")") + + -- play time (mapped from wPlayTimeHours/Minutes/Seconds/Frames into + -- save.playTime seconds): a real playthrough has a positive clock, and + -- it must round-trip through encode() back to the same H:M:S:F. + check(type(rs.playTime) == "number" and rs.playTime > 0, + "fixture: play time decodes to a positive second count (got " + .. tostring(rs.playTime) .. ")") + local rtBytes = GenSave.encode(rs, data, fixtureBytes) + local rt = GenSave.decode(rtBytes, data) + check(math.abs(rt.playTime - rs.playTime) < 1e-6, + "fixture: play time round-trips through encode()") + check(#rt.warnings == 0, "fixture: re-encoded save passes its own checksum") + + -- Scenario 1 (fixture-gated): full export fidelity of the real save. + check(#rtBytes == GenSave.SAVE_SIZE, "fixture export: exactly 32768 bytes") + check(checksumValid(rtBytes, OFF.checksumStart, OFF.checksumEnd, OFF.mainChecksum), + "fixture export: main data checksum valid") + check(boxBankChecksumValid(rtBytes, OFF.box1, OFF.boxBank2Checksum, OFF.boxBank2IndividualChecksums), + "fixture export: bank 2 box checksums valid") + check(boxBankChecksumValid(rtBytes, OFF.box7, OFF.boxBank3Checksum, OFF.boxBank3IndividualChecksums), + "fixture export: bank 3 box checksums valid") + -- Byte-for-byte fidelity with the original as template: every byte GenSave + -- emits must reproduce the source EXCEPT the derived integrity bytes (the + -- main checksum and the two 7-byte box-bank checksum footers). Zero content + -- diffs proves both that every modeled region re-encodes identically AND + -- that the template carries each unmodeled region (sprite buffers, Hall of + -- Fame, Day Care, options, connection cache, ...) through untouched. A + -- tampered source can carry stale box checksums; the export rewrites them to + -- valid values, which is why the checksum bytes are the only exemptions. + local exempt = {} + exempt[OFF.mainChecksum] = true + for b = 0, 6 do exempt[OFF.boxBank2Checksum + b] = true end + for b = 0, 6 do exempt[OFF.boxBank3Checksum + b] = true end + local contentDiffs = 0 + for i = 0, GenSave.SAVE_SIZE - 1 do + if not exempt[i] and rtBytes:byte(i + 1) ~= fixtureBytes:byte(i + 1) then + contentDiffs = contentDiffs + 1 + end + end + check(contentDiffs == 0, + "fixture export: modeled + template-preserved bytes reproduce the source " + .. "byte-for-byte (got " .. contentDiffs .. " unexpected diffs)") + -- Expose the export so crosscheck.lua can be reused as the vendor oracle: + -- POKEPORT_SAV_FIXTURE=/tmp/roundtrip.sav lua tools/save_convert/crosscheck.lua + -- confirms gen1lib parse_save accepts these exported bytes and agrees. + do local w = io.open("/tmp/roundtrip.sav", "wb"); if w then w:write(rtBytes); w:close() end end + + print(("fixture audit OK: name=%s badges=%d money=%d party=%d boxed=%d dex=%d/%d play=%dh%02dm"):format( + rs.player.name, badges, rs.money, #rs.party, boxed, owned, seen, + math.floor(rs.playTime / 3600), math.floor(rs.playTime / 60) % 60)) +end + +print(string.format("save convert: %d/%d checks passed", checks - failures, checks)) +if failures > 0 then os.exit(1) end diff --git a/tools/extract/charmap.py b/tools/extract/charmap.py new file mode 100644 index 00000000..9857bec1 --- /dev/null +++ b/tools/extract/charmap.py @@ -0,0 +1,93 @@ +"""Extract the Gen1 text byte <-> glyph/token charmap. + +Source: pokered/constants/charmap.asm -- `charmap "TOKEN", $NN` lines (one +byte value per token; TOKEN is a printable glyph for ordinary characters, +or a bracketed control token like ""/"@" for terminators and +runtime substitutions). This is the encoding fixed-length name fields +(sPlayerName, sRivalName, party/box OT names, nicknames) use -- NOT the +same thing as data/generated/text.lua, which holds already-decoded +dialogue strings extracted from asm source text and never needed a raw +byte<->glyph table of its own. + +Several byte VALUES are deliberately reused across different on-screen +graphics contexts later in the file (font_extra.png bold letters, then +font_battle_extra.png, then misc one-off glyphs, THEN the real A-Z table +at $80-$99, which unused Japanese katakana entries further down redefine +again at the same range) -- legal for RGBDS charmap (it only needs +token->byte to be unambiguous for encoding source text; nothing in this +English-only source ever assembles the literal token "ア", so its +redefinition is inert for the actual ROM). For our purposes it means: +- byToken[token] = byte is unambiguous either way (last-definition-wins, + matching RGBDS's own semantics), used to ENCODE a name. +- byByte[byte] = token needs the FIRST definition of each byte, not the + last, to DECODE a byte back to the international glyph that's actually + in the shipped font at that position instead of a later vestigial + redefinition (confirmed against the file: Latin "A".."Z" at $80-$99 are + defined once, early, cleanly, before later `charmap "ア", $80` etc. + entries that reuse those same bytes for characters this ROM's font + never draws there). + +Output: src/save_convert/data/charmap.lua (committed; independent of ROM +import, like data/palettes_gbc.lua) + byByte[byte] = token (first definition per byte -- see above) + byToken[token] = byte (last definition per token -- see above) +""" + +import argparse +import os +import re +import sys + +from . import util + + +def extract(pokered, out_path): + path = os.path.join(pokered, "constants/charmap.asm") + by_byte = {} + by_token = {} + for lineno, line in util.read_asm(path): + s = line.strip() + m = re.match(r'charmap\s+"((?:[^"\\]|\\.)*)"\s*,\s*(\S+)', s) + if not m: + continue + token = m.group(1) + value = util.parse_number(m.group(2)) + if not (0 <= value <= 255): + util.die(f"{path}:{lineno}: byte value {value} out of range for {token!r}") + if value not in by_byte: # first definition wins for decoding + by_byte[value] = token + by_token[token] = value # last definition wins for encoding + + if not by_byte: + util.die(f"{path}: parsed 0 charmap entries") + + util.write_lua( + out_path, + {"source": "pokered constants/charmap.asm", + "byByte": by_byte, + "byToken": by_token}, + header="Gen1 text byte <-> glyph/token charmap (fixed-length name\n" + "fields: player/rival/OT names, nicknames -- box/party mon\n" + "names are NUL-free, '@' ($50) terminated, space-padded).\n" + "byToken's key is the literal glyph for ordinary characters\n" + "(\"A\", \"é\", ...) or a bracketed control token\n" + "(\"\", \"@\") for terminators/substitutions -- only\n" + "the plain single-glyph entries are meaningful inside a name.") + return by_byte, by_token + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + parser.add_argument("--pokered", required=True) + parser.add_argument("--out", default="src/save_convert/data/charmap.lua") + args = parser.parse_args(argv) + extract(args.pokered, args.out) + return 0 + + +if __name__ == "__main__": + if __package__ is None: + sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + __package__ = "extract" + from extract import util as util # noqa: F811 + raise SystemExit(main()) diff --git a/tools/extract/event_flags.py b/tools/extract/event_flags.py new file mode 100644 index 00000000..86b7d4e1 --- /dev/null +++ b/tools/extract/event_flags.py @@ -0,0 +1,117 @@ +"""Extract wEventFlags' bit-index -> EVENT_* name table. + +Source: pokered/constants/event_constants.asm -- an RGBDS const block using +`const_def` / `const NAME` / `const_skip N` / `const_next N` (jump the +counter to an absolute bit position; N is usually `$hex` but occasionally +a small arithmetic expression like `$F0 - 2`). This is NOT the same shape +tools/extract/util.py's parse_const_block handles (that one only knows +const_def/const/const_skip), so this file gets its own small tracker +rather than stretching a shared helper to fit one caller. + +wEventFlags (ram/wram.asm) is a flat NUM_EVENTS-bit array; each EVENT_* +constant IS its bit index. NUM_EVENTS is set by the file's own trailing +`const_next $A00` (2560 bits = 320 bytes), matched by `flag_array +NUM_EVENTS` at the wEventFlags declaration. + +Output: src/save_convert/data/event_flags.lua (committed; independent of ROM +import, like data/palettes_gbc.lua) + byName[EVENT_NAME] = bit index (int) + byBit[bit index] = EVENT_NAME + count = total bit width of wEventFlags (NUM_EVENTS) +""" + +import argparse +import os +import re +import sys + +from . import util + + +def _eval_next(expr): + """`$XX` or `$XX - N` / `$XX + N` -> int.""" + m = re.match(r"^(\S+)\s*([+-])\s*(\S+)$", expr) + if m: + base = util.parse_number(m.group(1)) + n = util.parse_number(m.group(3)) + return base + n if m.group(2) == "+" else base - n + return util.parse_number(expr) + + +def extract(pokered, out_path): + path = os.path.join(pokered, "constants/event_constants.asm") + by_name = {} + by_bit = {} + value = None + count = None + for lineno, line in util.read_asm(path): + s = line.strip() + if not s: + continue + m = re.match(r"const_def(?:\s+(\S+))?$", s) + if m: + value = util.parse_number(m.group(1)) if m.group(1) else 0 + continue + m = re.match(r"const\s+(\w+)", s) + if m: + if value is None: + util.die(f"{path}:{lineno}: const before const_def") + name = m.group(1) + if name in by_name: + util.die(f"{path}:{lineno}: duplicate flag {name}") + by_name[name] = value + by_bit[value] = name + value += 1 + continue + m = re.match(r"const_skip(?:\s+(\S+))?$", s) + if m: + if value is None: + util.die(f"{path}:{lineno}: const_skip before const_def") + value += util.parse_number(m.group(1)) if m.group(1) else 1 + continue + m = re.match(r"const_next\s+(.+)$", s) + if m: + value = _eval_next(m.group(1).strip()) + continue + m = re.match(r"DEF\s+NUM_EVENTS\s+EQU\s+const_value\s*$", s) + if m: + if value is None: + util.die(f"{path}:{lineno}: NUM_EVENTS before any const_def") + count = value + continue + + if count is None: + util.die(f"{path}: NUM_EVENTS EQU const_value not found") + if count % 8 != 0: + util.die(f"{path}: NUM_EVENTS={count} is not byte-aligned") + if not by_name: + util.die(f"{path}: parsed 0 EVENT_* flags") + + util.write_lua( + out_path, + {"source": "pokered constants/event_constants.asm", + "count": count, + "byName": by_name, + "byBit": by_bit}, + header="wEventFlags bit index <-> EVENT_* name (see ram/wram.asm\n" + "wEventFlags, a flat NUM_EVENTS-bit / (NUM_EVENTS/8)-byte\n" + "array). byBit only has entries for bits with a name --\n" + "reserved/padding bits are intentionally absent.") + return by_name, by_bit, count + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + parser.add_argument("--pokered", required=True) + parser.add_argument("--out", default="src/save_convert/data/event_flags.lua") + args = parser.parse_args(argv) + extract(args.pokered, args.out) + return 0 + + +if __name__ == "__main__": + if __package__ is None: + sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + __package__ = "extract" + from extract import util as util # noqa: F811 + raise SystemExit(main()) diff --git a/tools/extract/field.py b/tools/extract/field.py index c3281a51..081372a3 100644 --- a/tools/extract/field.py +++ b/tools/extract/field.py @@ -6,7 +6,8 @@ Sources: data/tilesets/water_tilesets.asm -> tilesets where Surf works data/events/hidden_events.asm -> hidden items / coins / slot machines, PC tiles, bench guys, gym statues, - Vermilion Gym trash cans + PrintTrashText bins, Vermilion Gym + trash cans data/events/bench_guys.asm -> bench guy text per map data/events/slot_machine_wheels.asm -> the three slot wheel symbol lists data/events/card_key_{coords,maps}.asm + engine/events/card_key.asm @@ -117,15 +118,20 @@ def parse_hidden_events(pokered): StartSlotMachine (slot machine seats; arg SLOTS_* marks broken ones). Also collects the engine text hooks that the port implements natively: OpenPokemonCenterPC / OpenRedsPC (the player's storage PC in the - bedroom), PrintBenchGuyText, GymStatues and the Vermilion - Gym GymTrashScript cans (arg = [wGymTrashCanIndex]). For those the - fourth macro argument is the facing direction required to trigger the - event, except GymTrashScript where it is the can index. + bedroom), PrintBenchGuyText, GymStatues, PrintTrashText (plain + "only trash here" bins) and the Vermilion Gym GymTrashScript cans + (arg = [wGymTrashCanIndex]). For those the fourth macro argument is + the facing direction required to trigger the event, except + GymTrashScript where it is the can index and PrintTrashText which + stores a facing but ignores it. """ items = {} coins = {} slots = {} - extras = {"pcTiles": {}, "benchGuys": {}, "gymStatues": {}, "trashCans": []} + extras = { + "pcTiles": {}, "benchGuys": {}, "gymStatues": {}, + "printTrash": {}, "trashCans": [], + } current = None path = os.path.join(pokered, "data/events/hidden_events.asm") for lineno, line in read_asm(path): @@ -163,6 +169,9 @@ def parse_hidden_events(pokered): elif func == "GymStatues": extras["gymStatues"].setdefault(current, []).append( {"x": x, "y": y, "facing": DIRS.get(arg, arg)}) + elif func == "PrintTrashText": + extras["printTrash"].setdefault(current, []).append( + {"x": x, "y": y, "facing": DIRS.get(arg, arg)}) elif func == "GymTrashScript": if current != "VERMILION_GYM": util.die(f"hidden_events.asm:{lineno}: GymTrashScript outside VERMILION_GYM") @@ -1339,6 +1348,7 @@ def extract(pokered, out_dir): "pcTiles": extras["pcTiles"], "benchGuys": extras["benchGuys"], "gymStatues": extras["gymStatues"], + "printTrash": extras["printTrash"], "trashCans": parse_trash_can_puzzle(pokered, extras["trashCans"]), } @@ -1373,6 +1383,7 @@ def extract(pokered, out_dir): credits["theEnd"] = gfx.extract_the_end(pokered, assets_dir) battle_hud = gfx.extract_battle_hud(pokered, assets_dir) town_map["background"] = gfx.extract_town_map_bg(pokered, assets_dir) + trade_art = gfx.extract_trade(pokered, assets_dir) if not ledges or not cut_trees or "OVERWORLD" not in water or len(trades) < 8 \ or "PALLET_TOWN" not in fly_warps: @@ -1387,7 +1398,9 @@ def extract(pokered, out_dir): or len(hidden_extras["pcTiles"]) < 10 \ or "VIRIDIAN_GYM" not in hidden_extras["gymStatues"] \ or hidden_extras["benchGuys"].get("VIRIDIAN_POKECENTER", - [{}])[0].get("text") is None: + [{}])[0].get("text") is None \ + or len(hidden_extras["printTrash"].get("SS_ANNE_KITCHEN", [])) != 2 \ + or len(hidden_extras["printTrash"].get("VERMILION_GYM", [])) != 1: util.die("hidden extras extraction sanity check failed") if sorted(forced_movement["tiles"]) != ["ROUTE_16", "ROUTE_18", "SEAFOAM_ISLANDS_B3F", @@ -1499,6 +1512,7 @@ def extract(pokered, out_dir): "townMap": town_map, "credits": credits, "slotSymbols": slot_symbols, + "tradeArt": trade_art, "emotionBubbles": emotion_bubbles, "oakSpeech": oak_speech, "overworldFx": overworld_fx, @@ -1525,6 +1539,7 @@ def extract(pokered, out_dir): "+ gfx/credits/the_end.png,\n" "constants/script_constants.asm (SLOTS*, *_BUBBLE)\n" "+ gfx/slots/red_slots_{1,2}.png + gfx/emotes/*.png\n" + "+ gfx/trade/* (InternalClockTradeAnim),\n" "+ engine/{slots/slot_machine,overworld/emotion_bubbles}.asm,\n" "scripts/ViridianCity.asm + engine/battle/core.asm,\n" "scripts/GameCorner.asm + text/GameCorner.asm,\n" diff --git a/tools/extract/gfx.py b/tools/extract/gfx.py index e36a9f9a..6a75483b 100644 --- a/tools/extract/gfx.py +++ b/tools/extract/gfx.py @@ -809,3 +809,110 @@ def extract_town_map_bg(pokered, assets_dir): "source": "gfx/town_map/town_map.rle + town_map.png " "(engine/items/town_map.asm LoadTownMap)", } + + +# --------------------------------------------------------------------------- +# Trade animation graphics (engine/movie/trade.asm) +# --------------------------------------------------------------------------- + +def _unique_tiles(im, cols): + """Row-major 8x8 tiles kept in first-seen order (rgbgfx --remove-duplicates).""" + kept, index_of = [], {} + for r in range(im.size[1] // 8): + for c in range(cols): + tile = im.crop((c * 8, r * 8, c * 8 + 8, r * 8 + 8)) + key = tile.tobytes() + if key not in index_of: + index_of[key] = len(kept) + kept.append(tile) + return kept + + +def _all_tiles(im, cols): + return [im.crop((c * 8, r * 8, c * 8 + 8, r * 8 + 8)) + for r in range(im.size[1] // 8) for c in range(cols)] + + +def extract_trade(pokered, assets_dir): + """TradingAnimationGraphics (+ cable ball / bubble) for InternalClockTradeAnim. + + game_boy.2bpp is built with --remove-duplicates; link_cable.2bpp is not. + Tilemaps reference absolute vChars2 ids starting at $31. We rebuild the + open-cable plate and cable segment tiles from that atlas, and convert the + Game Boy / ball / bubble sheets for direct blitting. + """ + trade_dir = os.path.join(pokered, "gfx/trade") + out_dir = os.path.join(assets_dir, "trade") + gb_src = Image.open(os.path.join(trade_dir, "game_boy.png")).convert("L") + lc_src = Image.open(os.path.join(trade_dir, "link_cable.png")).convert("L") + if gb_src.size != (48, 64) or lc_src.size != (24, 40): + util.die(f"trade sheets: unexpected sizes {gb_src.size} / {lc_src.size}") + atlas = _unique_tiles(gb_src, 6) + _all_tiles(lc_src, 3) + if len(atlas) != 49: + util.die(f"trade atlas: expected 49 tiles after dedupe, got {len(atlas)}") + + convert_png(os.path.join(trade_dir, "game_boy.png"), + os.path.join(out_dir, "game_boy.png"), transparent_matte=True) + convert_png(os.path.join(trade_dir, "bubble.png"), + os.path.join(out_dir, "bubble.png"), transparent_color0=True) + + # cable_ball.2bpp is four 8x8 tiles at vSprites $7c; the ball OAM uses + # tile $7e four times with X/Y flips (and $7f for the bulge frame). + ball_src = _convert_image( + Image.open(os.path.join(trade_dir, "cable_ball.png")), + transparent_color0=True) + if ball_src.size != (16, 16): + util.die(f"cable_ball.png: expected 16x16, got {ball_src.size}") + + def compose_ball(tile): + out = Image.new("RGBA", (16, 16), (0, 0, 0, 0)) + out.paste(tile, (0, 0)) + out.paste(tile.transpose(Image.FLIP_LEFT_RIGHT), (8, 0)) + out.paste(tile.transpose(Image.FLIP_TOP_BOTTOM), (0, 8)) + out.paste(tile.transpose(Image.FLIP_LEFT_RIGHT).transpose( + Image.FLIP_TOP_BOTTOM), (8, 8)) + return out + + _save_png(compose_ball(ball_src.crop((0, 8, 8, 16))), + os.path.join(out_dir, "cable_ball.png")) + _save_png(compose_ball(ball_src.crop((8, 8, 16, 16))), + os.path.join(out_dir, "cable_ball_alt.png")) + + with open(os.path.join(trade_dir, "link_cable.tilemap"), "rb") as fh: + lc_map = fh.read() + if len(lc_map) != 36: + util.die(f"link_cable.tilemap: expected 36 bytes, got {len(lc_map)}") + open_cable = Image.new("L", (96, 24), 255) + for i, tid in enumerate(lc_map): + idx = tid - 0x31 + if idx < 0 or idx >= len(atlas): + util.die(f"link_cable.tilemap: tile ${tid:02x} outside atlas") + open_cable.paste(atlas[idx], ((i % 12) * 8, (i // 12) * 8)) + _save_png(_convert_image(open_cable), os.path.join(out_dir, "open_cable.png")) + + for name, vid in (("cable_seg", 0x5E), ("cable_conn", 0x5D), + ("cable_vert", 0x61), ("cable_corner", 0x5F), + ("cable_end", 0x60)): + _save_png(_convert_image(atlas[vid - 0x31]), + os.path.join(out_dir, f"{name}.png")) + + horiz = Image.new("L", (160, 8), 255) + seg = atlas[0x5E - 0x31] + for x in range(20): + horiz.paste(seg, (x * 8, 0)) + _save_png(_convert_image(horiz), os.path.join(out_dir, "cable_horiz.png")) + + return { + "gameBoy": "assets/generated/trade/game_boy.png", + "openCable": "assets/generated/trade/open_cable.png", + "cableHoriz": "assets/generated/trade/cable_horiz.png", + "cableConn": "assets/generated/trade/cable_conn.png", + "cableVert": "assets/generated/trade/cable_vert.png", + "cableCorner": "assets/generated/trade/cable_corner.png", + "cableEnd": "assets/generated/trade/cable_end.png", + "cableBall": "assets/generated/trade/cable_ball.png", + "cableBallAlt": "assets/generated/trade/cable_ball_alt.png", + "bubble": "assets/generated/trade/bubble.png", + "source": "gfx/trade/* (TradingAnimationGraphics, " + "engine/movie/trade.asm InternalClockTradeAnim)", + } diff --git a/tools/modkit.py b/tools/modkit.py index 44df1c81..094c0a89 100644 --- a/tools/modkit.py +++ b/tools/modkit.py @@ -113,7 +113,7 @@ def engine_version(repo): src = open(os.path.join(repo, "src", "core", "Version.lua"), encoding="utf-8").read() match = re.search(r'engine\s*=\s*"([^"]+)"', src) - return match.group(1) if match else "1.0.0" + return match.group(1) if match else "0.0.0-dev" def known_permissions(repo): diff --git a/tools/rom_manifest.json b/tools/rom_manifest.json index e82c41a6..69e17b00 100644 --- a/tools/rom_manifest.json +++ b/tools/rom_manifest.json @@ -1629,7 +1629,7 @@ }, "charmap": { "0": "", - "10": "ザ", + "10": "\u30b6", "100": "", "101": "", "102": "", @@ -1640,29 +1640,29 @@ "107": "", "108": "", "109": "", - "11": "ジ", - "110": "ぃ", - "111": "ぅ", - "112": "‘", - "113": "’", - "114": "“", - "115": "”", - "116": "·", - "117": "…", - "118": "ぁ", - "119": "ぇ", - "12": "ズ", - "120": "ぉ", - "121": "┌", - "122": "─", - "123": "┐", - "124": "│", - "125": "└", - "126": "┘", + "11": "\u30b8", + "110": "\u3043", + "111": "\u3045", + "112": "\u2018", + "113": "\u2019", + "114": "\u201c", + "115": "\u201d", + "116": "\u00b7", + "117": "\u2026", + "118": "\u3041", + "119": "\u3047", + "12": "\u30ba", + "120": "\u3049", + "121": "\u250c", + "122": "\u2500", + "123": "\u2510", + "124": "\u2502", + "125": "\u2514", + "126": "\u2518", "127": " ", "128": "A", "129": "B", - "13": "ゼ", + "13": "\u30bc", "130": "C", "131": "D", "132": "E", @@ -1673,7 +1673,7 @@ "137": "J", "138": "K", "139": "L", - "14": "ゾ", + "14": "\u30be", "140": "M", "141": "N", "142": "O", @@ -1684,7 +1684,7 @@ "147": "T", "148": "U", "149": "V", - "15": "ダ", + "15": "\u30c0", "150": "W", "151": "X", "152": "Y", @@ -1695,7 +1695,7 @@ "157": ";", "158": "[", "159": "]", - "16": "ヂ", + "16": "\u30c2", "160": "a", "161": "b", "162": "c", @@ -1706,7 +1706,7 @@ "167": "h", "168": "i", "169": "j", - "17": "ヅ", + "17": "\u30c5", "170": "k", "171": "l", "172": "m", @@ -1717,52 +1717,52 @@ "177": "r", "178": "s", "179": "t", - "18": "デ", + "18": "\u30c7", "180": "u", "181": "v", "182": "w", "183": "x", "184": "y", "185": "z", - "186": "é", + "186": "\u00e9", "187": "'d", "188": "'l", "189": "'s", - "19": "ド", + "19": "\u30c9", "190": "'t", "191": "'v", - "192": "た", - "193": "ち", - "194": "つ", - "195": "て", - "196": "と", - "197": "な", - "198": "に", - "199": "ぬ", - "200": "ね", - "201": "の", - "202": "は", - "203": "ひ", - "204": "ふ", - "205": "へ", - "206": "ほ", - "207": "ま", - "208": "み", - "209": "む", - "210": "め", - "211": "も", - "212": "や", - "213": "ゆ", - "214": "よ", - "215": "ら", - "216": "り", - "217": "る", - "218": "れ", - "219": "ろ", - "220": "わ", - "221": "を", - "222": "ん", - "223": "っ", + "192": "\u305f", + "193": "\u3061", + "194": "\u3064", + "195": "\u3066", + "196": "\u3068", + "197": "\u306a", + "198": "\u306b", + "199": "\u306c", + "200": "\u306d", + "201": "\u306e", + "202": "\u306f", + "203": "\u3072", + "204": "\u3075", + "205": "\u3078", + "206": "\u307b", + "207": "\u307e", + "208": "\u307f", + "209": "\u3080", + "210": "\u3081", + "211": "\u3082", + "212": "\u3084", + "213": "\u3086", + "214": "\u3088", + "215": "\u3089", + "216": "\u308a", + "217": "\u308b", + "218": "\u308c", + "219": "\u308d", + "220": "\u308f", + "221": "\u3092", + "222": "\u3093", + "223": "\u3063", "224": "'", "225": "", "226": "", @@ -1772,83 +1772,83 @@ "230": "?", "231": "!", "232": ".", - "233": "ァ", - "234": "ゥ", - "235": "ェ", - "236": "▷", - "237": "▲", - "238": "▼", - "239": "♂", + "233": "\u30a1", + "234": "\u30a5", + "235": "\u30a7", + "236": "\u25b7", + "237": "\u25b2", + "238": "\u25bc", + "239": "\u2642", "240": "", - "241": "×", + "241": "\u00d7", "242": ".", "243": "/", "244": ",", - "245": "♀", + "245": "\u2640", "246": "0", "247": "1", "248": "2", "249": "3", - "25": "バ", + "25": "\u30d0", "250": "4", "251": "5", "252": "6", "253": "7", "254": "8", "255": "9", - "26": "ビ", - "27": "ブ", - "28": "ボ", - "38": "が", - "39": "ぎ", - "40": "ぐ", - "41": "げ", - "42": "ご", - "43": "ざ", - "44": "じ", - "45": "ず", - "46": "ぜ", - "47": "ぞ", - "48": "だ", - "49": "ぢ", - "5": "ガ", - "50": "づ", - "51": "で", - "52": "ど", - "58": "ば", - "59": "び", - "6": "ギ", - "60": "ぶ", - "61": "べ", - "62": "ぼ", - "64": "パ", - "65": "ピ", - "66": "プ", - "67": "ポ", - "68": "ぱ", - "69": "ぴ", - "7": "グ", - "70": "ぷ", - "71": "ぺ", - "72": "ぽ", + "26": "\u30d3", + "27": "\u30d6", + "28": "\u30dc", + "38": "\u304c", + "39": "\u304e", + "40": "\u3050", + "41": "\u3052", + "42": "\u3054", + "43": "\u3056", + "44": "\u3058", + "45": "\u305a", + "46": "\u305c", + "47": "\u305e", + "48": "\u3060", + "49": "\u3062", + "5": "\u30ac", + "50": "\u3065", + "51": "\u3067", + "52": "\u3069", + "58": "\u3070", + "59": "\u3073", + "6": "\u30ae", + "60": "\u3076", + "61": "\u3079", + "62": "\u307c", + "64": "\u30d1", + "65": "\u30d4", + "66": "\u30d7", + "67": "\u30dd", + "68": "\u3071", + "69": "\u3074", + "7": "\u30b0", + "70": "\u3077", + "71": "\u307a", + "72": "\u307d", "73": "\f", - "74": "POKéMON", + "74": "POK\u00e9MON", "75": "<_CONT>", "76": "", "78": "\n", "79": "\n", - "8": "ゲ", + "8": "\u30b2", "80": "", "81": "\f", "82": "{PLAYER}", "83": "{RIVAL}", - "84": "POKé", + "84": "POK\u00e9", "85": "\u000b", - "86": "……", + "86": "\u2026\u2026", "87": "", "88": "", "89": "{TARGET}", - "9": "ゴ", + "9": "\u30b4", "90": "{USER}", "91": "PC", "92": "TM", @@ -4255,44 +4255,164 @@ "cardKeyDoors": { "closedDoors": { "SILPH_CO_2F": [ - { "block": 84, "bx": 2, "by": 2, "event": "EVENT_SILPH_CO_2_UNLOCKED_DOOR1", "open": 14 }, - { "block": 84, "bx": 2, "by": 5, "event": "EVENT_SILPH_CO_2_UNLOCKED_DOOR2", "open": 14 } + { + "block": 84, + "bx": 2, + "by": 2, + "event": "EVENT_SILPH_CO_2_UNLOCKED_DOOR1", + "open": 14 + }, + { + "block": 84, + "bx": 2, + "by": 5, + "event": "EVENT_SILPH_CO_2_UNLOCKED_DOOR2", + "open": 14 + } ], "SILPH_CO_3F": [ - { "block": 95, "bx": 4, "by": 4, "event": "EVENT_SILPH_CO_3_UNLOCKED_DOOR1", "open": 14 }, - { "block": 95, "bx": 8, "by": 4, "event": "EVENT_SILPH_CO_3_UNLOCKED_DOOR2", "open": 14 } + { + "block": 95, + "bx": 4, + "by": 4, + "event": "EVENT_SILPH_CO_3_UNLOCKED_DOOR1", + "open": 14 + }, + { + "block": 95, + "bx": 8, + "by": 4, + "event": "EVENT_SILPH_CO_3_UNLOCKED_DOOR2", + "open": 14 + } ], "SILPH_CO_4F": [ - { "block": 84, "bx": 2, "by": 6, "event": "EVENT_SILPH_CO_4_UNLOCKED_DOOR1", "open": 14 }, - { "block": 84, "bx": 6, "by": 4, "event": "EVENT_SILPH_CO_4_UNLOCKED_DOOR2", "open": 14 } + { + "block": 84, + "bx": 2, + "by": 6, + "event": "EVENT_SILPH_CO_4_UNLOCKED_DOOR1", + "open": 14 + }, + { + "block": 84, + "bx": 6, + "by": 4, + "event": "EVENT_SILPH_CO_4_UNLOCKED_DOOR2", + "open": 14 + } ], "SILPH_CO_5F": [ - { "block": 95, "bx": 3, "by": 2, "event": "EVENT_SILPH_CO_5_UNLOCKED_DOOR1", "open": 14 }, - { "block": 95, "bx": 3, "by": 6, "event": "EVENT_SILPH_CO_5_UNLOCKED_DOOR2", "open": 14 }, - { "block": 95, "bx": 7, "by": 5, "event": "EVENT_SILPH_CO_5_UNLOCKED_DOOR3", "open": 14 } + { + "block": 95, + "bx": 3, + "by": 2, + "event": "EVENT_SILPH_CO_5_UNLOCKED_DOOR1", + "open": 14 + }, + { + "block": 95, + "bx": 3, + "by": 6, + "event": "EVENT_SILPH_CO_5_UNLOCKED_DOOR2", + "open": 14 + }, + { + "block": 95, + "bx": 7, + "by": 5, + "event": "EVENT_SILPH_CO_5_UNLOCKED_DOOR3", + "open": 14 + } ], "SILPH_CO_6F": [ - { "block": 95, "bx": 2, "by": 6, "event": "EVENT_SILPH_CO_6_UNLOCKED_DOOR", "open": 14 } + { + "block": 95, + "bx": 2, + "by": 6, + "event": "EVENT_SILPH_CO_6_UNLOCKED_DOOR", + "open": 14 + } ], "SILPH_CO_7F": [ - { "block": 84, "bx": 5, "by": 3, "event": "EVENT_SILPH_CO_7_UNLOCKED_DOOR1", "open": 14 }, - { "block": 84, "bx": 10, "by": 2, "event": "EVENT_SILPH_CO_7_UNLOCKED_DOOR2", "open": 14 }, - { "block": 84, "bx": 10, "by": 6, "event": "EVENT_SILPH_CO_7_UNLOCKED_DOOR3", "open": 14 } + { + "block": 84, + "bx": 5, + "by": 3, + "event": "EVENT_SILPH_CO_7_UNLOCKED_DOOR1", + "open": 14 + }, + { + "block": 84, + "bx": 10, + "by": 2, + "event": "EVENT_SILPH_CO_7_UNLOCKED_DOOR2", + "open": 14 + }, + { + "block": 84, + "bx": 10, + "by": 6, + "event": "EVENT_SILPH_CO_7_UNLOCKED_DOOR3", + "open": 14 + } ], "SILPH_CO_8F": [ - { "block": 95, "bx": 3, "by": 4, "event": "EVENT_SILPH_CO_8_UNLOCKED_DOOR", "open": 14 } + { + "block": 95, + "bx": 3, + "by": 4, + "event": "EVENT_SILPH_CO_8_UNLOCKED_DOOR", + "open": 14 + } ], "SILPH_CO_9F": [ - { "block": 95, "bx": 1, "by": 4, "event": "EVENT_SILPH_CO_9_UNLOCKED_DOOR1", "open": 14 }, - { "block": 84, "bx": 9, "by": 2, "event": "EVENT_SILPH_CO_9_UNLOCKED_DOOR2", "open": 14 }, - { "block": 84, "bx": 9, "by": 5, "event": "EVENT_SILPH_CO_9_UNLOCKED_DOOR3", "open": 14 }, - { "block": 95, "bx": 5, "by": 6, "event": "EVENT_SILPH_CO_9_UNLOCKED_DOOR4", "open": 14 } + { + "block": 95, + "bx": 1, + "by": 4, + "event": "EVENT_SILPH_CO_9_UNLOCKED_DOOR1", + "open": 14 + }, + { + "block": 84, + "bx": 9, + "by": 2, + "event": "EVENT_SILPH_CO_9_UNLOCKED_DOOR2", + "open": 14 + }, + { + "block": 84, + "bx": 9, + "by": 5, + "event": "EVENT_SILPH_CO_9_UNLOCKED_DOOR3", + "open": 14 + }, + { + "block": 95, + "bx": 5, + "by": 6, + "event": "EVENT_SILPH_CO_9_UNLOCKED_DOOR4", + "open": 14 + } ], "SILPH_CO_10F": [ - { "block": 84, "bx": 5, "by": 4, "event": "EVENT_SILPH_CO_10_UNLOCKED_DOOR", "open": 14 } + { + "block": 84, + "bx": 5, + "by": 4, + "event": "EVENT_SILPH_CO_10_UNLOCKED_DOOR", + "open": 14 + } ], "SILPH_CO_11F": [ - { "block": 32, "bx": 3, "by": 6, "event": "EVENT_SILPH_CO_11_UNLOCKED_DOOR", "open": 3 } + { + "block": 32, + "bx": 3, + "by": 6, + "event": "EVENT_SILPH_CO_11_UNLOCKED_DOOR", + "open": 3 + } ] }, "doorTiles": [ @@ -4479,7 +4599,7 @@ "lines": [ { "column": 6, - "text": "POKéMON" + "text": "POK\u00e9MON" }, { "column": 1, @@ -5762,6 +5882,27 @@ } ] }, + "printTrash": { + "SS_ANNE_KITCHEN": [ + { + "facing": "down", + "x": 13, + "y": 5 + }, + { + "facing": "down", + "x": 13, + "y": 7 + } + ], + "VERMILION_GYM": [ + { + "facing": "down", + "x": 6, + "y": 1 + } + ] + }, "trashCans": { "adjacent": { "0": [ @@ -9229,7 +9370,7 @@ "gridPixelSize": 8, "locations": { "AGATHAS_ROOM": { - "name": "POKéMON LEAGUE", + "name": "POK\u00e9MON LEAGUE", "x": 0, "y": 2 }, @@ -9249,7 +9390,7 @@ "y": 11 }, "BRUNOS_ROOM": { - "name": "POKéMON LEAGUE", + "name": "POK\u00e9MON LEAGUE", "x": 0, "y": 2 }, @@ -9399,7 +9540,7 @@ "y": 2 }, "CHAMPIONS_ROOM": { - "name": "POKéMON LEAGUE", + "name": "POK\u00e9MON LEAGUE", "x": 0, "y": 2 }, @@ -9449,7 +9590,7 @@ "y": 15 }, "COLOSSEUM": { - "name": "POKéMON LEAGUE", + "name": "POK\u00e9MON LEAGUE", "x": 0, "y": 2 }, @@ -9534,7 +9675,7 @@ "y": 5 }, "HALL_OF_FAME": { - "name": "POKéMON LEAGUE", + "name": "POK\u00e9MON LEAGUE", "x": 0, "y": 2 }, @@ -9549,7 +9690,7 @@ "y": 2 }, "LANCES_ROOM": { - "name": "POKéMON LEAGUE", + "name": "POK\u00e9MON LEAGUE", "x": 0, "y": 2 }, @@ -9574,7 +9715,7 @@ "y": 5 }, "LORELEIS_ROOM": { - "name": "POKéMON LEAGUE", + "name": "POK\u00e9MON LEAGUE", "x": 0, "y": 2 }, @@ -9669,57 +9810,57 @@ "y": 9 }, "POKEMON_MANSION_1F": { - "name": "POKéMON MANSION", + "name": "POK\u00e9MON MANSION", "x": 2, "y": 15 }, "POKEMON_MANSION_2F": { - "name": "POKéMON MANSION", + "name": "POK\u00e9MON MANSION", "x": 2, "y": 15 }, "POKEMON_MANSION_3F": { - "name": "POKéMON MANSION", + "name": "POK\u00e9MON MANSION", "x": 2, "y": 15 }, "POKEMON_MANSION_B1F": { - "name": "POKéMON MANSION", + "name": "POK\u00e9MON MANSION", "x": 2, "y": 15 }, "POKEMON_TOWER_1F": { - "name": "POKéMON TOWER", + "name": "POK\u00e9MON TOWER", "x": 15, "y": 5 }, "POKEMON_TOWER_2F": { - "name": "POKéMON TOWER", + "name": "POK\u00e9MON TOWER", "x": 15, "y": 5 }, "POKEMON_TOWER_3F": { - "name": "POKéMON TOWER", + "name": "POK\u00e9MON TOWER", "x": 15, "y": 5 }, "POKEMON_TOWER_4F": { - "name": "POKéMON TOWER", + "name": "POK\u00e9MON TOWER", "x": 15, "y": 5 }, "POKEMON_TOWER_5F": { - "name": "POKéMON TOWER", + "name": "POK\u00e9MON TOWER", "x": 15, "y": 5 }, "POKEMON_TOWER_6F": { - "name": "POKéMON TOWER", + "name": "POK\u00e9MON TOWER", "x": 15, "y": 5 }, "POKEMON_TOWER_7F": { - "name": "POKéMON TOWER", + "name": "POK\u00e9MON TOWER", "x": 15, "y": 5 }, @@ -10209,7 +10350,7 @@ "y": 10 }, "TRADE_CENTER": { - "name": "POKéMON LEAGUE", + "name": "POK\u00e9MON LEAGUE", "x": 0, "y": 2 }, @@ -10905,631 +11046,631 @@ }, { "code": 240, - "seq": "¥" + "seq": "\u00a5" }, { "code": 116, - "seq": "·" + "seq": "\u00b7" }, { "code": 241, - "seq": "×" + "seq": "\u00d7" }, { "code": 186, - "seq": "é" + "seq": "\u00e9" }, { "code": 112, - "seq": "‘" + "seq": "\u2018" }, { "code": 113, - "seq": "’" + "seq": "\u2019" }, { "code": 114, - "seq": "“" + "seq": "\u201c" }, { "code": 115, - "seq": "”" + "seq": "\u201d" }, { "code": 117, - "seq": "…" + "seq": "\u2026" }, { "code": 96, - "seq": "′" + "seq": "\u2032" }, { "code": 97, - "seq": "″" + "seq": "\u2033" }, { "code": 116, - "seq": "№" + "seq": "\u2116" }, { "code": 117, - "seq": "⋯" + "seq": "\u22ef" }, { "code": 122, - "seq": "─" + "seq": "\u2500" }, { "code": 124, - "seq": "│" + "seq": "\u2502" }, { "code": 121, - "seq": "┌" + "seq": "\u250c" }, { "code": 123, - "seq": "┐" + "seq": "\u2510" }, { "code": 125, - "seq": "└" + "seq": "\u2514" }, { "code": 126, - "seq": "┘" + "seq": "\u2518" }, { "code": 237, - "seq": "▲" + "seq": "\u25b2" }, { "code": 237, - "seq": "▶" + "seq": "\u25b6" }, { "code": 236, - "seq": "▷" + "seq": "\u25b7" }, { "code": 238, - "seq": "▼" + "seq": "\u25bc" }, { "code": 245, - "seq": "♀" + "seq": "\u2640" }, { "code": 239, - "seq": "♂" + "seq": "\u2642" }, { "code": 127, - "seq": " " + "seq": "\u3000" }, { "code": 232, - "seq": "。" + "seq": "\u3002" }, { "code": 112, - "seq": "「" + "seq": "\u300c" }, { "code": 113, - "seq": "」" + "seq": "\u300d" }, { "code": 114, - "seq": "『" + "seq": "\u300e" }, { "code": 115, - "seq": "』" + "seq": "\u300f" }, { "code": 118, - "seq": "ぁ" + "seq": "\u3041" }, { "code": 177, - "seq": "あ" + "seq": "\u3042" }, { "code": 110, - "seq": "ぃ" + "seq": "\u3043" }, { "code": 178, - "seq": "い" + "seq": "\u3044" }, { "code": 111, - "seq": "ぅ" + "seq": "\u3045" }, { "code": 179, - "seq": "う" + "seq": "\u3046" }, { "code": 119, - "seq": "ぇ" + "seq": "\u3047" }, { "code": 180, - "seq": "え" + "seq": "\u3048" }, { "code": 120, - "seq": "ぉ" + "seq": "\u3049" }, { "code": 181, - "seq": "お" + "seq": "\u304a" }, { "code": 182, - "seq": "か" + "seq": "\u304b" }, { "code": 183, - "seq": "き" + "seq": "\u304d" }, { "code": 184, - "seq": "く" + "seq": "\u304f" }, { "code": 185, - "seq": "け" + "seq": "\u3051" }, { "code": 186, - "seq": "こ" + "seq": "\u3053" }, { "code": 187, - "seq": "さ" + "seq": "\u3055" }, { "code": 188, - "seq": "し" + "seq": "\u3057" }, { "code": 189, - "seq": "す" + "seq": "\u3059" }, { "code": 190, - "seq": "せ" + "seq": "\u305b" }, { "code": 191, - "seq": "そ" + "seq": "\u305d" }, { "code": 192, - "seq": "た" + "seq": "\u305f" }, { "code": 193, - "seq": "ち" + "seq": "\u3061" }, { "code": 223, - "seq": "っ" + "seq": "\u3063" }, { "code": 194, - "seq": "つ" + "seq": "\u3064" }, { "code": 195, - "seq": "て" + "seq": "\u3066" }, { "code": 196, - "seq": "と" + "seq": "\u3068" }, { "code": 197, - "seq": "な" + "seq": "\u306a" }, { "code": 198, - "seq": "に" + "seq": "\u306b" }, { "code": 199, - "seq": "ぬ" + "seq": "\u306c" }, { "code": 200, - "seq": "ね" + "seq": "\u306d" }, { "code": 201, - "seq": "の" + "seq": "\u306e" }, { "code": 202, - "seq": "は" + "seq": "\u306f" }, { "code": 203, - "seq": "ひ" + "seq": "\u3072" }, { "code": 204, - "seq": "ふ" + "seq": "\u3075" }, { "code": 205, - "seq": "へ" + "seq": "\u3078" }, { "code": 206, - "seq": "ほ" + "seq": "\u307b" }, { "code": 207, - "seq": "ま" + "seq": "\u307e" }, { "code": 208, - "seq": "み" + "seq": "\u307f" }, { "code": 209, - "seq": "む" + "seq": "\u3080" }, { "code": 210, - "seq": "め" + "seq": "\u3081" }, { "code": 211, - "seq": "も" + "seq": "\u3082" }, { "code": 224, - "seq": "ゃ" + "seq": "\u3083" }, { "code": 212, - "seq": "や" + "seq": "\u3084" }, { "code": 225, - "seq": "ゅ" + "seq": "\u3085" }, { "code": 213, - "seq": "ゆ" + "seq": "\u3086" }, { "code": 226, - "seq": "ょ" + "seq": "\u3087" }, { "code": 214, - "seq": "よ" + "seq": "\u3088" }, { "code": 215, - "seq": "ら" + "seq": "\u3089" }, { "code": 216, - "seq": "り" + "seq": "\u308a" }, { "code": 217, - "seq": "る" + "seq": "\u308b" }, { "code": 218, - "seq": "れ" + "seq": "\u308c" }, { "code": 219, - "seq": "ろ" + "seq": "\u308d" }, { "code": 220, - "seq": "わ" + "seq": "\u308f" }, { "code": 221, - "seq": "を" + "seq": "\u3092" }, { "code": 222, - "seq": "ん" + "seq": "\u3093" }, { "code": 233, - "seq": "ァ" + "seq": "\u30a1" }, { "code": 128, - "seq": "ア" + "seq": "\u30a2" }, { "code": 176, - "seq": "ィ" + "seq": "\u30a3" }, { "code": 129, - "seq": "イ" + "seq": "\u30a4" }, { "code": 234, - "seq": "ゥ" + "seq": "\u30a5" }, { "code": 130, - "seq": "ウ" + "seq": "\u30a6" }, { "code": 235, - "seq": "ェ" + "seq": "\u30a7" }, { "code": 131, - "seq": "エ" + "seq": "\u30a8" }, { "code": 244, - "seq": "ォ" + "seq": "\u30a9" }, { "code": 132, - "seq": "オ" + "seq": "\u30aa" }, { "code": 133, - "seq": "カ" + "seq": "\u30ab" }, { "code": 134, - "seq": "キ" + "seq": "\u30ad" }, { "code": 135, - "seq": "ク" + "seq": "\u30af" }, { "code": 136, - "seq": "ケ" + "seq": "\u30b1" }, { "code": 137, - "seq": "コ" + "seq": "\u30b3" }, { "code": 138, - "seq": "サ" + "seq": "\u30b5" }, { "code": 139, - "seq": "シ" + "seq": "\u30b7" }, { "code": 140, - "seq": "ス" + "seq": "\u30b9" }, { "code": 141, - "seq": "セ" + "seq": "\u30bb" }, { "code": 142, - "seq": "ソ" + "seq": "\u30bd" }, { "code": 143, - "seq": "タ" + "seq": "\u30bf" }, { "code": 144, - "seq": "チ" + "seq": "\u30c1" }, { "code": 172, - "seq": "ッ" + "seq": "\u30c3" }, { "code": 145, - "seq": "ツ" + "seq": "\u30c4" }, { "code": 146, - "seq": "テ" + "seq": "\u30c6" }, { "code": 147, - "seq": "ト" + "seq": "\u30c8" }, { "code": 148, - "seq": "ナ" + "seq": "\u30ca" }, { "code": 149, - "seq": "ニ" + "seq": "\u30cb" }, { "code": 150, - "seq": "ヌ" + "seq": "\u30cc" }, { "code": 151, - "seq": "ネ" + "seq": "\u30cd" }, { "code": 152, - "seq": "ノ" + "seq": "\u30ce" }, { "code": 153, - "seq": "ハ" + "seq": "\u30cf" }, { "code": 154, - "seq": "ヒ" + "seq": "\u30d2" }, { "code": 155, - "seq": "フ" + "seq": "\u30d5" }, { "code": 156, - "seq": "ホ" + "seq": "\u30db" }, { "code": 157, - "seq": "マ" + "seq": "\u30de" }, { "code": 158, - "seq": "ミ" + "seq": "\u30df" }, { "code": 159, - "seq": "ム" + "seq": "\u30e0" }, { "code": 160, - "seq": "メ" + "seq": "\u30e1" }, { "code": 161, - "seq": "モ" + "seq": "\u30e2" }, { "code": 173, - "seq": "ャ" + "seq": "\u30e3" }, { "code": 162, - "seq": "ヤ" + "seq": "\u30e4" }, { "code": 174, - "seq": "ュ" + "seq": "\u30e5" }, { "code": 163, - "seq": "ユ" + "seq": "\u30e6" }, { "code": 175, - "seq": "ョ" + "seq": "\u30e7" }, { "code": 164, - "seq": "ヨ" + "seq": "\u30e8" }, { "code": 165, - "seq": "ラ" + "seq": "\u30e9" }, { "code": 166, - "seq": "ル" + "seq": "\u30eb" }, { "code": 167, - "seq": "レ" + "seq": "\u30ec" }, { "code": 168, - "seq": "ロ" + "seq": "\u30ed" }, { "code": 169, - "seq": "ワ" + "seq": "\u30ef" }, { "code": 170, - "seq": "ヲ" + "seq": "\u30f2" }, { "code": 171, - "seq": "ン" + "seq": "\u30f3" }, { "code": 227, - "seq": "ー" + "seq": "\u30fc" }, { "code": 240, - "seq": "円" + "seq": "\u5186" }, { "code": 231, - "seq": "!" + "seq": "\uff01" }, { "code": 242, - "seq": "." + "seq": "\uff0e" }, { "code": 243, - "seq": "/" + "seq": "\uff0f" }, { "code": 246, - "seq": "0" + "seq": "\uff10" }, { "code": 247, - "seq": "1" + "seq": "\uff11" }, { "code": 248, - "seq": "2" + "seq": "\uff12" }, { "code": 249, - "seq": "3" + "seq": "\uff13" }, { "code": 250, - "seq": "4" + "seq": "\uff14" }, { "code": 251, - "seq": "5" + "seq": "\uff15" }, { "code": 252, - "seq": "6" + "seq": "\uff16" }, { "code": 253, - "seq": "7" + "seq": "\uff17" }, { "code": 254, - "seq": "8" + "seq": "\uff18" }, { "code": 255, - "seq": "9" + "seq": "\uff19" }, { "code": 230, - "seq": "?" + "seq": "\uff1f" }, { "code": 229, - "seq": "゙" + "seq": "\uff9e" }, { "code": 228, - "seq": "゚" + "seq": "\uff9f" } ], "format": 2, @@ -44745,11 +44886,26 @@ ], "trainerPartyOverrides": { "OPP_CHIEF": [ - { "level": 41, "species": "MACHOKE" }, - { "level": 41, "species": "GOLBAT" }, - { "level": 43, "species": "MAROWAK" }, - { "level": 43, "species": "WEEZING" }, - { "level": 46, "species": "PERSIAN" } + { + "level": 41, + "species": "MACHOKE" + }, + { + "level": 41, + "species": "GOLBAT" + }, + { + "level": 43, + "species": "MAROWAK" + }, + { + "level": 43, + "species": "WEEZING" + }, + { + "level": 46, + "species": "PERSIAN" + } ] }, "trainerPics": [ diff --git a/tools/rom_manifest_blue.json b/tools/rom_manifest_blue.json index fc9f5e3f..1453551c 100644 --- a/tools/rom_manifest_blue.json +++ b/tools/rom_manifest_blue.json @@ -1629,7 +1629,7 @@ }, "charmap": { "0": "", - "10": "ザ", + "10": "\u30b6", "100": "", "101": "", "102": "", @@ -1640,29 +1640,29 @@ "107": "", "108": "", "109": "", - "11": "ジ", - "110": "ぃ", - "111": "ぅ", - "112": "‘", - "113": "’", - "114": "“", - "115": "”", - "116": "·", - "117": "…", - "118": "ぁ", - "119": "ぇ", - "12": "ズ", - "120": "ぉ", - "121": "┌", - "122": "─", - "123": "┐", - "124": "│", - "125": "└", - "126": "┘", + "11": "\u30b8", + "110": "\u3043", + "111": "\u3045", + "112": "\u2018", + "113": "\u2019", + "114": "\u201c", + "115": "\u201d", + "116": "\u00b7", + "117": "\u2026", + "118": "\u3041", + "119": "\u3047", + "12": "\u30ba", + "120": "\u3049", + "121": "\u250c", + "122": "\u2500", + "123": "\u2510", + "124": "\u2502", + "125": "\u2514", + "126": "\u2518", "127": " ", "128": "A", "129": "B", - "13": "ゼ", + "13": "\u30bc", "130": "C", "131": "D", "132": "E", @@ -1673,7 +1673,7 @@ "137": "J", "138": "K", "139": "L", - "14": "ゾ", + "14": "\u30be", "140": "M", "141": "N", "142": "O", @@ -1684,7 +1684,7 @@ "147": "T", "148": "U", "149": "V", - "15": "ダ", + "15": "\u30c0", "150": "W", "151": "X", "152": "Y", @@ -1695,7 +1695,7 @@ "157": ";", "158": "[", "159": "]", - "16": "ヂ", + "16": "\u30c2", "160": "a", "161": "b", "162": "c", @@ -1706,7 +1706,7 @@ "167": "h", "168": "i", "169": "j", - "17": "ヅ", + "17": "\u30c5", "170": "k", "171": "l", "172": "m", @@ -1717,52 +1717,52 @@ "177": "r", "178": "s", "179": "t", - "18": "デ", + "18": "\u30c7", "180": "u", "181": "v", "182": "w", "183": "x", "184": "y", "185": "z", - "186": "é", + "186": "\u00e9", "187": "'d", "188": "'l", "189": "'s", - "19": "ド", + "19": "\u30c9", "190": "'t", "191": "'v", - "192": "た", - "193": "ち", - "194": "つ", - "195": "て", - "196": "と", - "197": "な", - "198": "に", - "199": "ぬ", - "200": "ね", - "201": "の", - "202": "は", - "203": "ひ", - "204": "ふ", - "205": "へ", - "206": "ほ", - "207": "ま", - "208": "み", - "209": "む", - "210": "め", - "211": "も", - "212": "や", - "213": "ゆ", - "214": "よ", - "215": "ら", - "216": "り", - "217": "る", - "218": "れ", - "219": "ろ", - "220": "わ", - "221": "を", - "222": "ん", - "223": "っ", + "192": "\u305f", + "193": "\u3061", + "194": "\u3064", + "195": "\u3066", + "196": "\u3068", + "197": "\u306a", + "198": "\u306b", + "199": "\u306c", + "200": "\u306d", + "201": "\u306e", + "202": "\u306f", + "203": "\u3072", + "204": "\u3075", + "205": "\u3078", + "206": "\u307b", + "207": "\u307e", + "208": "\u307f", + "209": "\u3080", + "210": "\u3081", + "211": "\u3082", + "212": "\u3084", + "213": "\u3086", + "214": "\u3088", + "215": "\u3089", + "216": "\u308a", + "217": "\u308b", + "218": "\u308c", + "219": "\u308d", + "220": "\u308f", + "221": "\u3092", + "222": "\u3093", + "223": "\u3063", "224": "'", "225": "", "226": "", @@ -1772,83 +1772,83 @@ "230": "?", "231": "!", "232": ".", - "233": "ァ", - "234": "ゥ", - "235": "ェ", - "236": "▷", - "237": "▲", - "238": "▼", - "239": "♂", + "233": "\u30a1", + "234": "\u30a5", + "235": "\u30a7", + "236": "\u25b7", + "237": "\u25b2", + "238": "\u25bc", + "239": "\u2642", "240": "", - "241": "×", + "241": "\u00d7", "242": ".", "243": "/", "244": ",", - "245": "♀", + "245": "\u2640", "246": "0", "247": "1", "248": "2", "249": "3", - "25": "バ", + "25": "\u30d0", "250": "4", "251": "5", "252": "6", "253": "7", "254": "8", "255": "9", - "26": "ビ", - "27": "ブ", - "28": "ボ", - "38": "が", - "39": "ぎ", - "40": "ぐ", - "41": "げ", - "42": "ご", - "43": "ざ", - "44": "じ", - "45": "ず", - "46": "ぜ", - "47": "ぞ", - "48": "だ", - "49": "ぢ", - "5": "ガ", - "50": "づ", - "51": "で", - "52": "ど", - "58": "ば", - "59": "び", - "6": "ギ", - "60": "ぶ", - "61": "べ", - "62": "ぼ", - "64": "パ", - "65": "ピ", - "66": "プ", - "67": "ポ", - "68": "ぱ", - "69": "ぴ", - "7": "グ", - "70": "ぷ", - "71": "ぺ", - "72": "ぽ", + "26": "\u30d3", + "27": "\u30d6", + "28": "\u30dc", + "38": "\u304c", + "39": "\u304e", + "40": "\u3050", + "41": "\u3052", + "42": "\u3054", + "43": "\u3056", + "44": "\u3058", + "45": "\u305a", + "46": "\u305c", + "47": "\u305e", + "48": "\u3060", + "49": "\u3062", + "5": "\u30ac", + "50": "\u3065", + "51": "\u3067", + "52": "\u3069", + "58": "\u3070", + "59": "\u3073", + "6": "\u30ae", + "60": "\u3076", + "61": "\u3079", + "62": "\u307c", + "64": "\u30d1", + "65": "\u30d4", + "66": "\u30d7", + "67": "\u30dd", + "68": "\u3071", + "69": "\u3074", + "7": "\u30b0", + "70": "\u3077", + "71": "\u307a", + "72": "\u307d", "73": "\f", - "74": "POKéMON", + "74": "POK\u00e9MON", "75": "<_CONT>", "76": "", "78": "\n", "79": "\n", - "8": "ゲ", + "8": "\u30b2", "80": "", "81": "\f", "82": "{PLAYER}", "83": "{RIVAL}", - "84": "POKé", + "84": "POK\u00e9", "85": "\u000b", - "86": "……", + "86": "\u2026\u2026", "87": "", "88": "", "89": "{TARGET}", - "9": "ゴ", + "9": "\u30b4", "90": "{USER}", "91": "PC", "92": "TM", @@ -4599,7 +4599,7 @@ "lines": [ { "column": 6, - "text": "POKéMON" + "text": "POK\u00e9MON" }, { "column": 1, @@ -5859,6 +5859,27 @@ } ] }, + "printTrash": { + "SS_ANNE_KITCHEN": [ + { + "facing": "down", + "x": 13, + "y": 5 + }, + { + "facing": "down", + "x": 13, + "y": 7 + } + ], + "VERMILION_GYM": [ + { + "facing": "down", + "x": 6, + "y": 1 + } + ] + }, "trashCans": { "adjacent": { "0": [ @@ -9326,7 +9347,7 @@ "gridPixelSize": 8, "locations": { "AGATHAS_ROOM": { - "name": "POKéMON LEAGUE", + "name": "POK\u00e9MON LEAGUE", "x": 0, "y": 2 }, @@ -9346,7 +9367,7 @@ "y": 11 }, "BRUNOS_ROOM": { - "name": "POKéMON LEAGUE", + "name": "POK\u00e9MON LEAGUE", "x": 0, "y": 2 }, @@ -9496,7 +9517,7 @@ "y": 2 }, "CHAMPIONS_ROOM": { - "name": "POKéMON LEAGUE", + "name": "POK\u00e9MON LEAGUE", "x": 0, "y": 2 }, @@ -9546,7 +9567,7 @@ "y": 15 }, "COLOSSEUM": { - "name": "POKéMON LEAGUE", + "name": "POK\u00e9MON LEAGUE", "x": 0, "y": 2 }, @@ -9631,7 +9652,7 @@ "y": 5 }, "HALL_OF_FAME": { - "name": "POKéMON LEAGUE", + "name": "POK\u00e9MON LEAGUE", "x": 0, "y": 2 }, @@ -9646,7 +9667,7 @@ "y": 2 }, "LANCES_ROOM": { - "name": "POKéMON LEAGUE", + "name": "POK\u00e9MON LEAGUE", "x": 0, "y": 2 }, @@ -9671,7 +9692,7 @@ "y": 5 }, "LORELEIS_ROOM": { - "name": "POKéMON LEAGUE", + "name": "POK\u00e9MON LEAGUE", "x": 0, "y": 2 }, @@ -9766,57 +9787,57 @@ "y": 9 }, "POKEMON_MANSION_1F": { - "name": "POKéMON MANSION", + "name": "POK\u00e9MON MANSION", "x": 2, "y": 15 }, "POKEMON_MANSION_2F": { - "name": "POKéMON MANSION", + "name": "POK\u00e9MON MANSION", "x": 2, "y": 15 }, "POKEMON_MANSION_3F": { - "name": "POKéMON MANSION", + "name": "POK\u00e9MON MANSION", "x": 2, "y": 15 }, "POKEMON_MANSION_B1F": { - "name": "POKéMON MANSION", + "name": "POK\u00e9MON MANSION", "x": 2, "y": 15 }, "POKEMON_TOWER_1F": { - "name": "POKéMON TOWER", + "name": "POK\u00e9MON TOWER", "x": 15, "y": 5 }, "POKEMON_TOWER_2F": { - "name": "POKéMON TOWER", + "name": "POK\u00e9MON TOWER", "x": 15, "y": 5 }, "POKEMON_TOWER_3F": { - "name": "POKéMON TOWER", + "name": "POK\u00e9MON TOWER", "x": 15, "y": 5 }, "POKEMON_TOWER_4F": { - "name": "POKéMON TOWER", + "name": "POK\u00e9MON TOWER", "x": 15, "y": 5 }, "POKEMON_TOWER_5F": { - "name": "POKéMON TOWER", + "name": "POK\u00e9MON TOWER", "x": 15, "y": 5 }, "POKEMON_TOWER_6F": { - "name": "POKéMON TOWER", + "name": "POK\u00e9MON TOWER", "x": 15, "y": 5 }, "POKEMON_TOWER_7F": { - "name": "POKéMON TOWER", + "name": "POK\u00e9MON TOWER", "x": 15, "y": 5 }, @@ -10306,7 +10327,7 @@ "y": 10 }, "TRADE_CENTER": { - "name": "POKéMON LEAGUE", + "name": "POK\u00e9MON LEAGUE", "x": 0, "y": 2 }, @@ -11002,631 +11023,631 @@ }, { "code": 240, - "seq": "¥" + "seq": "\u00a5" }, { "code": 116, - "seq": "·" + "seq": "\u00b7" }, { "code": 241, - "seq": "×" + "seq": "\u00d7" }, { "code": 186, - "seq": "é" + "seq": "\u00e9" }, { "code": 112, - "seq": "‘" + "seq": "\u2018" }, { "code": 113, - "seq": "’" + "seq": "\u2019" }, { "code": 114, - "seq": "“" + "seq": "\u201c" }, { "code": 115, - "seq": "”" + "seq": "\u201d" }, { "code": 117, - "seq": "…" + "seq": "\u2026" }, { "code": 96, - "seq": "′" + "seq": "\u2032" }, { "code": 97, - "seq": "″" + "seq": "\u2033" }, { "code": 116, - "seq": "№" + "seq": "\u2116" }, { "code": 117, - "seq": "⋯" + "seq": "\u22ef" }, { "code": 122, - "seq": "─" + "seq": "\u2500" }, { "code": 124, - "seq": "│" + "seq": "\u2502" }, { "code": 121, - "seq": "┌" + "seq": "\u250c" }, { "code": 123, - "seq": "┐" + "seq": "\u2510" }, { "code": 125, - "seq": "└" + "seq": "\u2514" }, { "code": 126, - "seq": "┘" + "seq": "\u2518" }, { "code": 237, - "seq": "▲" + "seq": "\u25b2" }, { "code": 237, - "seq": "▶" + "seq": "\u25b6" }, { "code": 236, - "seq": "▷" + "seq": "\u25b7" }, { "code": 238, - "seq": "▼" + "seq": "\u25bc" }, { "code": 245, - "seq": "♀" + "seq": "\u2640" }, { "code": 239, - "seq": "♂" + "seq": "\u2642" }, { "code": 127, - "seq": " " + "seq": "\u3000" }, { "code": 232, - "seq": "。" + "seq": "\u3002" }, { "code": 112, - "seq": "「" + "seq": "\u300c" }, { "code": 113, - "seq": "」" + "seq": "\u300d" }, { "code": 114, - "seq": "『" + "seq": "\u300e" }, { "code": 115, - "seq": "』" + "seq": "\u300f" }, { "code": 118, - "seq": "ぁ" + "seq": "\u3041" }, { "code": 177, - "seq": "あ" + "seq": "\u3042" }, { "code": 110, - "seq": "ぃ" + "seq": "\u3043" }, { "code": 178, - "seq": "い" + "seq": "\u3044" }, { "code": 111, - "seq": "ぅ" + "seq": "\u3045" }, { "code": 179, - "seq": "う" + "seq": "\u3046" }, { "code": 119, - "seq": "ぇ" + "seq": "\u3047" }, { "code": 180, - "seq": "え" + "seq": "\u3048" }, { "code": 120, - "seq": "ぉ" + "seq": "\u3049" }, { "code": 181, - "seq": "お" + "seq": "\u304a" }, { "code": 182, - "seq": "か" + "seq": "\u304b" }, { "code": 183, - "seq": "き" + "seq": "\u304d" }, { "code": 184, - "seq": "く" + "seq": "\u304f" }, { "code": 185, - "seq": "け" + "seq": "\u3051" }, { "code": 186, - "seq": "こ" + "seq": "\u3053" }, { "code": 187, - "seq": "さ" + "seq": "\u3055" }, { "code": 188, - "seq": "し" + "seq": "\u3057" }, { "code": 189, - "seq": "す" + "seq": "\u3059" }, { "code": 190, - "seq": "せ" + "seq": "\u305b" }, { "code": 191, - "seq": "そ" + "seq": "\u305d" }, { "code": 192, - "seq": "た" + "seq": "\u305f" }, { "code": 193, - "seq": "ち" + "seq": "\u3061" }, { "code": 223, - "seq": "っ" + "seq": "\u3063" }, { "code": 194, - "seq": "つ" + "seq": "\u3064" }, { "code": 195, - "seq": "て" + "seq": "\u3066" }, { "code": 196, - "seq": "と" + "seq": "\u3068" }, { "code": 197, - "seq": "な" + "seq": "\u306a" }, { "code": 198, - "seq": "に" + "seq": "\u306b" }, { "code": 199, - "seq": "ぬ" + "seq": "\u306c" }, { "code": 200, - "seq": "ね" + "seq": "\u306d" }, { "code": 201, - "seq": "の" + "seq": "\u306e" }, { "code": 202, - "seq": "は" + "seq": "\u306f" }, { "code": 203, - "seq": "ひ" + "seq": "\u3072" }, { "code": 204, - "seq": "ふ" + "seq": "\u3075" }, { "code": 205, - "seq": "へ" + "seq": "\u3078" }, { "code": 206, - "seq": "ほ" + "seq": "\u307b" }, { "code": 207, - "seq": "ま" + "seq": "\u307e" }, { "code": 208, - "seq": "み" + "seq": "\u307f" }, { "code": 209, - "seq": "む" + "seq": "\u3080" }, { "code": 210, - "seq": "め" + "seq": "\u3081" }, { "code": 211, - "seq": "も" + "seq": "\u3082" }, { "code": 224, - "seq": "ゃ" + "seq": "\u3083" }, { "code": 212, - "seq": "や" + "seq": "\u3084" }, { "code": 225, - "seq": "ゅ" + "seq": "\u3085" }, { "code": 213, - "seq": "ゆ" + "seq": "\u3086" }, { "code": 226, - "seq": "ょ" + "seq": "\u3087" }, { "code": 214, - "seq": "よ" + "seq": "\u3088" }, { "code": 215, - "seq": "ら" + "seq": "\u3089" }, { "code": 216, - "seq": "り" + "seq": "\u308a" }, { "code": 217, - "seq": "る" + "seq": "\u308b" }, { "code": 218, - "seq": "れ" + "seq": "\u308c" }, { "code": 219, - "seq": "ろ" + "seq": "\u308d" }, { "code": 220, - "seq": "わ" + "seq": "\u308f" }, { "code": 221, - "seq": "を" + "seq": "\u3092" }, { "code": 222, - "seq": "ん" + "seq": "\u3093" }, { "code": 233, - "seq": "ァ" + "seq": "\u30a1" }, { "code": 128, - "seq": "ア" + "seq": "\u30a2" }, { "code": 176, - "seq": "ィ" + "seq": "\u30a3" }, { "code": 129, - "seq": "イ" + "seq": "\u30a4" }, { "code": 234, - "seq": "ゥ" + "seq": "\u30a5" }, { "code": 130, - "seq": "ウ" + "seq": "\u30a6" }, { "code": 235, - "seq": "ェ" + "seq": "\u30a7" }, { "code": 131, - "seq": "エ" + "seq": "\u30a8" }, { "code": 244, - "seq": "ォ" + "seq": "\u30a9" }, { "code": 132, - "seq": "オ" + "seq": "\u30aa" }, { "code": 133, - "seq": "カ" + "seq": "\u30ab" }, { "code": 134, - "seq": "キ" + "seq": "\u30ad" }, { "code": 135, - "seq": "ク" + "seq": "\u30af" }, { "code": 136, - "seq": "ケ" + "seq": "\u30b1" }, { "code": 137, - "seq": "コ" + "seq": "\u30b3" }, { "code": 138, - "seq": "サ" + "seq": "\u30b5" }, { "code": 139, - "seq": "シ" + "seq": "\u30b7" }, { "code": 140, - "seq": "ス" + "seq": "\u30b9" }, { "code": 141, - "seq": "セ" + "seq": "\u30bb" }, { "code": 142, - "seq": "ソ" + "seq": "\u30bd" }, { "code": 143, - "seq": "タ" + "seq": "\u30bf" }, { "code": 144, - "seq": "チ" + "seq": "\u30c1" }, { "code": 172, - "seq": "ッ" + "seq": "\u30c3" }, { "code": 145, - "seq": "ツ" + "seq": "\u30c4" }, { "code": 146, - "seq": "テ" + "seq": "\u30c6" }, { "code": 147, - "seq": "ト" + "seq": "\u30c8" }, { "code": 148, - "seq": "ナ" + "seq": "\u30ca" }, { "code": 149, - "seq": "ニ" + "seq": "\u30cb" }, { "code": 150, - "seq": "ヌ" + "seq": "\u30cc" }, { "code": 151, - "seq": "ネ" + "seq": "\u30cd" }, { "code": 152, - "seq": "ノ" + "seq": "\u30ce" }, { "code": 153, - "seq": "ハ" + "seq": "\u30cf" }, { "code": 154, - "seq": "ヒ" + "seq": "\u30d2" }, { "code": 155, - "seq": "フ" + "seq": "\u30d5" }, { "code": 156, - "seq": "ホ" + "seq": "\u30db" }, { "code": 157, - "seq": "マ" + "seq": "\u30de" }, { "code": 158, - "seq": "ミ" + "seq": "\u30df" }, { "code": 159, - "seq": "ム" + "seq": "\u30e0" }, { "code": 160, - "seq": "メ" + "seq": "\u30e1" }, { "code": 161, - "seq": "モ" + "seq": "\u30e2" }, { "code": 173, - "seq": "ャ" + "seq": "\u30e3" }, { "code": 162, - "seq": "ヤ" + "seq": "\u30e4" }, { "code": 174, - "seq": "ュ" + "seq": "\u30e5" }, { "code": 163, - "seq": "ユ" + "seq": "\u30e6" }, { "code": 175, - "seq": "ョ" + "seq": "\u30e7" }, { "code": 164, - "seq": "ヨ" + "seq": "\u30e8" }, { "code": 165, - "seq": "ラ" + "seq": "\u30e9" }, { "code": 166, - "seq": "ル" + "seq": "\u30eb" }, { "code": 167, - "seq": "レ" + "seq": "\u30ec" }, { "code": 168, - "seq": "ロ" + "seq": "\u30ed" }, { "code": 169, - "seq": "ワ" + "seq": "\u30ef" }, { "code": 170, - "seq": "ヲ" + "seq": "\u30f2" }, { "code": 171, - "seq": "ン" + "seq": "\u30f3" }, { "code": 227, - "seq": "ー" + "seq": "\u30fc" }, { "code": 240, - "seq": "円" + "seq": "\u5186" }, { "code": 231, - "seq": "!" + "seq": "\uff01" }, { "code": 242, - "seq": "." + "seq": "\uff0e" }, { "code": 243, - "seq": "/" + "seq": "\uff0f" }, { "code": 246, - "seq": "0" + "seq": "\uff10" }, { "code": 247, - "seq": "1" + "seq": "\uff11" }, { "code": 248, - "seq": "2" + "seq": "\uff12" }, { "code": 249, - "seq": "3" + "seq": "\uff13" }, { "code": 250, - "seq": "4" + "seq": "\uff14" }, { "code": 251, - "seq": "5" + "seq": "\uff15" }, { "code": 252, - "seq": "6" + "seq": "\uff16" }, { "code": 253, - "seq": "7" + "seq": "\uff17" }, { "code": 254, - "seq": "8" + "seq": "\uff18" }, { "code": 255, - "seq": "9" + "seq": "\uff19" }, { "code": 230, - "seq": "?" + "seq": "\uff1f" }, { "code": 229, - "seq": "゙" + "seq": "\uff9e" }, { "code": 228, - "seq": "゚" + "seq": "\uff9f" } ], "format": 2, diff --git a/tools/save_convert/convert.lua b/tools/save_convert/convert.lua new file mode 100644 index 00000000..88f4509a --- /dev/null +++ b/tools/save_convert/convert.lua @@ -0,0 +1,98 @@ +#!/usr/bin/env luajit +-- CLI: import a vanilla Gen1 (Red/Blue, international) save -- either a +-- raw 32768-byte .sav file, or a JSON wrapper carrying one as its +-- `raw_base64` field -- into this project's save.lua format, or export a +-- save.lua back out to a raw .sav. Run from the repo root: +-- +-- luajit tools/save_convert/convert.lua import +-- luajit tools/save_convert/convert.lua export +-- +-- This is a thin shell: all the actual work (size/checksum validation, the +-- GenSave codec, crosswalk data loading, the merge over new-game defaults) +-- lives in src/save_convert/SaveConvert.lua, shared with the runtime. This +-- file only handles the filesystem + the JSON/base64 input framing. See +-- src/save_convert/GenSave.lua for the codec and its documented scope. + +package.path = "./?.lua;" .. package.path + +local SaveConvert = require("src.save_convert.SaveConvert") +local SaveSerializer = require("src.core.SaveSerializer") +local Version = require("src.core.Version") + +local B64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" +local B64_LOOKUP = {} +for i = 1, #B64_CHARS do B64_LOOKUP[B64_CHARS:sub(i, i)] = i - 1 end + +local function b64decode(s) + s = s:gsub("[^%w+/=]", "") + local out = {} + local i = 1 + while i <= #s do + local c1, c2, c3, c4 = s:sub(i, i), s:sub(i + 1, i + 1), s:sub(i + 2, i + 2), s:sub(i + 3, i + 3) + local n1, n2 = B64_LOOKUP[c1], B64_LOOKUP[c2] + local n3 = c3 ~= "=" and c3 ~= "" and B64_LOOKUP[c3] or nil + local n4 = c4 ~= "=" and c4 ~= "" and B64_LOOKUP[c4] or nil + out[#out + 1] = string.char(n1 * 4 + math.floor(n2 / 16)) + if n3 then + out[#out + 1] = string.char((n2 % 16) * 16 + math.floor(n3 / 4)) + if n4 then out[#out + 1] = string.char((n3 % 4) * 64 + n4) end + end + i = i + 4 + end + return table.concat(out) +end + +local function readFile(path, mode) + local f = assert(io.open(path, mode or "r"), "cannot open " .. path) + local content = f:read("*a") + f:close() + return content +end + +local function cmdImport(inPath, outPath) + local content = readFile(inPath, "rb") + local bytes + if #content == SaveConvert.SAVE_SIZE then + bytes = content + else + local b64 = content:match('"raw_base64"%s*:%s*"([^"]+)"') + assert(b64, "input is neither a 32768-byte .sav nor JSON with a raw_base64 field") + bytes = b64decode(b64) + assert(#bytes == SaveConvert.SAVE_SIZE, + ("decoded raw_base64 is %d bytes, want %d"):format(#bytes, SaveConvert.SAVE_SIZE)) + end + + local save, err = SaveConvert.importSav(bytes, Version.saveFormat) + assert(save, err) + + local out = assert(io.open(outPath, "w")) + out:write(SaveSerializer.encode(save)) + out:close() + print(("wrote %s (party %d, boxed %d, %d flags)"):format( + outPath, #save.party, + (function() local n = 0 for _, b in ipairs(save.boxes) do n = n + #b end return n end)(), + (function() local n = 0 for _ in pairs(save.flags) do n = n + 1 end return n end)())) +end + +local function cmdExport(inPath, outPath) + local content = readFile(inPath) + local save = assert(SaveSerializer.decode(content)) + local bytes, err = SaveConvert.exportSav(save) + assert(bytes, err) + local out = assert(io.open(outPath, "wb")) + out:write(bytes) + out:close() + print(("wrote %s (%d bytes)"):format(outPath, #bytes)) +end + +local cmd = arg[1] +if cmd == "import" and arg[2] and arg[3] then + cmdImport(arg[2], arg[3]) +elseif cmd == "export" and arg[2] and arg[3] then + cmdExport(arg[2], arg[3]) +else + io.stderr:write( + "usage: luajit tools/save_convert/convert.lua import \n" .. + " luajit tools/save_convert/convert.lua export \n") + os.exit(1) +end diff --git a/tools/save_convert/crosscheck.lua b/tools/save_convert/crosscheck.lua new file mode 100644 index 00000000..c6945ee5 --- /dev/null +++ b/tools/save_convert/crosscheck.lua @@ -0,0 +1,446 @@ +-- crosscheck.lua -- adversarial cross-validation of GenSave.decode against +-- the INDEPENDENT vendor parser (vendor/gen1lib.lua, a PKHeX-derived generic +-- Gen1 .sav<->JSON codec) on a real 32768-byte battery save. Two codecs +-- triangulated from different authorities (GenSave from the pokered +-- disassembly, the vendor from PKHeX.Core) reading the same bytes: any +-- semantic field they disagree on is a bug in one of them. +-- +-- run: lua5.4 tools/save_convert/crosscheck.lua (needs POKEPORT_SAV_FIXTURE) +-- +-- INTERPRETER NOTE. The vendor lib is written in Lua 5.3+ (native << >> & // +-- operators, utf8 library) and cannot even be PARSED by LuaJIT, while GenSave +-- `require("bit")`s LuaJIT's BitOp. So this harness must run under a stock +-- Lua 5.3/5.4, and we hand GenSave a tiny `bit` shim backed by 5.4's native +-- operators. (The main suite, tests/save_convert_tests.lua, still runs under +-- luajit; this maintenance tool is the one place both codecs coexist.) +-- +-- COVERAGE ASYMMETRY / ORACLE CHOICE. The vendor decodes trainer block, +-- party, and all 12 boxes, but deliberately leaves items, Pokedex, options, +-- event flags, map and coords inside its opaque `raw_base64` blob (it only +-- round-trips them, never interprets them). For every such field this harness +-- reads the RAW BYTES straight out of the vendor's own byte buffer at the +-- vendor's own M.OFS.* offsets (an authority fully independent of GenSave's +-- offset table) and compares GenSave's decode against that. So even the +-- "vendor doesn't model it" fields still get a genuine second-source check. +-- +-- NO STANDING VENDOR DISAGREEMENTS. Every field the vendor DOES interpret +-- agrees with GenSave on the real fixture, so there is no vendor bug to work +-- around here. (Were one found, the rule per the task is: leave vendor code +-- untouched, keep GenSave's value, and document the disagreement in this +-- block.) The one bug this cross-check flushed out was on GenSave's side: its +-- flag_array reader packed bits MSB-first, but pokered's FlagAction (and +-- PKHeX) pack LSB-first. It survived the round-trip suite because encode and +-- decode shared the wrong convention; it did NOT survive contact with a real +-- save, where boxed ZAPDOS (dex 145) read back as un-owned. Fixed in GenSave +-- (bitGet/bitSet); this harness re-decodes the dex from raw bytes LSB-first +-- and now agrees. + +------------------------------------------------------------------------ +-- `bit` shim for GenSave, backed by native Lua 5.4 operators. +------------------------------------------------------------------------ +if not pcall(require, "bit") then + package.preload["bit"] = function() + local M = {} + -- LuaJIT's BitOp band/bor/bxor are VARIADIC (fold over all args); GenSave + -- relies on that in decodeDVs' 4-arg bit.bor for the HP DV. A 2-arg shim + -- silently drops the tail args -- so fold explicitly here. + function M.band(a, ...) local r = a; for _, v in ipairs({...}) do r = r & v end; return r & 0xFFFFFFFF end + function M.bor(a, ...) local r = a; for _, v in ipairs({...}) do r = r | v end; return r & 0xFFFFFFFF end + function M.bxor(a, ...) local r = a; for _, v in ipairs({...}) do r = r ~ v end; return r & 0xFFFFFFFF end + function M.bnot(a) return (~a) & 0xFFFFFFFF end + function M.lshift(a, n) return (a << n) & 0xFFFFFFFF end + function M.rshift(a, n) return (a & 0xFFFFFFFF) >> n end + return M + end +end + +package.path = "./?.lua;" .. package.path + +------------------------------------------------------------------------ +-- Load GenSave + the same generated data the codec uses in production. +------------------------------------------------------------------------ +local GenSave = require("src.save_convert.GenSave") +local charmap = loadfile("src/save_convert/data/charmap.lua")() +GenSave.setCharmap(charmap) +local data = { + pokemon = loadfile("data/generated/pokemon.lua")(), + moves = loadfile("data/generated/moves.lua")(), + items = loadfile("data/generated/items.lua")(), + maps = loadfile("data/generated/maps.lua")(), + eventFlags = loadfile("src/save_convert/data/event_flags.lua")(), +} +local cw = GenSave.crosswalks(data) + +------------------------------------------------------------------------ +-- Load the fixture. +------------------------------------------------------------------------ +local fixturePath = os.getenv("POKEPORT_SAV_FIXTURE") +if not fixturePath then + io.stderr:write("POKEPORT_SAV_FIXTURE is not set (point it at a 32768-byte .sav)\n") + os.exit(2) +end +local ff, oerr = io.open(fixturePath, "rb") +if not ff then + io.stderr:write("cannot open POKEPORT_SAV_FIXTURE: " .. tostring(oerr) .. "\n") + os.exit(2) +end +local rawStr = ff:read("*a"); ff:close() +if #rawStr ~= GenSave.SAVE_SIZE then + io.stderr:write(("fixture is %d bytes, expected %d\n"):format(#rawStr, GenSave.SAVE_SIZE)) + os.exit(2) +end + +------------------------------------------------------------------------ +-- Parse the SAME bytes through both codecs. +------------------------------------------------------------------------ +local gen = GenSave.decode(rawStr, data) + +local gen1 = dofile("tools/save_convert/vendor/gen1lib.lua") +local vbuf = gen1.string_to_bytes(rawStr) -- vendor's 1-indexed byte array +local ven = gen1.parse_save(vbuf) + +-- Raw readers over the vendor's byte buffer (fileOffset -> byte). Used as an +-- independent oracle for the fields the vendor leaves inside raw_base64. +-- These numeric offsets are exactly the ones gen1lib uses internally (its +-- M.OFS), transcribed here so the oracle is anchored to the VENDOR's layout, +-- not GenSave's. +local RAW = { + DexCaught = 0x25A3, DexSeen = 0x25B6, Items = 0x25C9, Options = 0x2601, + PCItems = 0x27E6, +} +local function rb(off) return vbuf[off + 1] end -- raw byte at file offset +local function dexbit_lsb(base, idx) return ((rb(base + (idx // 8)) >> (idx % 8)) & 1) == 1 end + +------------------------------------------------------------------------ +-- Diff collector. +------------------------------------------------------------------------ +local fails, notes = {}, {} +local function fail(field, msg) fails[#fails + 1] = { field = field, msg = msg } end +local function eq(field, a, b, ctx) + if a ~= b then + fail(field, ("%s: GenSave=%s vendor=%s"):format(ctx or "", tostring(a), tostring(b))) + return false + end + return true +end +local function note(msg) notes[#notes + 1] = msg end + +local NAME_LEN = 11 -- GenSave NAME_LENGTH == vendor STRING_LENGTH +-- Name equivalence. GenSave and the vendor spell a few glyphs differently: +-- GenSave uses bracket control tokens ("" for byte 0xE8, "" for +-- the 0x5D in-game-trade OT marker); the vendor uses the raw Unicode glyph +-- ("\u{2024}") / "*". These are the SAME underlying Gen1 byte, so instead of +-- comparing the decoded text we canonicalize each side back to its 11-byte +-- Gen1 sequence via its own table and compare THOSE. A genuine offset or +-- coverage bug (wrong name bytes) still surfaces as a byte-sequence diff. +local function genNameToBytes(text) -- mirrors GenSave's local encodeName + local out, i, pos = {}, 0, 1 + text = text or "" + while i < NAME_LEN - 1 and pos <= #text do + local bracket = text:match("^(<[^<>]*>)", pos) + local ch, clen + if bracket and charmap.byToken[bracket] then + ch, clen = bracket, #bracket + else + local b0 = text:byte(pos) + clen = (b0 < 0x80 and 1) or (b0 < 0xE0 and 2) or (b0 < 0xF0 and 3) or 4 + ch = text:sub(pos, pos + clen - 1) + end + out[i + 1] = string.char(charmap.byToken[ch] or charmap.byToken["?"] or 0x50) + i, pos = i + 1, pos + clen + end + for j = i, NAME_LEN - 1 do out[j + 1] = string.char(0x50) end + return table.concat(out) +end +local function venNameToBytes(text) -- via the vendor's own encoder + local b = {} + gen1.encode_string(b, 0, NAME_LEN, text or "") + local out = {} + for k = 1, NAME_LEN do out[k] = string.char((b[k] or 0x50) & 0xFF) end + return table.concat(out) +end +local repNoted = {} +local function compareName(field, g, v, ctx) + if g == v then return end + if genNameToBytes(g) == venNameToBytes(v) then + local key = tostring(g) .. "|" .. tostring(v) + if not repNoted[key] then + repNoted[key] = true + note(("name glyph representation differs, bytes identical: GenSave %q == vendor %q") + :format(tostring(g), tostring(v))) + end + else + fail(field, ("%s: GenSave=%q vendor=%q (byte sequences differ)") + :format(ctx or "", tostring(g), tostring(v))) + end +end + +------------------------------------------------------------------------ +-- 1. Trainer block (vendor decodes all of these). +------------------------------------------------------------------------ +compareName("player.name", gen.player.name, ven.trainer.name, "player name") +eq("player.id", gen.player.id, ven.trainer.id, "trainer id") +compareName("player.rival", gen.player.rival, ven.trainer.rival_name, "rival name") +eq("money", gen.money, ven.trainer.money, "money") +eq("coins", gen.coins, ven.trainer.coins, "coins") + +-- badges: GenSave exposes them as truthy inventory entries; vendor gives the +-- raw wObtainedBadges byte. Compare bit for bit (BIT_BOULDERBADGE=0 .. =7). +local BADGE = { [0]="BOULDERBADGE",[1]="CASCADEBADGE",[2]="THUNDERBADGE", + [3]="RAINBOWBADGE",[4]="SOULBADGE",[5]="MARSHBADGE",[6]="VOLCANOBADGE",[7]="EARTHBADGE" } +for i = 0, 7 do + local vset = ((ven.trainer.badges >> i) & 1) == 1 + local gset = gen.inventory[BADGE[i]] == 1 + eq("badges." .. BADGE[i], gset, vset, "badge bit " .. i) +end + +------------------------------------------------------------------------ +-- 2. Play time (vendor decodes H/M/S/F/maxed; GenSave folds to one float). +------------------------------------------------------------------------ +do + local pt = ven.trainer.play_time + local expected = pt.hours * 3600 + pt.minutes * 60 + pt.seconds + pt.frames / 60 + if math.abs(gen.playTime - expected) > 1e-6 then + fail("playTime", ("GenSave=%s vendor=%dh%02dm%02ds%02df"):format( + tostring(gen.playTime), pt.hours, pt.minutes, pt.seconds, pt.frames)) + end +end + +------------------------------------------------------------------------ +-- 3. Options -- GenSave does not model this field; verify that's the only +-- reason for silence, and record the raw value the vendor would carry. +------------------------------------------------------------------------ +if gen.options == nil then + note(("options: not modeled by GenSave (raw wOptions byte = 0x%02X, preserved " + .. "verbatim via the export template)"):format(rb(RAW.Options))) +else + eq("options", gen.options, rb(RAW.Options), "options byte") +end + +------------------------------------------------------------------------ +-- 4. Current box + map/coords. +------------------------------------------------------------------------ +eq("currentBox", gen.currentBox, ven.current_box, "current box number") +-- Map + coords: the vendor does not decode wCurMap/X/Y at all. Sanity-check +-- GenSave's values against its own data tables (no second source available). +if gen.player.map and data.maps[gen.player.map] then + note(("map/coords: vendor does not decode these; GenSave says map=%s x=%s y=%s " + .. "(a valid map id)"):format(gen.player.map, tostring(gen.player.x), tostring(gen.player.y))) +else + fail("player.map", "GenSave decoded an unknown/absent current map") +end + +------------------------------------------------------------------------ +-- 5. Bag + PC items -- vendor leaves these in raw_base64; read the raw +-- (id,qty) lists straight from its byte buffer as the oracle. +------------------------------------------------------------------------ +local function rawItemList(base, capacity) + local count = rb(base) + local list = {} + for i = 0, math.min(count, capacity) - 1 do + local idb = rb(base + 1 + i * 2) + if idb == 0xFF then break end + list[#list + 1] = { idByte = idb, qty = rb(base + 2 + i * 2) } + end + return count, list +end + +-- Bag: GenSave preserves order in save.bagOrder; compare id+qty in sequence. +do + local count, raw = rawItemList(RAW.Items, 20) + eq("bag.count", #gen.bagOrder, count, "bag item count") + local n = math.max(#gen.bagOrder, #raw) + for i = 1, n do + local gid = gen.bagOrder[i] + local r = raw[i] + local gidx = gid and cw.itemsIndex[gid] + eq("bag[" .. i .. "].id", gidx, r and r.idByte, "bag slot " .. i .. " item") + if gid and r then eq("bag[" .. i .. "].qty", gen.inventory[gid], r.qty, "bag slot " .. i .. " qty") end + end +end + +-- PC: GenSave keeps only a {id->qty} map (order dropped); compare as a set. +do + local count, raw = rawItemList(RAW.PCItems, 50) + local rawMap, rawCount = {}, 0 + for _, r in ipairs(raw) do + local id = cw.itemsByIndex[r.idByte] + if id then rawMap[id] = r.qty; rawCount = rawCount + 1 end + end + local genCount = 0 + for id, qty in pairs(gen.pcItems) do + genCount = genCount + 1 + eq("pcItems." .. id, qty, rawMap[id], "PC item " .. id .. " qty") + end + eq("pcItems.count", genCount, rawCount, "PC item count") +end + +------------------------------------------------------------------------ +-- 6. Pokedex owned/seen -- vendor leaves these in raw_base64; decode the +-- raw bitsets LSB-first (pokered/PKHeX convention) as the oracle. +------------------------------------------------------------------------ +do + for dex, species in pairs(cw.pokemonByDex) do + if dex >= 1 and dex <= 151 then + local rawOwned = dexbit_lsb(RAW.DexCaught, dex - 1) + local rawSeen = dexbit_lsb(RAW.DexSeen, dex - 1) + eq("dex.owned." .. species, gen.pokedex.owned[species] == true, rawOwned, "owned " .. species) + eq("dex.seen." .. species, gen.pokedex.seen[species] == true, rawSeen, "seen " .. species) + end + end + -- physical invariant: every possessed species must be owned AND seen. + local possessed = {} + for _, m in ipairs(gen.party) do possessed[m.species] = true end + for b = 1, 12 do for _, m in ipairs(gen.boxes[b]) do possessed[m.species] = true end end + for sp in pairs(possessed) do + if not gen.pokedex.owned[sp] then fail("dex.invariant", sp .. " is possessed but not owned") end + if not gen.pokedex.seen[sp] then fail("dex.invariant", sp .. " is possessed but not seen") end + end +end + +------------------------------------------------------------------------ +-- 7. Event flags -- vendor leaves these in raw_base64; there is no vendor +-- field to compare against, but the physical invariant that the very +-- first story flag (EVENT_FOLLOWED_OAK_INTO_LAB, bit 0) is set on any +-- save past the intro is a real LSB-vs-MSB discriminator. +------------------------------------------------------------------------ +do + local nflags = 0 + for _ in pairs(gen.flags) do nflags = nflags + 1 end + if nflags == 0 then fail("flags", "no event flags decoded at all") end + if not gen.flags.EVENT_FOLLOWED_OAK_INTO_LAB then + fail("flags.EVENT_FOLLOWED_OAK_INTO_LAB", + "bit 0 not set -- expected on any save past the intro (LSB-order check)") + end +end + +------------------------------------------------------------------------ +-- 8. Mon-by-mon comparison (party + boxes) against the vendor's PK1 parse. +------------------------------------------------------------------------ +local STATUS_BIT = { PSN = 3, BRN = 4, FRZ = 5, PAR = 6 } +local function statusFromByte(b) + if (b & 7) > 0 then return "SLP" end + for name, bi in pairs(STATUS_BIT) do if (b & (1 << bi)) ~= 0 then return name end end + return nil +end + +local function compareMon(tag, g, v, isParty) + if not g or not v then + fail(tag, "one side missing this slot (GenSave=" .. tostring(g) .. " vendor=" .. tostring(v) .. ")") + return + end + -- species: GenSave id string -> national dex must equal vendor's dex number. + eq(tag .. ".species", cw.pokemonDex[g.species], v.species, tag .. " species (" .. tostring(g.species) .. ")") + eq(tag .. ".level", g.level, v.level, tag .. " level") + eq(tag .. ".hp", g.hp, v.current_hp, tag .. " current HP") + eq(tag .. ".otId", g.otId, v.ot_id, tag .. " OT id") + eq(tag .. ".exp", g.exp, v.exp, tag .. " exp") + eq(tag .. ".catchRate", g.catchRate, v.catch_rate, tag .. " catch rate") + compareName(tag .. ".ot", g.ot, v.ot_name, tag .. " OT name") + compareName(tag .. ".nickname", g.nickname, v.nickname, tag .. " nickname") + -- DVs / IVs + eq(tag .. ".dv.atk", g.dvs.attack, v.ivs.atk, tag .. " DV atk") + eq(tag .. ".dv.def", g.dvs.defense, v.ivs.def, tag .. " DV def") + eq(tag .. ".dv.spe", g.dvs.speed, v.ivs.spe, tag .. " DV spe") + eq(tag .. ".dv.spc", g.dvs.special, v.ivs.spc, tag .. " DV spc") + eq(tag .. ".dv.hp", g.dvs.hp, v.ivs.hp, tag .. " DV hp") + -- stat EXP / EVs + eq(tag .. ".ev.hp", g.statExp.hp, v.evs.hp, tag .. " statExp hp") + eq(tag .. ".ev.atk", g.statExp.attack, v.evs.atk, tag .. " statExp atk") + eq(tag .. ".ev.def", g.statExp.defense, v.evs.def, tag .. " statExp def") + eq(tag .. ".ev.spe", g.statExp.speed, v.evs.spe, tag .. " statExp spe") + eq(tag .. ".ev.spc", g.statExp.special, v.evs.spc, tag .. " statExp spc") + -- status: GenSave string vs vendor raw byte (decoded the same way) + eq(tag .. ".status", g.status, statusFromByte(v.status_condition), tag .. " status") + -- moves: GenSave keeps only nonzero slots, in order; line them up against + -- the vendor's 4 raw slots skipping zeros. + local vmoves = {} + for j = 1, 4 do + if v.moves[j] and v.moves[j] > 0 then + vmoves[#vmoves + 1] = { idx = v.moves[j], pp = v.pp[j], ppUps = v.pp_ups[j] } + end + end + eq(tag .. ".moves.count", #g.moves, #vmoves, tag .. " move count") + for j = 1, math.max(#g.moves, #vmoves) do + local gm, vm = g.moves[j], vmoves[j] + local gidx = gm and cw.movesIndex[gm.id] + eq(tag .. ".move[" .. j .. "].id", gidx, vm and vm.idx, tag .. " move " .. j) + if gm and vm then + eq(tag .. ".move[" .. j .. "].pp", gm.pp, vm.pp, tag .. " move " .. j .. " PP") + eq(tag .. ".move[" .. j .. "].ppUps", gm.ppUps, vm.ppUps, tag .. " move " .. j .. " PP-ups") + end + end + -- party-only computed stats + if isParty then + eq(tag .. ".stat.hp", g.stats.hp, v.stats.hp_max, tag .. " stat maxHP") + eq(tag .. ".stat.atk", g.stats.attack, v.stats.atk, tag .. " stat atk") + eq(tag .. ".stat.def", g.stats.defense, v.stats.def, tag .. " stat def") + eq(tag .. ".stat.spe", g.stats.speed, v.stats.spe, tag .. " stat spe") + eq(tag .. ".stat.spc", g.stats.special, v.stats.spc, tag .. " stat spc") + end +end + +-- party +eq("party.count", #gen.party, #ven.party, "party size") +for i = 1, math.max(#gen.party, #ven.party) do + compareMon("party[" .. i .. "]", gen.party[i], ven.party[i], true) +end + +-- boxes: the vendor returns all 12 boxes 1..12 (current box read from its +-- live offset). GenSave stores them the same 1-based way. +for b = 1, 12 do + local vbox = ven.boxes[b] and ven.boxes[b].pokemon or {} + local gbox = gen.boxes[b] or {} + eq("box[" .. b .. "].count", #gbox, #vbox, "box " .. b .. " size") + for i = 1, math.max(#gbox, #vbox) do + compareMon(("box[%d][%d]"):format(b, i), gbox[i], vbox[i], false) + end +end + +------------------------------------------------------------------------ +-- 9. Checksum cross-check: both codecs sum [0x2598,0x3523). The fixture is a +-- real save, so GenSave's stored-checksum verification must pass, and the +-- vendor's independent recompute must match the same stored byte. +------------------------------------------------------------------------ +do + if #gen.warnings > 0 then + for _, w in ipairs(gen.warnings) do fail("checksum", "GenSave warning: " .. w) end + end + local sum = 0 + for i = 0x2598, 0x3523 - 1 do sum = (sum + rb(i)) & 0xFF end + local vend = (255 - sum) & 0xFF + eq("checksum.byte", rb(0x3523), vend, "stored main checksum vs vendor recompute") +end + +------------------------------------------------------------------------ +-- Report. +------------------------------------------------------------------------ +print("== crosscheck: GenSave.decode vs vendor gen1lib on the real fixture ==") +print(("player=%s id=%d money=%d coins=%d party=%d") + :format(gen.player.name, gen.player.id, gen.money, gen.coins, #gen.party)) +do + local owned, seen = 0, 0 + for _ in pairs(gen.pokedex.owned) do owned = owned + 1 end + for _ in pairs(gen.pokedex.seen) do seen = seen + 1 end + local boxed = 0 + for b = 1, 12 do boxed = boxed + #gen.boxes[b] end + print(("dex owned/seen=%d/%d boxed=%d currentBox=%d") + :format(owned, seen, boxed, gen.currentBox)) +end + +if #notes > 0 then + print("\n-- notes (fields the vendor does not model; checked against raw bytes) --") + for _, m in ipairs(notes) do print(" * " .. m) end +end + +if #fails == 0 then + print("\nALL SHARED FIELDS AGREE -- GenSave.decode matches the vendor parser (and\n" + .. "the raw-byte oracle for the fields the vendor leaves opaque).") + os.exit(0) +else + print(("\n%d MISMATCH(ES):"):format(#fails)) + for _, f in ipairs(fails) do + print((" [%s] %s"):format(f.field, f.msg)) + end + os.exit(1) +end diff --git a/tools/save_convert/vendor/gen1lib.lua b/tools/save_convert/vendor/gen1lib.lua new file mode 100644 index 00000000..fd2eb73a --- /dev/null +++ b/tools/save_convert/vendor/gen1lib.lua @@ -0,0 +1,884 @@ +-- gen1lib.lua +-- +-- Shared library for converting Pokemon Generation 1 (Red/Blue/Yellow) +-- save files between their native binary format and JSON. +-- +-- Scope: International (non-Japanese) 32768-byte (0x8000) save files only. +-- Logic ported from PKHeX.Core: +-- PKHeX.Core/Saves/SAV1.cs, SAV1Offsets.cs +-- PKHeX.Core/PKM/PK1.cs, GBPKM.cs, GBPKML.cs +-- PKHeX.Core/PKM/Strings/StringConverter1.cs +-- PKHeX.Core/PKM/Util/Conversion/SpeciesConverter.cs +-- PKHeX.Core/Saves/Storage/PokeList1.cs +-- +-- Requires Lua 5.3+ (native bitwise operators, floor division, utf8 library). + +local M = {} + +------------------------------------------------------------------------ +-- Layout constants (International save layout only) +------------------------------------------------------------------------ + +M.SIZE_SAVE = 0x8000 + +M.OFS = { + OT = 0x2598, + DexCaught = 0x25A3, + DexSeen = 0x25B6, + Items = 0x25C9, + Money = 0x25F3, + Rival = 0x25F6, + Options = 0x2601, + Badges = 0x2602, + TID16 = 0x2605, + PikaFriendship = 0x271C, + PikaBeachScore = 0x2741, + PrinterBrightness = 0x2744, + PCItems = 0x27E6, + CurrentBoxIndex = 0x284C, + HallOfFameCount = 0x284E, + Coin = 0x2850, + ObjectSpawnFlags = 0x2852, + EventWork = 0x289C, + Starter = 0x29C3, + EventFlag = 0x29F3, + PlayTime = 0x2CED, + Daycare = 0x2CF4, + Party = 0x2F2C, + CurrentBox = 0x30C0, + ChecksumOfs = 0x3523, +} + +M.BOX_COUNT = 12 +M.BOX_SLOT_COUNT = 20 +M.STRING_LENGTH = 11 -- OT name / rival name / nickname raw buffer length (incl. terminator) +M.SIZE_STORED = 33 -- boxed PK1 struct size +M.SIZE_PARTY = 44 -- party PK1 struct size + +M.SIZE_BOX_LIST = ((M.STRING_LENGTH * 2) + M.SIZE_STORED + 1) * M.BOX_SLOT_COUNT + 2 +M.SIZE_PARTY_LIST = ((M.STRING_LENGTH * 2) + M.SIZE_PARTY + 1) * 6 + 2 + +-- Non-current boxes live in two banked arrays at 0x4000 (boxes 1-6) and +-- 0x6000 (boxes 7-12). Whichever box is "current" is instead read from/ +-- written to M.OFS.CurrentBox, and mirrored back into its normal slot here +-- on save (SAV1.cs Initialize()/GetFinalData()). +function M.box_bank_offset(boxIndexZero) + local half = M.BOX_COUNT // 2 + if boxIndexZero < half then + return 0x4000 + boxIndexZero * M.SIZE_BOX_LIST + else + return 0x6000 + (boxIndexZero - half) * M.SIZE_BOX_LIST + end +end + +------------------------------------------------------------------------ +-- Byte buffer helpers +-- `buf` is a plain Lua array of integers 0-255, 1-indexed, where +-- buf[fileOffset + 1] holds the byte at `fileOffset`. +------------------------------------------------------------------------ + +local function get(buf, ofs) return buf[ofs + 1] end +local function set(buf, ofs, v) buf[ofs + 1] = v & 0xFF end + +local function read_u16be(buf, ofs) return (get(buf, ofs) << 8) | get(buf, ofs + 1) end +local function write_u16be(buf, ofs, v) + set(buf, ofs, (v >> 8) & 0xFF) + set(buf, ofs + 1, v & 0xFF) +end + +local function read_u24be(buf, ofs) + return (get(buf, ofs) << 16) | (get(buf, ofs + 1) << 8) | get(buf, ofs + 2) +end +local function write_u24be(buf, ofs, v) + set(buf, ofs, (v >> 16) & 0xFF) + set(buf, ofs + 1, (v >> 8) & 0xFF) + set(buf, ofs + 2, v & 0xFF) +end + +-- n-byte binary-coded decimal (two decimal digits per byte). +local function bcd_read(buf, ofs, n, littleEndian) + local bytes = {} + for i = 0, n - 1 do bytes[i + 1] = get(buf, ofs + i) end + if littleEndian then + local rev = {} + for i = 1, n do rev[i] = bytes[n - i + 1] end + bytes = rev + end + local v = 0 + for _, b in ipairs(bytes) do + v = v * 100 + ((b >> 4) * 10) + (b & 0xF) + end + return v +end + +local function bcd_write(buf, ofs, n, littleEndian, value) + local bytes = {} + local v = value + for i = n, 1, -1 do + local d = v % 100 + v = (v - d) // 100 + bytes[i] = ((d // 10) << 4) | (d % 10) + end + if littleEndian then + local rev = {} + for i = 1, n do rev[i] = bytes[n - i + 1] end + bytes = rev + end + for i = 0, n - 1 do set(buf, ofs + i, bytes[i + 1]) end +end + +------------------------------------------------------------------------ +-- Species: Gen 1 internal index <-> National Dex ID +-- (PKHeX.Core/PKM/Util/Conversion/SpeciesConverter.cs, Table1*) +------------------------------------------------------------------------ + +-- index (1-based) = National Dex ID + 1; value = Gen1 internal species byte +local NAT_TO_INTERNAL = { +0x00, 0x99, 0x09, 0x9A, 0xB0, 0xB2, 0xB4, 0xB1, 0xB3, 0x1C, 0x7B, 0x7C, 0x7D, 0x70, 0x71, 0x72, +0x24, 0x96, 0x97, 0xA5, 0xA6, 0x05, 0x23, 0x6C, 0x2D, 0x54, 0x55, 0x60, 0x61, 0x0F, 0xA8, 0x10, +0x03, 0xA7, 0x07, 0x04, 0x8E, 0x52, 0x53, 0x64, 0x65, 0x6B, 0x82, 0xB9, 0xBA, 0xBB, 0x6D, 0x2E, +0x41, 0x77, 0x3B, 0x76, 0x4D, 0x90, 0x2F, 0x80, 0x39, 0x75, 0x21, 0x14, 0x47, 0x6E, 0x6F, 0x94, +0x26, 0x95, 0x6A, 0x29, 0x7E, 0xBC, 0xBD, 0xBE, 0x18, 0x9B, 0xA9, 0x27, 0x31, 0xA3, 0xA4, 0x25, +0x08, 0xAD, 0x36, 0x40, 0x46, 0x74, 0x3A, 0x78, 0x0D, 0x88, 0x17, 0x8B, 0x19, 0x93, 0x0E, 0x22, +0x30, 0x81, 0x4E, 0x8A, 0x06, 0x8D, 0x0C, 0x0A, 0x11, 0x91, 0x2B, 0x2C, 0x0B, 0x37, 0x8F, 0x12, +0x01, 0x28, 0x1E, 0x02, 0x5C, 0x5D, 0x9D, 0x9E, 0x1B, 0x98, 0x2A, 0x1A, 0x48, 0x35, 0x33, 0x1D, +0x3C, 0x85, 0x16, 0x13, 0x4C, 0x66, 0x69, 0x68, 0x67, 0xAA, 0x62, 0x63, 0x5A, 0x5B, 0xAB, 0x84, +0x4A, 0x4B, 0x49, 0x58, 0x59, 0x42, 0x83, 0x15, +0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +} + +-- index (1-based) = Gen1 internal species byte + 1; value = National Dex ID +local INTERNAL_TO_NAT = { +0x00, 0x70, 0x73, 0x20, 0x23, 0x15, 0x64, 0x22, 0x50, 0x02, 0x67, 0x6C, 0x66, 0x58, 0x5E, 0x1D, +0x1F, 0x68, 0x6F, 0x83, 0x3B, 0x97, 0x82, 0x5A, 0x48, 0x5C, 0x7B, 0x78, 0x09, 0x7F, 0x72, 0x00, +0x00, 0x3A, 0x5F, 0x16, 0x10, 0x4F, 0x40, 0x4B, 0x71, 0x43, 0x7A, 0x6A, 0x6B, 0x18, 0x2F, 0x36, +0x60, 0x4C, 0x00, 0x7E, 0x00, 0x7D, 0x52, 0x6D, 0x00, 0x38, 0x56, 0x32, 0x80, 0x00, 0x00, 0x00, +0x53, 0x30, 0x95, 0x00, 0x00, 0x00, 0x54, 0x3C, 0x7C, 0x92, 0x90, 0x91, 0x84, 0x34, 0x62, 0x00, +0x00, 0x00, 0x25, 0x26, 0x19, 0x1A, 0x00, 0x00, 0x93, 0x94, 0x8C, 0x8D, 0x74, 0x75, 0x00, 0x00, +0x1B, 0x1C, 0x8A, 0x8B, 0x27, 0x28, 0x85, 0x88, 0x87, 0x86, 0x42, 0x29, 0x17, 0x2E, 0x3D, 0x3E, +0x0D, 0x0E, 0x0F, 0x00, 0x55, 0x39, 0x33, 0x31, 0x57, 0x00, 0x00, 0x0A, 0x0B, 0x0C, 0x44, 0x00, +0x37, 0x61, 0x2A, 0x96, 0x8F, 0x81, 0x00, 0x00, 0x59, 0x00, 0x63, 0x5B, 0x00, 0x65, 0x24, 0x6E, +0x35, 0x69, 0x00, 0x5D, 0x3F, 0x41, 0x11, 0x12, 0x79, 0x01, 0x03, 0x49, 0x00, 0x76, 0x77, 0x00, +0x00, 0x00, 0x00, 0x4D, 0x4E, 0x13, 0x14, 0x21, 0x1E, 0x4A, 0x89, 0x8E, 0x00, 0x51, 0x00, 0x00, +0x04, 0x07, 0x05, 0x08, 0x06, 0x00, 0x00, 0x00, 0x00, 0x2B, 0x2C, 0x2D, 0x45, 0x46, 0x47, 0x00, +0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +} + +function M.national_to_internal(species) + return NAT_TO_INTERNAL[species + 1] or 0 +end + +function M.internal_to_national(raw) + return INTERNAL_TO_NAT[raw + 1] or 0 +end + +------------------------------------------------------------------------ +-- Species names (English, National Dex 1-151; PKHeX.Core Resources/text/other/en/text_Species_en.txt) +-- Informational only ("species_name" in JSON) -- ignored when converting back to a save. +------------------------------------------------------------------------ + +M.SPECIES_NAMES = { + [1] = "Bulbasaur", [2] = "Ivysaur", [3] = "Venusaur", [4] = "Charmander", + [5] = "Charmeleon", [6] = "Charizard", [7] = "Squirtle", [8] = "Wartortle", + [9] = "Blastoise", [10] = "Caterpie", [11] = "Metapod", [12] = "Butterfree", + [13] = "Weedle", [14] = "Kakuna", [15] = "Beedrill", [16] = "Pidgey", + [17] = "Pidgeotto", [18] = "Pidgeot", [19] = "Rattata", [20] = "Raticate", + [21] = "Spearow", [22] = "Fearow", [23] = "Ekans", [24] = "Arbok", + [25] = "Pikachu", [26] = "Raichu", [27] = "Sandshrew", [28] = "Sandslash", + [29] = "Nidoran\u{2640}", [30] = "Nidorina", [31] = "Nidoqueen", [32] = "Nidoran\u{2642}", + [33] = "Nidorino", [34] = "Nidoking", [35] = "Clefairy", [36] = "Clefable", + [37] = "Vulpix", [38] = "Ninetales", [39] = "Jigglypuff", [40] = "Wigglytuff", + [41] = "Zubat", [42] = "Golbat", [43] = "Oddish", [44] = "Gloom", + [45] = "Vileplume", [46] = "Paras", [47] = "Parasect", [48] = "Venonat", + [49] = "Venomoth", [50] = "Diglett", [51] = "Dugtrio", [52] = "Meowth", + [53] = "Persian", [54] = "Psyduck", [55] = "Golduck", [56] = "Mankey", + [57] = "Primeape", [58] = "Growlithe", [59] = "Arcanine", [60] = "Poliwag", + [61] = "Poliwhirl", [62] = "Poliwrath", [63] = "Abra", [64] = "Kadabra", + [65] = "Alakazam", [66] = "Machop", [67] = "Machoke", [68] = "Machamp", + [69] = "Bellsprout", [70] = "Weepinbell", [71] = "Victreebel", [72] = "Tentacool", + [73] = "Tentacruel", [74] = "Geodude", [75] = "Graveler", [76] = "Golem", + [77] = "Ponyta", [78] = "Rapidash", [79] = "Slowpoke", [80] = "Slowbro", + [81] = "Magnemite", [82] = "Magneton", [83] = "Farfetch\u{2019}d", [84] = "Doduo", + [85] = "Dodrio", [86] = "Seel", [87] = "Dewgong", [88] = "Grimer", + [89] = "Muk", [90] = "Shellder", [91] = "Cloyster", [92] = "Gastly", + [93] = "Haunter", [94] = "Gengar", [95] = "Onix", [96] = "Drowzee", + [97] = "Hypno", [98] = "Krabby", [99] = "Kingler", [100] = "Voltorb", + [101] = "Electrode", [102] = "Exeggcute", [103] = "Exeggutor", [104] = "Cubone", + [105] = "Marowak", [106] = "Hitmonlee", [107] = "Hitmonchan", [108] = "Lickitung", + [109] = "Koffing", [110] = "Weezing", [111] = "Rhyhorn", [112] = "Rhydon", + [113] = "Chansey", [114] = "Tangela", [115] = "Kangaskhan", [116] = "Horsea", + [117] = "Seadra", [118] = "Goldeen", [119] = "Seaking", [120] = "Staryu", + [121] = "Starmie", [122] = "Mr. Mime", [123] = "Scyther", [124] = "Jynx", + [125] = "Electabuzz", [126] = "Magmar", [127] = "Pinsir", [128] = "Tauros", + [129] = "Magikarp", [130] = "Gyarados", [131] = "Lapras", [132] = "Ditto", + [133] = "Eevee", [134] = "Vaporeon", [135] = "Jolteon", [136] = "Flareon", + [137] = "Porygon", [138] = "Omanyte", [139] = "Omastar", [140] = "Kabuto", + [141] = "Kabutops", [142] = "Aerodactyl", [143] = "Snorlax", [144] = "Articuno", + [145] = "Zapdos", [146] = "Moltres", [147] = "Dratini", [148] = "Dragonair", + [149] = "Dragonite", [150] = "Mewtwo", [151] = "Mew", +} + +------------------------------------------------------------------------ +-- Gen 1 text encoding, international/English table +-- (PKHeX.Core/PKM/Strings/StringConverter1.cs, TableEN) +-- `false` marks a byte that decodes to the string terminator (NUL). +------------------------------------------------------------------------ + +local TABLE_EN = { + -- 0x00-0x0F + false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, + -- 0x10-0x1F + false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, + -- 0x20-0x2F + false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, + -- 0x30-0x3F + false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, + -- 0x40-0x4F + false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, + -- 0x50-0x5F (0x50 terminator, 0x5D in-game-trade marker) + false, false, false, false, false, false, false, false, false, false, false, false, false, "*", false, false, + -- 0x60-0x6F + false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, + -- 0x70-0x7F + "@", "#", "\u{201C}", "\u{201D}", false, "\u{2026}", false, false, false, "\u{250C}", "\u{2500}", "\u{2510}", "\u{2502}", "\u{2514}", "\u{2518}", " ", + -- 0x80-0x8F + "A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", + -- 0x90-0x9F + "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", "(", ")", ":", ";", "[", "]", + -- 0xA0-0xAF + "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", + -- 0xB0-0xBF + "q", "r", "s", "t", "u", "v", "w", "x", "y", "z", "\u{E0}", "\u{E8}", "\u{E9}", "\u{F9}", "\u{C0}", "\u{C1}", + -- 0xC0-0xCF + "\u{C4}", "\u{D6}", "\u{DC}", "\u{E4}", "\u{F6}", "\u{FC}", "\u{C8}", "\u{C9}", "\u{CC}", "\u{CD}", "\u{D1}", "\u{D2}", "\u{D3}", "\u{D9}", "\u{DA}", "\u{E1}", + -- 0xD0-0xDF + "\u{EC}", "\u{ED}", "\u{F1}", "\u{F2}", "\u{F3}", "\u{FA}", "\u{BA}", false, false, false, false, false, false, false, "\u{2190}", "'", + -- 0xE0-0xEF + "\u{2019}", "{", "}", "-", false, false, "?", "!", "\u{2024}", "&", "%", "\u{2192}", "\u{25B7}", "\u{25B6}", "\u{25BC}", "\u{2642}", + -- 0xF0-0xFF + "\u{A5}", "\u{D7}", ".", "/", ",", "\u{2640}", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", +} + +local CHAR_TO_BYTE = {} +for b = 0, 255 do + local c = TABLE_EN[b + 1] + if c then CHAR_TO_BYTE[c] = b end +end + +-- Decodes a fixed-length Gen 1 string buffer into a Lua (UTF-8) string. +function M.decode_string(buf, ofs, bufLen) + if get(buf, ofs) == 0x5D then return "*" end -- in-game trade OT placeholder + local out = {} + for i = 0, bufLen - 1 do + local c = TABLE_EN[get(buf, ofs + i) + 1] + if not c then break end + out[#out + 1] = c + end + return table.concat(out) +end + +-- Encodes a Lua (UTF-8) string into a fixed-length Gen 1 string buffer, +-- padding the remainder with the 0x50 terminator byte. +function M.encode_string(buf, ofs, bufLen, str) + for i = 0, bufLen - 1 do set(buf, ofs + i, 0x50) end + str = str or "" + if str == "" then return end + if str == "*" then + set(buf, ofs, 0x5D) + if bufLen > 1 then set(buf, ofs + 1, 0x50) end + return + end + local i = 0 + for _, cp in utf8.codes(str) do + if i >= bufLen then break end + local ch = utf8.char(cp) + local b = CHAR_TO_BYTE[ch] + if not b then + error(string.format("character %q is not representable in the Gen 1 international character set", ch)) + end + set(buf, ofs + i, b) + i = i + 1 + end + if i < bufLen then set(buf, ofs + i, 0x50) end +end + +------------------------------------------------------------------------ +-- PK1 struct (PKHeX.Core/PKM/PK1.cs, GBPKM.cs) +-- Offsets below are relative to the start of the 33/44-byte struct. +------------------------------------------------------------------------ + +local function parse_pk1_body(buf, ofs, isParty) + local speciesInternal = get(buf, ofs + 0x00) + local dv16 = read_u16be(buf, ofs + 0x1B) + local ivAtk = (dv16 >> 12) & 0xF + local ivDef = (dv16 >> 8) & 0xF + local ivSpe = (dv16 >> 4) & 0xF + local ivSpc = dv16 & 0xF + local ivHp = ((ivAtk & 1) << 3) | ((ivDef & 1) << 2) | ((ivSpe & 1) << 1) | (ivSpc & 1) + + local pp1b, pp2b, pp3b, pp4b = get(buf, ofs + 0x1D), get(buf, ofs + 0x1E), get(buf, ofs + 0x1F), get(buf, ofs + 0x20) + local species = M.internal_to_national(speciesInternal) + + local pairs_ = { + { "species", species }, + { "species_name", M.SPECIES_NAMES[species] }, + { "nickname", "" }, -- filled in by parse_mon_list; placeholder keeps the key ordered here + { "level", isParty and get(buf, ofs + 0x21) or get(buf, ofs + 0x03) }, + { "ot_name", "" }, -- filled in by parse_mon_list + { "ot_id", read_u16be(buf, ofs + 0x0C) }, + { "current_hp", read_u16be(buf, ofs + 0x01) }, + { "status_condition", get(buf, ofs + 0x04) }, + { "type1", get(buf, ofs + 0x05) }, + { "type2", get(buf, ofs + 0x06) }, + { "catch_rate", get(buf, ofs + 0x07) }, + { "moves", { get(buf, ofs + 0x08), get(buf, ofs + 0x09), get(buf, ofs + 0x0A), get(buf, ofs + 0x0B) } }, + { "pp", { pp1b & 0x3F, pp2b & 0x3F, pp3b & 0x3F, pp4b & 0x3F } }, + { "pp_ups", { (pp1b >> 6) & 0x3, (pp2b >> 6) & 0x3, (pp3b >> 6) & 0x3, (pp4b >> 6) & 0x3 } }, + { "exp", read_u24be(buf, ofs + 0x0E) }, + { "evs", M.omap({ + { "hp", read_u16be(buf, ofs + 0x11) }, { "atk", read_u16be(buf, ofs + 0x13) }, + { "def", read_u16be(buf, ofs + 0x15) }, { "spe", read_u16be(buf, ofs + 0x17) }, + { "spc", read_u16be(buf, ofs + 0x19) }, + }) }, + { "ivs", M.omap({ + { "atk", ivAtk }, { "def", ivDef }, { "spe", ivSpe }, { "spc", ivSpc }, { "hp", ivHp }, + }) }, + } + + if isParty then + pairs_[#pairs_ + 1] = { "stats", M.omap({ + { "hp_max", read_u16be(buf, ofs + 0x22) }, { "atk", read_u16be(buf, ofs + 0x24) }, + { "def", read_u16be(buf, ofs + 0x26) }, { "spe", read_u16be(buf, ofs + 0x28) }, + { "spc", read_u16be(buf, ofs + 0x2A) }, + }) } + end + return M.omap(pairs_) +end + +local function write_pk1_body(buf, ofs, sizeBody, isParty, mon) + for i = 0, sizeBody - 1 do set(buf, ofs + i, 0) end + set(buf, ofs + 0x00, M.national_to_internal(mon.species)) + write_u16be(buf, ofs + 0x01, mon.current_hp or 0) + set(buf, ofs + 0x03, mon.level or 1) + set(buf, ofs + 0x04, mon.status_condition or 0) + set(buf, ofs + 0x05, mon.type1 or 0) + set(buf, ofs + 0x06, mon.type2 or 0) + set(buf, ofs + 0x07, mon.catch_rate or 0) + local moves = mon.moves or {0, 0, 0, 0} + set(buf, ofs + 0x08, moves[1] or 0) + set(buf, ofs + 0x09, moves[2] or 0) + set(buf, ofs + 0x0A, moves[3] or 0) + set(buf, ofs + 0x0B, moves[4] or 0) + write_u16be(buf, ofs + 0x0C, mon.ot_id or 0) + write_u24be(buf, ofs + 0x0E, mon.exp or 0) + local evs = mon.evs or {} + write_u16be(buf, ofs + 0x11, evs.hp or 0) + write_u16be(buf, ofs + 0x13, evs.atk or 0) + write_u16be(buf, ofs + 0x15, evs.def or 0) + write_u16be(buf, ofs + 0x17, evs.spe or 0) + write_u16be(buf, ofs + 0x19, evs.spc or 0) + local ivs = mon.ivs or {} + local dv16 = ((ivs.atk or 0) & 0xF) << 12 | ((ivs.def or 0) & 0xF) << 8 | ((ivs.spe or 0) & 0xF) << 4 | ((ivs.spc or 0) & 0xF) + write_u16be(buf, ofs + 0x1B, dv16) + local pp = mon.pp or {0, 0, 0, 0} + local ppUps = mon.pp_ups or {0, 0, 0, 0} + set(buf, ofs + 0x1D, ((ppUps[1] or 0) & 0x3) << 6 | ((pp[1] or 0) & 0x3F)) + set(buf, ofs + 0x1E, ((ppUps[2] or 0) & 0x3) << 6 | ((pp[2] or 0) & 0x3F)) + set(buf, ofs + 0x1F, ((ppUps[3] or 0) & 0x3) << 6 | ((pp[3] or 0) & 0x3F)) + set(buf, ofs + 0x20, ((ppUps[4] or 0) & 0x3) << 6 | ((pp[4] or 0) & 0x3F)) + + if isParty then + set(buf, ofs + 0x21, mon.level or 1) + local stats = mon.stats or {} + write_u16be(buf, ofs + 0x22, stats.hp_max or 0) + write_u16be(buf, ofs + 0x24, stats.atk or 0) + write_u16be(buf, ofs + 0x26, stats.def or 0) + write_u16be(buf, ofs + 0x28, stats.spe or 0) + write_u16be(buf, ofs + 0x2A, stats.spc or 0) + end +end + +------------------------------------------------------------------------ +-- Packed box/party lists (PKHeX.Core/Saves/Storage/PokeList1.cs) +-- u8 count of occupied slots +-- u8[capacity+1] per-slot species marker (0xFF = empty); last byte always 0xFF +-- pk1[capacity] PK1 struct data (no strings), `sizeBody` bytes each +-- str[capacity] Original Trainer name table, STRING_LENGTH bytes each +-- str[capacity] Nickname table, STRING_LENGTH bytes each +------------------------------------------------------------------------ + +local function parse_mon_list(buf, absBase, capacity, sizeBody, isParty) + local count = get(buf, absBase) + if count > capacity then count = capacity end + local start = 1 + (capacity + 1) + local bodyBase = absBase + start + local otBase = bodyBase + sizeBody * capacity + local nickBase = otBase + capacity * M.STRING_LENGTH + + local list = {} + for i = 0, count - 1 do + local mon = parse_pk1_body(buf, bodyBase + sizeBody * i, isParty) + mon.ot_name = M.decode_string(buf, otBase + M.STRING_LENGTH * i, M.STRING_LENGTH) + mon.nickname = M.decode_string(buf, nickBase + M.STRING_LENGTH * i, M.STRING_LENGTH) + list[#list + 1] = mon + end + return list +end + +local function write_mon_list(buf, absBase, capacity, sizeBody, isParty, monList) + local count = #monList + if count > capacity then + error(string.format("list has %d Pokemon, but capacity is only %d", count, capacity)) + end + set(buf, absBase, count) + + local start = 1 + (capacity + 1) + for i = 0, capacity - 1 do + local mon = monList[i + 1] + set(buf, absBase + 1 + i, mon and M.national_to_internal(mon.species) or 0xFF) + end + set(buf, absBase + 1 + capacity, 0xFF) -- list terminator, always present + + local bodyBase = absBase + start + local otBase = bodyBase + sizeBody * capacity + local nickBase = otBase + capacity * M.STRING_LENGTH + + -- Only touch bytes for occupied slots (i < count). Slots beyond `count` + -- are inert as far as the game is concerned (the marker byte above + -- already flags them 0xFF/empty), so their body/name bytes are left + -- exactly as they were in the original save instead of being normalized. + for i = 0, count - 1 do + local mon = monList[i + 1] + local bodyOfs = bodyBase + sizeBody * i + local otOfs = otBase + M.STRING_LENGTH * i + local nickOfs = nickBase + M.STRING_LENGTH * i + write_pk1_body(buf, bodyOfs, sizeBody, isParty, mon) + M.encode_string(buf, otOfs, M.STRING_LENGTH, mon.ot_name) + M.encode_string(buf, nickOfs, M.STRING_LENGTH, mon.nickname) + end +end + +------------------------------------------------------------------------ +-- Checksum (SAV1.cs: GetRBYChecksum / SetChecksums) +-- One's complement of the byte-sum over [OFS.OT, OFS.ChecksumOfs). +------------------------------------------------------------------------ + +local function compute_checksum(buf) + local sum = 0 + for i = M.OFS.OT, M.OFS.ChecksumOfs - 1 do + sum = sum + get(buf, i) + end + return (255 - (sum % 256)) & 0xFF +end + +------------------------------------------------------------------------ +-- Bytes <-> Lua string, base64 +------------------------------------------------------------------------ + +function M.bytes_to_string(buf, len) + len = len or #buf + local chars = {} + for i = 1, len do chars[i] = string.char(buf[i] & 0xFF) end + return table.concat(chars) +end + +function M.string_to_bytes(s) + local buf = {} + for i = 1, #s do buf[i] = string.byte(s, i) end + return buf +end + +local B64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" +local B64_REV = {} +for idx = 1, #B64_CHARS do B64_REV[B64_CHARS:sub(idx, idx)] = idx - 1 end + +function M.base64_encode(data) + local out = {} + local len = #data + local i = 1 + while i <= len do + local b1 = string.byte(data, i) + local b2 = string.byte(data, i + 1) + local b3 = string.byte(data, i + 2) + local n = b1 << 16 + if b2 then n = n | (b2 << 8) end + if b3 then n = n | b3 end + out[#out + 1] = B64_CHARS:sub((n >> 18 & 0x3F) + 1, (n >> 18 & 0x3F) + 1) + out[#out + 1] = B64_CHARS:sub((n >> 12 & 0x3F) + 1, (n >> 12 & 0x3F) + 1) + out[#out + 1] = b2 and B64_CHARS:sub((n >> 6 & 0x3F) + 1, (n >> 6 & 0x3F) + 1) or "=" + out[#out + 1] = b3 and B64_CHARS:sub((n & 0x3F) + 1, (n & 0x3F) + 1) or "=" + i = i + 3 + end + return table.concat(out) +end + +function M.base64_decode(s) + s = s:gsub("[^A-Za-z0-9+/=]", "") + local out = {} + local i = 1 + local len = #s + while i <= len do + local c1 = B64_REV[s:sub(i, i)] + local c2 = B64_REV[s:sub(i + 1, i + 1)] + local s3, s4 = s:sub(i + 2, i + 2), s:sub(i + 3, i + 3) + local c3, c4 = B64_REV[s3], B64_REV[s4] + local n = (c1 << 18) | (c2 << 12) | ((c3 or 0) << 6) | (c4 or 0) + out[#out + 1] = string.char((n >> 16) & 0xFF) + if s3 ~= "=" and s3 ~= "" then out[#out + 1] = string.char((n >> 8) & 0xFF) end + if s4 ~= "=" and s4 ~= "" then out[#out + 1] = string.char(n & 0xFF) end + i = i + 4 + end + return table.concat(out) +end + +------------------------------------------------------------------------ +-- Minimal pure-Lua JSON codec (no external dependencies). +-- M.omap(list) builds an object that encodes with a fixed, readable key +-- order instead of falling back to alphabetical sorting. +------------------------------------------------------------------------ + +local ORDER_KEY = "__order__" + +function M.omap(pairsList) + local t = { [ORDER_KEY] = {} } + for _, kv in ipairs(pairsList) do + t[kv[1]] = kv[2] + table.insert(t[ORDER_KEY], kv[1]) + end + return t +end + +local function escape_str(s) + local out = {} + for i = 1, #s do + local b = string.byte(s, i) + if b == 34 then out[#out + 1] = '\\"' + elseif b == 92 then out[#out + 1] = "\\\\" + elseif b == 8 then out[#out + 1] = "\\b" + elseif b == 9 then out[#out + 1] = "\\t" + elseif b == 10 then out[#out + 1] = "\\n" + elseif b == 12 then out[#out + 1] = "\\f" + elseif b == 13 then out[#out + 1] = "\\r" + elseif b < 0x20 then out[#out + 1] = string.format("\\u%04x", b) + else out[#out + 1] = s:sub(i, i) + end + end + return table.concat(out) +end + +local function array_len(t) + local n = 0 + for k, _ in pairs(t) do + if k == ORDER_KEY then goto continue end + if type(k) ~= "number" or k < 1 or math.floor(k) ~= k then return nil end + if k > n then n = k end + ::continue:: + end + for i = 1, n do if t[i] == nil then return nil end end + return n +end + +local function encode_value(value, ind) + local t = type(value) + if value == nil then return "null" end + if t == "boolean" then return tostring(value) end + if t == "number" then + if value == math.floor(value) and math.abs(value) < 1e15 then + return string.format("%d", value) + end + return tostring(value) + end + if t == "string" then return '"' .. escape_str(value) .. '"' end + if t == "table" then + local nextIndent = ind .. " " + if value[ORDER_KEY] then + local keys = value[ORDER_KEY] + if #keys == 0 then return "{}" end + local parts = {} + for _, k in ipairs(keys) do + parts[#parts + 1] = nextIndent .. '"' .. escape_str(k) .. '": ' .. encode_value(value[k], nextIndent) + end + return "{\n" .. table.concat(parts, ",\n") .. "\n" .. ind .. "}" + end + if next(value) == nil then return "[]" end + local n = array_len(value) + if n then + local parts = {} + for i = 1, n do parts[#parts + 1] = nextIndent .. encode_value(value[i], nextIndent) end + return "[\n" .. table.concat(parts, ",\n") .. "\n" .. ind .. "]" + end + local keys = {} + for k, _ in pairs(value) do keys[#keys + 1] = k end + table.sort(keys, function(a, b) return tostring(a) < tostring(b) end) + local parts = {} + for _, k in ipairs(keys) do + parts[#parts + 1] = nextIndent .. '"' .. escape_str(tostring(k)) .. '": ' .. encode_value(value[k], nextIndent) + end + return "{\n" .. table.concat(parts, ",\n") .. "\n" .. ind .. "}" + end + error("cannot JSON-encode a value of type " .. t) +end + +function M.json_encode(value) + return encode_value(value, "") +end + +local function skip_ws(s, i) + while i <= #s do + local c = s:sub(i, i) + if c == " " or c == "\t" or c == "\n" or c == "\r" then i = i + 1 else break end + end + return i +end + +local parse_value + +local function parse_string(s, i) + i = i + 1 -- opening quote + local out = {} + while true do + local c = s:sub(i, i) + if c == "" then error("unterminated string in JSON input") end + if c == '"' then i = i + 1; break end + if c == "\\" then + local e = s:sub(i + 1, i + 1) + if e == '"' then out[#out + 1] = '"'; i = i + 2 + elseif e == "\\" then out[#out + 1] = "\\"; i = i + 2 + elseif e == "/" then out[#out + 1] = "/"; i = i + 2 + elseif e == "b" then out[#out + 1] = string.char(8); i = i + 2 + elseif e == "f" then out[#out + 1] = string.char(12); i = i + 2 + elseif e == "n" then out[#out + 1] = string.char(10); i = i + 2 + elseif e == "r" then out[#out + 1] = string.char(13); i = i + 2 + elseif e == "t" then out[#out + 1] = string.char(9); i = i + 2 + elseif e == "u" then + local cp = tonumber(s:sub(i + 2, i + 5), 16) + i = i + 6 + if cp >= 0xD800 and cp <= 0xDBFF and s:sub(i, i + 1) == "\\u" then + local cp2 = tonumber(s:sub(i + 2, i + 5), 16) + if cp2 and cp2 >= 0xDC00 and cp2 <= 0xDFFF then + cp = 0x10000 + (cp - 0xD800) * 0x400 + (cp2 - 0xDC00) + i = i + 6 + end + end + out[#out + 1] = utf8.char(cp) + else + error("invalid escape sequence in JSON string: \\" .. e) + end + else + out[#out + 1] = c + i = i + 1 + end + end + return table.concat(out), i +end + +local function parse_number(s, i) + local j = i + if s:sub(j, j) == "-" then j = j + 1 end + while s:sub(j, j):match("%d") do j = j + 1 end + if s:sub(j, j) == "." then + j = j + 1 + while s:sub(j, j):match("%d") do j = j + 1 end + end + if s:sub(j, j) == "e" or s:sub(j, j) == "E" then + j = j + 1 + if s:sub(j, j) == "+" or s:sub(j, j) == "-" then j = j + 1 end + while s:sub(j, j):match("%d") do j = j + 1 end + end + return tonumber(s:sub(i, j - 1)), j +end + +parse_value = function(s, i) + i = skip_ws(s, i) + local c = s:sub(i, i) + if c == '"' then return parse_string(s, i) end + if c == "{" then + i = skip_ws(s, i + 1) + local t = {} + if s:sub(i, i) == "}" then return t, i + 1 end + while true do + i = skip_ws(s, i) + if s:sub(i, i) ~= '"' then error("expected string key in JSON object at position " .. i) end + local key, ni = parse_string(s, i) + i = skip_ws(s, ni) + if s:sub(i, i) ~= ":" then error("expected ':' in JSON object at position " .. i) end + local val, ni2 = parse_value(s, i + 1) + t[key] = val + i = skip_ws(s, ni2) + local cc = s:sub(i, i) + if cc == "," then i = i + 1 + elseif cc == "}" then i = i + 1; break + else error("expected ',' or '}' in JSON object at position " .. i) end + end + return t, i + end + if c == "[" then + i = skip_ws(s, i + 1) + local t = {} + if s:sub(i, i) == "]" then return t, i + 1 end + local n = 0 + while true do + local val, ni = parse_value(s, i) + n = n + 1 + t[n] = val + i = skip_ws(s, ni) + local cc = s:sub(i, i) + if cc == "," then i = i + 1 + elseif cc == "]" then i = i + 1; break + else error("expected ',' or ']' in JSON array at position " .. i) end + end + return t, i + end + if s:sub(i, i + 3) == "true" then return true, i + 4 end + if s:sub(i, i + 4) == "false" then return false, i + 5 end + if s:sub(i, i + 3) == "null" then return nil, i + 4 end + local num, ni = parse_number(s, i) + if num == nil then error("invalid JSON at position " .. i .. ": " .. s:sub(i, i + 10)) end + return num, ni +end + +function M.json_decode(s) + return (parse_value(s, 1)) +end + +------------------------------------------------------------------------ +-- Top-level save <-> table conversion +------------------------------------------------------------------------ + +local function is_yellow(buf) + local starter = get(buf, M.OFS.Starter) + if starter ~= 0 then return starter == 0x54 end -- 0x54 = internal species ID for Pikachu + return get(buf, M.OFS.PikaFriendship) ~= 0 +end + +-- Quick structural sanity check equivalent to SaveUtil's HasListAt/IsListValidG12. +function M.looks_like_gen1_international_save(buf) + if #buf ~= M.SIZE_SAVE then return false end + local function has_list_at(ofs, maxCount) + local count = get(buf, ofs) + return count <= maxCount and get(buf, ofs + 1 + count) == 0xFF + end + return has_list_at(M.OFS.Party, 6) and has_list_at(M.OFS.CurrentBox, M.BOX_SLOT_COUNT) +end + +-- Parses a raw 32768-byte Gen 1 save (as a byte array) into a JSON-friendly table. +function M.parse_save(buf) + if not M.looks_like_gen1_international_save(buf) then + error("this does not look like an international Gen 1 (Red/Blue/Yellow) save file (expected a 32768-byte file with valid party/box list headers)") + end + + local currentBoxZero = get(buf, M.OFS.CurrentBoxIndex) & 0x7F + local boxesInitialized = (get(buf, M.OFS.CurrentBoxIndex) & 0x80) ~= 0 + + local playTimeOfs = M.OFS.PlayTime + local trainer = M.omap({ + { "name", M.decode_string(buf, M.OFS.OT, M.STRING_LENGTH) }, + { "id", read_u16be(buf, M.OFS.TID16) }, + { "rival_name", M.decode_string(buf, M.OFS.Rival, M.STRING_LENGTH) }, + { "money", bcd_read(buf, M.OFS.Money, 3, false) }, + { "coins", bcd_read(buf, M.OFS.Coin, 2, false) }, + { "badges", get(buf, M.OFS.Badges) }, + { "options", get(buf, M.OFS.Options) }, + { "starter", get(buf, M.OFS.Starter) }, + { "pikachu_friendship", get(buf, M.OFS.PikaFriendship) }, + { "pikachu_beach_score", bcd_read(buf, M.OFS.PikaBeachScore, 2, true) }, + { "play_time", M.omap({ + { "hours", get(buf, playTimeOfs) }, + { "minutes", get(buf, playTimeOfs + 2) }, + { "seconds", get(buf, playTimeOfs + 3) }, + { "frames", get(buf, playTimeOfs + 4) }, + { "maxed_out", get(buf, playTimeOfs + 1) ~= 0 }, + }) }, + }) + + local boxes = {} + for boxIndexZero = 0, M.BOX_COUNT - 1 do + local absBase = (boxIndexZero == currentBoxZero) and M.OFS.CurrentBox or M.box_bank_offset(boxIndexZero) + boxes[#boxes + 1] = M.omap({ + { "box", boxIndexZero + 1 }, + { "pokemon", parse_mon_list(buf, absBase, M.BOX_SLOT_COUNT, M.SIZE_STORED, false) }, + }) + end + + return M.omap({ + { "format", "gen1" }, + { "region", "international" }, + { "version_guess", is_yellow(buf) and "yellow" or "red_blue" }, + { "trainer", trainer }, + { "current_box", currentBoxZero + 1 }, + { "boxes_initialized", boxesInitialized }, + { "party", parse_mon_list(buf, M.OFS.Party, 6, M.SIZE_PARTY, true) }, + { "boxes", boxes }, + { "raw_base64", M.base64_encode(M.bytes_to_string(buf)) }, + }) +end + +-- Builds a raw 32768-byte Gen 1 save (as a byte array) from a JSON-decoded table. +-- `data.raw_base64` (as produced by parse_save) is required and used as the base +-- buffer, so that anything not modeled above (items, Pokedex flags, event flags, +-- Hall of Fame, etc.) survives the round trip unmodified. +function M.build_save(data) + if not data.raw_base64 or data.raw_base64 == "" then + error("JSON is missing required field 'raw_base64' (the original save's base data)") + end + local buf = M.string_to_bytes(M.base64_decode(data.raw_base64)) + if #buf ~= M.SIZE_SAVE then + error(string.format("decoded raw_base64 is %d bytes, expected %d", #buf, M.SIZE_SAVE)) + end + + local trainer = data.trainer or {} + M.encode_string(buf, M.OFS.OT, M.STRING_LENGTH, trainer.name) + write_u16be(buf, M.OFS.TID16, trainer.id or 0) + M.encode_string(buf, M.OFS.Rival, M.STRING_LENGTH, trainer.rival_name) + bcd_write(buf, M.OFS.Money, 3, false, trainer.money or 0) + bcd_write(buf, M.OFS.Coin, 2, false, trainer.coins or 0) + set(buf, M.OFS.Badges, trainer.badges or 0) + set(buf, M.OFS.Options, trainer.options or 0) + set(buf, M.OFS.Starter, trainer.starter or 0) + set(buf, M.OFS.PikaFriendship, trainer.pikachu_friendship or 0) + bcd_write(buf, M.OFS.PikaBeachScore, 2, true, trainer.pikachu_beach_score or 0) + + local playTime = trainer.play_time or {} + set(buf, M.OFS.PlayTime, playTime.hours or 0) + set(buf, M.OFS.PlayTime + 1, playTime.maxed_out and 1 or 0) + set(buf, M.OFS.PlayTime + 2, playTime.minutes or 0) + set(buf, M.OFS.PlayTime + 3, playTime.seconds or 0) + set(buf, M.OFS.PlayTime + 4, playTime.frames or 0) + + write_mon_list(buf, M.OFS.Party, 6, M.SIZE_PARTY, true, data.party or {}) + + local currentBoxZero = (data.current_box or 1) - 1 + if currentBoxZero < 0 or currentBoxZero >= M.BOX_COUNT then + error("current_box must be between 1 and " .. M.BOX_COUNT) + end + + local boxes = data.boxes or {} + for boxIndexZero = 0, M.BOX_COUNT - 1 do + local boxEntry = boxes[boxIndexZero + 1] + local pokemon = boxEntry and boxEntry.pokemon or {} + local absBase = M.box_bank_offset(boxIndexZero) + write_mon_list(buf, absBase, M.BOX_SLOT_COUNT, M.SIZE_STORED, false, pokemon) + end + + -- Mirror the current box's freshly-written bytes into the "live" buffer + -- (SAV1.cs GetFinalData()), and mark boxes as initialized. + local curBankOfs = M.box_bank_offset(currentBoxZero) + for i = 0, M.SIZE_BOX_LIST - 1 do + set(buf, M.OFS.CurrentBox + i, get(buf, curBankOfs + i)) + end + set(buf, M.OFS.CurrentBoxIndex, (currentBoxZero & 0x7F) | 0x80) + + set(buf, M.OFS.ChecksumOfs, compute_checksum(buf)) + + return M.bytes_to_string(buf, M.SIZE_SAVE) +end + +return M diff --git a/tools/save_convert/vendor/json2sav.lua b/tools/save_convert/vendor/json2sav.lua new file mode 100644 index 00000000..cf307b92 --- /dev/null +++ b/tools/save_convert/vendor/json2sav.lua @@ -0,0 +1,46 @@ +#!/usr/bin/env lua +-- json2sav.lua [output.sav] +-- +-- Converts a JSON file (as produced by sav2json.lua, optionally hand-edited) +-- back into a binary Pokemon Red/Blue/Yellow save file, recomputing the +-- save's checksum. See README.md for details and limitations. + +local scriptDir = arg[0]:match("^(.*)[/\\]") or "." +local gen1 = dofile(scriptDir .. "/gen1lib.lua") + +local input = arg[1] +if not input then + io.stderr:write("usage: lua json2sav.lua [output.sav]\n") + os.exit(1) +end +local output = arg[2] or (input:gsub("%.[^.]*$", "") .. ".sav") + +local f, err = io.open(input, "rb") +if not f then + io.stderr:write("error: could not open '" .. input .. "': " .. tostring(err) .. "\n") + os.exit(1) +end +local text = f:read("a") +f:close() + +local ok, data = pcall(gen1.json_decode, text) +if not ok then + io.stderr:write("error: invalid JSON: " .. tostring(data) .. "\n") + os.exit(1) +end + +local ok2, savBytes = pcall(gen1.build_save, data) +if not ok2 then + io.stderr:write("error: " .. tostring(savBytes) .. "\n") + os.exit(1) +end + +local out, werr = io.open(output, "wb") +if not out then + io.stderr:write("error: could not write '" .. output .. "': " .. tostring(werr) .. "\n") + os.exit(1) +end +out:write(savBytes) +out:close() + +print("wrote " .. output .. " (" .. #savBytes .. " bytes)") diff --git a/tools/save_convert/vendor/sav2json.lua b/tools/save_convert/vendor/sav2json.lua new file mode 100644 index 00000000..9fdcc199 --- /dev/null +++ b/tools/save_convert/vendor/sav2json.lua @@ -0,0 +1,45 @@ +#!/usr/bin/env lua +-- sav2json.lua [output.json] +-- +-- Converts an international (non-Japanese) Pokemon Red/Blue/Yellow save +-- file into a human-editable JSON file. See README.md for details and +-- limitations, and json2sav.lua for the reverse direction. + +local scriptDir = arg[0]:match("^(.*)[/\\]") or "." +local gen1 = dofile(scriptDir .. "/gen1lib.lua") + +local input = arg[1] +if not input then + io.stderr:write("usage: lua sav2json.lua [output.json]\n") + os.exit(1) +end +local output = arg[2] or (input:gsub("%.[^.]*$", "") .. ".json") + +local f, err = io.open(input, "rb") +if not f then + io.stderr:write("error: could not open '" .. input .. "': " .. tostring(err) .. "\n") + os.exit(1) +end +local raw = f:read("a") +f:close() + +local buf = gen1.string_to_bytes(raw) + +local ok, result = pcall(gen1.parse_save, buf) +if not ok then + io.stderr:write("error: " .. tostring(result) .. "\n") + os.exit(1) +end + +local json = gen1.json_encode(result) + +local out, werr = io.open(output, "wb") +if not out then + io.stderr:write("error: could not write '" .. output .. "': " .. tostring(werr) .. "\n") + os.exit(1) +end +out:write(json) +out:write("\n") +out:close() + +print("wrote " .. output)