diff --git a/data/scripts/gyms.lua b/data/scripts/gyms.lua index c94484e3..ddf126c8 100644 --- a/data/scripts/gyms.lua +++ b/data/scripts/gyms.lua @@ -18,6 +18,26 @@ local M = { VIRIDIAN_GYM = { city = "VIRIDIAN CITY", leader = "GIOVANNI", badge = "EARTHBADGE" }, } +-- The originals' middle branch shared by every leader's text_asm: beaten +-- but EVENT_GOT_TM* unset means the bag was full when the victory script +-- ran GiveItem, so talking to the leader re-runs the ReceiveTM script. +-- Returns true when the retry took over the talk. A save from before +-- #797 already holds the TM without the flag; treat the owned TM as +-- received so those saves fall through to the advice text instead of +-- collecting a second copy. +local function retryTmGive(game, ow, victoryKey, done) + local reward = require("data.scripts.victories")[victoryKey] + if not (reward and reward.gotFlag) then return false end + if game.save.flags[reward.gotFlag] then return false end + local owned = game.save.inventory and game.save.inventory[reward.item] or 0 + if owned > 0 then + game.save.flags[reward.gotFlag] = true + return false + end + ow:offerGymTm(reward, done) + return true +end + -- scripts/PewterGym.asm PewterGymBrockText (text_asm): CheckEvent -- EVENT_BEAT_BROCK branches his dialogue. Before the badge he prints -- _PewterGymBrockPreBattleText and engages the leader battle @@ -25,12 +45,14 @@ local M = { -- badge/TM34 rewards and EVENT_BEAT_BROCK come from -- data/scripts/victories.lua OPP_BROCK#1). After the badge his -- .afterBeat branch prints _PewterGymBrockPostBattleAdviceText ("Go to --- the GYM in CERULEAN..."). The original's middle branch (beat but --- TM34 not yet handed over, CheckEventReuseA EVENT_GOT_TM34) is --- unreachable in the port: the TM is granted with the victory. +-- the GYM in CERULEAN..."). The middle branch (beat but TM34 not yet +-- handed over, CheckEventReuseA EVENT_GOT_TM34 -> call +-- PewterGymScriptReceiveTM34) retries the TM give when the bag was full +-- at the victory (#797). M.PEWTER_GYM.talk = { TEXT_PEWTERGYM_BROCK = function(game, ow, npc, done) if game.save.flags.EVENT_BEAT_BROCK then + if retryTmGive(game, ow, "OPP_BROCK#1", done) then return end local TextBox = require("src.render.TextBox") game.stack:push(TextBox.new(game, game.data.text._PewterGymBrockPostBattleAdviceText @@ -48,16 +70,17 @@ M.PEWTER_GYM.talk = { -- (engageTrainer shows that same pre-battle text via resolveText; the -- badge/TM rewards and the beat flag come from data/scripts/victories.lua) -- -- and once beaten print the post-battle advice text. As with Brock, --- the originals' middle branch (beaten but the TM not yet handed over, --- CheckEventReuseA EVENT_GOT_TM*) is unreachable in the port: the TM is --- granted with the victory. +-- the middle branch (beaten but the TM not yet handed over, +-- CheckEventReuseA EVENT_GOT_TM*) retries the TM give when the bag was +-- full at the victory. -- afterAdvice, when given, takes over `done`: it is handed (game, ow, npc, -- done) and must call done() itself once whatever it's doing (e.g. a fade -- around a HideObject) finishes, rather than having it invoked -- automatically. Only Giovanni's farewell uses this. -local function leaderTalk(beatFlag, adviceLabel, fallback, afterAdvice) +local function leaderTalk(beatFlag, adviceLabel, fallback, afterAdvice, victoryKey) return function(game, ow, npc, done) if game.save.flags[beatFlag] then + if victoryKey and retryTmGive(game, ow, victoryKey, done) then return end local TextBox = require("src.render.TextBox") local finish = done if afterAdvice then @@ -79,42 +102,42 @@ end M.CERULEAN_GYM.talk = { TEXT_CERULEANGYM_MISTY = leaderTalk("EVENT_BEAT_MISTY", "_CeruleanGymMistyTM11ExplanationText", - "TM11 teaches\nBUBBLEBEAM!"), + "TM11 teaches\nBUBBLEBEAM!", nil, "OPP_MISTY#1"), } -- scripts/VermilionGym.asm VermilionGymLTSurgeText .got_tm24_already M.VERMILION_GYM.talk = { TEXT_VERMILIONGYM_LT_SURGE = leaderTalk("EVENT_BEAT_LT_SURGE", "_VermilionGymLTSurgePostBattleAdviceText", - "A little word of\nadvice, kid!"), + "A little word of\nadvice, kid!", nil, "OPP_LT_SURGE#1"), } -- scripts/CeladonGym.asm CeladonGymErikaText .afterBeat M.CELADON_GYM.talk = { TEXT_CELADONGYM_ERIKA = leaderTalk("EVENT_BEAT_ERIKA", "_CeladonGymErikaPostBattleAdviceText", - "You are cataloging\nPOKéMON? I must\nsay I'm impressed."), + "You are cataloging\nPOKéMON? I must\nsay I'm impressed.", nil, "OPP_ERIKA#1"), } -- scripts/FuchsiaGym.asm FuchsiaGymKogaText .afterBeat M.FUCHSIA_GYM.talk = { TEXT_FUCHSIAGYM_KOGA = leaderTalk("EVENT_BEAT_KOGA", "_FuchsiaGymKogaPostBattleAdviceText", - "When afflicted by\nTOXIC, POKéMON\nsuffer more and\nmore as battle\nprogresses!"), + "When afflicted by\nTOXIC, POKéMON\nsuffer more and\nmore as battle\nprogresses!", nil, "OPP_KOGA#1"), } -- scripts/SaffronGym.asm SaffronGymSabrinaText .afterBeat M.SAFFRON_GYM.talk = { TEXT_SAFFRONGYM_SABRINA = leaderTalk("EVENT_BEAT_SABRINA", "_SaffronGymSabrinaPostBattleAdviceText", - "Everyone has\npsychic power!\nPeople just don't\nrealize it!"), + "Everyone has\npsychic power!\nPeople just don't\nrealize it!", nil, "OPP_SABRINA#1"), } -- scripts/CinnabarGym.asm CinnabarGymBlaineText .afterBeat M.CINNABAR_GYM.talk = { TEXT_CINNABARGYM_BLAINE = leaderTalk("EVENT_BEAT_BLAINE", "_CinnabarGymBlainePostBattleAdviceText", - "FIRE BLAST is the\nultimate fire\ntechnique!"), + "FIRE BLAST is the\nultimate fire\ntechnique!", nil, "OPP_BLAINE#1"), } -- scripts/ViridianGym.asm ViridianGymGiovanniText .afterBeat: after the @@ -142,7 +165,7 @@ M.VIRIDIAN_GYM.talk = { "VIRIDIAN_GYM", "VIRIDIANGYM_GIOVANNI") end end, done)) - end), + end, "OPP_GIOVANNI#3"), } return M diff --git a/data/scripts/victories.lua b/data/scripts/victories.lua index 8a05264c..dc71e796 100644 --- a/data/scripts/victories.lua +++ b/data/scripts/victories.lua @@ -19,11 +19,15 @@ -- no header.won -- checkVictoryRewards shows this chain instead of a -- synthetic "received badge/TM" stub. -- --- `itemDialogue` is the tail of that chain the original prints only after --- GiveItem succeeds; `bagFull` is the gym's *TM*NoRoomText it prints --- instead when the bag is at capacity (`jr nc, .BagFull` in --- PewterGymScriptReceiveTM34, scripts/PewterGym.asm, and its siblings). --- The badge lands either way -- .gymVictory runs from both paths (#797). +-- Gym entries split the TM hand-over out of `dialogue`, mirroring the +-- originals' GiveItem check (`call GiveItem` / `jr nc, .BagFull`): +-- `tmPre` is the ReceiveTM script's lead-in (badge info / "Wait! Take +-- this!"), shown at the victory and again when a beaten leader retries +-- the hand-over; `tmDialogue` shows only when the TM actually goes in +-- the bag; `noRoom` is the "make room" line shown instead when the bag +-- is full; `gotFlag` (pokered's EVENT_GOT_TM*) is set only on a +-- successful give, which is what makes the leader's talk script retry +-- later (gyms.lua). local function range(prefix, first, last) local t = {} @@ -39,6 +43,8 @@ return { -- escort NPC and the first Route 22 rival stay gone after the badge. ["OPP_BROCK#1"] = { badge = "BOULDERBADGE", flag = "EVENT_BEAT_BROCK", item = "TM_BIDE", + gotFlag = "EVENT_GOT_TM34", + noRoom = "_PewterGymTM34NoRoomText", deactivate = { "EVENT_BEAT_PEWTER_GYM_TRAINER_0" }, hide = { { "PEWTER_CITY", "PEWTERCITY_YOUNGSTER" }, @@ -47,94 +53,102 @@ return { dialogue = { "_PewterGymBrockReceivedBoulderBadgeText", "_PewterGymBrockBoulderBadgeInfoText", - "_PewterGymBrockWaitTakeThisText", }, - itemDialogue = { + tmPre = { "_PewterGymBrockWaitTakeThisText" }, + tmDialogue = { "_PewterGymReceivedTM34Text", "_TM34ExplanationText", - }, - bagFull = "_PewterGymTM34NoRoomText" }, + } }, ["OPP_MISTY#1"] = { badge = "CASCADEBADGE", flag = "EVENT_BEAT_MISTY", item = "TM_BUBBLEBEAM", + gotFlag = "EVENT_GOT_TM11", + noRoom = "_CeruleanGymMistyTM11NoRoomText", deactivate = range("EVENT_BEAT_CERULEAN_GYM_TRAINER_", 0, 1), dialogue = { "_CeruleanGymMistyReceivedCascadeBadgeText", - "_CeruleanGymMistyCascadeBadgeInfoText", }, - itemDialogue = { "_CeruleanGymMistyReceivedTM11Text" }, - bagFull = "_CeruleanGymMistyTM11NoRoomText" }, + tmPre = { "_CeruleanGymMistyCascadeBadgeInfoText" }, + tmDialogue = { + "_CeruleanGymMistyReceivedTM11Text", + } }, ["OPP_LT_SURGE#1"] = { badge = "THUNDERBADGE", flag = "EVENT_BEAT_LT_SURGE", item = "TM_THUNDERBOLT", + gotFlag = "EVENT_GOT_TM24", + noRoom = "_VermilionGymLTSurgeTM24NoRoomText", deactivate = range("EVENT_BEAT_VERMILION_GYM_TRAINER_", 0, 2), dialogue = { "_VermilionGymLTSurgeReceivedThunderBadgeText", - "_VermilionGymLTSurgeThunderBadgeInfoText", }, - itemDialogue = { + tmPre = { "_VermilionGymLTSurgeThunderBadgeInfoText" }, + tmDialogue = { "_VermilionGymLTSurgeReceivedTM24Text", "_TM24ExplanationText", - }, - bagFull = "_VermilionGymLTSurgeTM24NoRoomText" }, + } }, ["OPP_ERIKA#1"] = { badge = "RAINBOWBADGE", flag = "EVENT_BEAT_ERIKA", item = "TM_MEGA_DRAIN", + gotFlag = "EVENT_GOT_TM21", + noRoom = "_CeladonGymTM21NoRoomText", deactivate = range("EVENT_BEAT_CELADON_GYM_TRAINER_", 0, 6), dialogue = { "_CeladonGymErikaReceivedRainbowBadgeText", - "_CeladonGymRainbowBadgeInfoText", }, - itemDialogue = { + tmPre = { "_CeladonGymRainbowBadgeInfoText" }, + tmDialogue = { "_CeladonGymReceivedTM21Text", "_TM21ExplanationText", - }, - bagFull = "_CeladonGymTM21NoRoomText" }, + } }, ["OPP_KOGA#1"] = { badge = "SOULBADGE", flag = "EVENT_BEAT_KOGA", item = "TM_TOXIC", + gotFlag = "EVENT_GOT_TM06", + noRoom = "_FuchsiaGymKogaTM06NoRoomText", deactivate = range("EVENT_BEAT_FUCHSIA_GYM_TRAINER_", 0, 5), dialogue = { "_FuchsiaGymKogaReceivedSoulBadgeText", - "_FuchsiaGymKogaSoulBadgeInfoText", }, - itemDialogue = { + tmPre = { "_FuchsiaGymKogaSoulBadgeInfoText" }, + tmDialogue = { "_FuchsiaGymKogaReceivedTM06Text", "_FuchsiaGymKogaTM06ExplanationText", - }, - bagFull = "_FuchsiaGymKogaTM06NoRoomText" }, + } }, ["OPP_SABRINA#1"] = { badge = "MARSHBADGE", flag = "EVENT_BEAT_SABRINA", item = "TM_PSYWAVE", + gotFlag = "EVENT_GOT_TM46", + noRoom = "_SaffronGymSabrinaTM46NoRoomText", deactivate = range("EVENT_BEAT_SAFFRON_GYM_TRAINER_", 0, 6), dialogue = { "_SaffronGymSabrinaReceivedMarshBadgeText", - "_SaffronGymSabrinaMarshBadgeInfoText", }, - itemDialogue = { + tmPre = { "_SaffronGymSabrinaMarshBadgeInfoText" }, + tmDialogue = { "_SaffronGymSabrinaReceivedTM46Text", "_TM46ExplanationText", - }, - bagFull = "_SaffronGymSabrinaTM46NoRoomText" }, + } }, ["OPP_BLAINE#1"] = { badge = "VOLCANOBADGE", flag = "EVENT_BEAT_BLAINE", item = "TM_FIRE_BLAST", + gotFlag = "EVENT_GOT_TM38", + noRoom = "_CinnabarGymBlaineTM38NoRoomText", deactivate = range("EVENT_BEAT_CINNABAR_GYM_TRAINER_", 0, 6), dialogue = { "_CinnabarGymBlaineReceivedVolcanoBadgeText", - "_CinnabarGymBlaineVolcanoBadgeInfoText", }, - itemDialogue = { + tmPre = { "_CinnabarGymBlaineVolcanoBadgeInfoText" }, + tmDialogue = { "_CinnabarGymBlaineReceivedTM38Text", "_CinnabarGymBlaineTM38ExplanationText", - }, - bagFull = "_CinnabarGymBlaineTM38NoRoomText" }, + } }, ["OPP_GIOVANNI#3"] = { badge = "EARTHBADGE", flag = "EVENT_BEAT_GIOVANNI", item = "TM_FISSURE", + gotFlag = "EVENT_GOT_TM27", + noRoom = "_ViridianGymGiovanniTM27NoRoomText", deactivate = range("EVENT_BEAT_VIRIDIAN_GYM_TRAINER_", 0, 7), dialogue = { "_ViridianGymGiovanniReceivedEarthBadgeText", - "_ViridianGymGiovanniEarthBadgeInfoText", }, - itemDialogue = { + tmPre = { "_ViridianGymGiovanniEarthBadgeInfoText" }, + tmDialogue = { "_ViridianGymGiovanniReceivedTM27Text", "_ViridianGymGiovanniTM27ExplanationText", - }, - bagFull = "_ViridianGymGiovanniTM27NoRoomText" }, + } }, -- Silph Co. Giovanni: unlocks the president's Master Ball gift. -- SilphCo11FGiovanniStartBattleScript (scripts/SilphCo11F.asm) hands the diff --git a/docs/behavior-porting-notes.md b/docs/behavior-porting-notes.md index 8693bcef..d26affce 100644 --- a/docs/behavior-porting-notes.md +++ b/docs/behavior-porting-notes.md @@ -234,8 +234,12 @@ What was ported from pokered's engine code and where it came from. pre-battle text and engages the leader battle (badge/TM via data/scripts/victories.lua); post-badge talk prints the leader's post-battle advice text (Misty's is her TM11 explanation). The - originals' middle branch (beaten but TM not handed over) is - unreachable since the TM is granted with the victory. Giovanni's + originals' middle branch (beaten but TM not handed over, + CheckEventReuseA EVENT_GOT_TM*) is ported too: the victory's GiveItem + goes through the bag's capacity check, a full bag shows the leader's + "make room" text instead of the received lines and leaves + EVENT_GOT_TM* unset, and talking to the leader re-runs the ReceiveTM + script until the TM goes in (#797). Giovanni's farewell (`ViridianGymGiovanniText` .afterBeat) hides him inside a fade-to-black/fade-in Transition matching ViridianGym.asm's GBFadeOutToBlack → HideObject → GBFadeInFromBlack, persisted diff --git a/scripts/test.sh b/scripts/test.sh index 18bb960e..fe1a3120 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -22,6 +22,7 @@ set -uo pipefail cd "$(dirname "$0")/.." LUA=${LUA:-luajit} +LUA54=${LUA54:-lua5.4} BLESS=0 QUICK=0 SHOTS=${WITH_SHOTS:-0} @@ -134,6 +135,16 @@ if [ -f data/generated/maps.lua ]; then run_tier "T3 save editor: wheel scrolling" "$LUA" tests/save_editor_wheel_bug595_test.lua run_tier "T3 save editor: pad / NX input" "$LUA" tests/save_editor_pad_input_test.lua run_tier "T5 link (loopback lockstep)" "$LUA" tests/run_link_tests.lua + # The oversize-save vendor oracle (tests/save_oversize_vendor_test.lua) + # cross-checks the launcher's footer-truncation import against the + # INDEPENDENT PKHeX-derived gen1lib codec, which cannot run under luajit + # (native 5.3+ operators). Needs a stock Lua 5.3/5.4; skip when absent. + if command -v "$LUA54" >/dev/null 2>&1; then + run_tier "T3 save oversize vendor oracle" "$LUA54" tests/save_oversize_vendor_test.lua + else + echo "" + echo "-- T3 save oversize vendor oracle: skipped (no '$LUA54' on PATH; set LUA54=...)" + fi fi else echo "" diff --git a/src/core/Game.lua b/src/core/Game.lua index d38db05a..11d86c5a 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -793,11 +793,13 @@ function Game:joystickhat(joystick, hat, direction) end -- Window focus/visibility flips: a release due while unfocused/hidden can --- be swallowed by the OS. Reset on both edges -- gaining focus with a --- physically held key won't re-fire keypressed, so trusting leftover --- state is worse than asking the player to re-press. +-- be swallowed by the OS. Reset on both edges; on the regain, reconcile +-- re-arms only what is still physically held -- a held key won't re-fire +-- keypressed by itself, and without the rebuild a spurious lifecycle event +-- parked the player until every direction was re-pressed (#799). function Game:focus(f) Input:reset() + if f then Input:reconcile() end TouchControls:reset() self:cancelPointers() end @@ -814,6 +816,7 @@ end function Game:onResume() Input:reset() + Input:reconcile() TouchControls:reset() self:cancelPointers() -- Chip music may survive NX suspend as a duplicate stream; stop it and let @@ -830,6 +833,10 @@ end function Game:recoverInput(event, joystick) Input:reset() + -- A hotplug can arrive with no hotplug (macOS Bluetooth re-enumeration), + -- and the blanket reset above also drops unrelated keyboard holds; put + -- back whatever is still physically down (#799). + Input:reconcile() TouchControls:reset() -- reset just dropped every source, mod holds included: retire the mods' -- outstanding press tokens so nothing stale can be released later, and diff --git a/src/core/Input.lua b/src/core/Input.lua index a576e15d..33543c97 100644 --- a/src/core/Input.lua +++ b/src/core/Input.lua @@ -301,6 +301,71 @@ function Input:joystickhat(joystick, hat, direction) self.hatDirs[hat] = dirs end +-- Lifecycle resets (focus/visibility flips, joystick add/remove, resume) +-- wipe held state because a release can be swallowed while the OS owns the +-- event stream. A direction the player is STILL holding never re-fires +-- keypressed/gamepadpressed after the wipe either, so a spurious reset -- +-- macOS re-enumerating a Bluetooth pad fires joystickadded with no hotplug, +-- and the blanket reset took unrelated keyboard holds down with it -- +-- parked the player in place until every direction was released and +-- pressed again (#799). Rebuild holds from the devices' ground truth +-- instead: only what is physically down right now comes back, so the +-- swallowed-release hazards the resets guard against stay cleared. +-- Deliberately separate from reset(): the soft-reset chord path in +-- Game:step needs the clean slate (re-arming A there would read it as a +-- title-menu choice). +function Input:reconcile() + local kb = love and love.keyboard + if kb and kb.isDown then + for key, btn in pairs(self.keyBindings) do + local ok, down = pcall(kb.isDown, key) + if ok and down then press(self, btn, "key:" .. key) end + end + end + local js = love and love.joystick + if not (js and js.getJoysticks) then return end + local ok, joysticks = pcall(js.getJoysticks) + if not ok or type(joysticks) ~= "table" then return end + for _, j in ipairs(joysticks) do + if GamepadMap.ignoreRawForJoystick(j) then + -- SDL-recognized pad: buttons + left stick, the gamepad surfaces + if j.isGamepadDown then + for button, btn in pairs(self.padBindings) do + local ok2, down = pcall(j.isGamepadDown, j, button) + if ok2 and down then press(self, btn, "pad:" .. button) end + end + end + if j.getGamepadAxis then + for _, axis in ipairs({ "leftx", "lefty" }) do + local ok2, v = pcall(j.getGamepadAxis, j, axis) + if ok2 and type(v) == "number" then self:gamepadaxis(j, axis, v) end + end + end + else + -- raw stick (#620/#632): the surfaces the joystick* events feed + if j.isDown then + for index, btn in pairs(self.joyBindings) do + local ok2, down = pcall(j.isDown, j, index) + if ok2 and down then press(self, btn, "joy:" .. index) end + end + end + if j.getAxis then + for _, axis in ipairs({ 1, 2 }) do + local ok2, v = pcall(j.getAxis, j, axis) + if ok2 and type(v) == "number" then self:joystickaxis(j, axis, v) end + end + end + if j.getHatCount and j.getHat then + local ok2, count = pcall(j.getHatCount, j) + for hat = 1, (ok2 and count) or 0 do + local ok3, dir = pcall(j.getHat, j, hat) + if ok3 and dir then self:joystickhat(j, hat, dir) end + end + end + end + end +end + function Input:isDown(btn) return self.state[btn] or false end diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index dc25188f..7be70f26 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -1505,6 +1505,8 @@ local function buildConfirmModal(imp, m) imp:_confirmModUpdate(c.id, c.release) elseif c.kind == "enableAll" then imp:_setAllMods(true, true) + elseif c.kind == "importOversize" then + imp:_importSave(c.version, c.source, true) else imp:_toggleMod(c.id, true) end diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index c44f2fd1..20b2071e 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -1584,7 +1584,7 @@ end -- 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) +function RomImporter:_importSave(version, source, force) if self.workState == "working" then return end if GameVersion.VERSIONS[self.tab] or self.tab == "mods" then self.tab = version @@ -1594,15 +1594,35 @@ function RomImporter:_importSave(version, source) .. GameVersion.info(version).displayName .. " ROM before importing a save." } return end - local ok, res = require("src.import.SaveFileIO").importToSlot(source, version) + local ok, res, info = require("src.import.SaveFileIO").importToSlot(source, version, force) 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) } + return end + if res == nil and info and info.needsConfirm then + -- A .sav larger than 32 KB whose first 32768 bytes checksum: the surplus + -- is almost certainly an emulator RTC footer, so ask before truncating. + -- The yes arm re-enters with force=true; cancel leaves the file untouched. + self._modConfirm = { + kind = "importOversize", + version = version, + source = source, + title = "Oversized save file", + lines = { + ("This save is %d bytes; a cartridge save is exactly %d bytes (32 KB).") + :format(info.size, 32768), + "It may come from a ROM that saved the battery image with an emulator.", + "The extra bytes would be discarded.", + "Import it anyway?", + }, + yesLabel = "Import anyway", + } + return + end + self.saveNotice[version] = { ok = false, text = tostring(res) } end -- "Import save" button: open a native .sav picker and import the pick. diff --git a/src/import/SaveFileIO.lua b/src/import/SaveFileIO.lua index 42d45014..c9a149f5 100644 --- a/src/import/SaveFileIO.lua +++ b/src/import/SaveFileIO.lua @@ -64,18 +64,34 @@ local function readSource(source) return nil, "could not read the save file: " .. tostring(openErr) end --- importToSlot(source, version) -> ok, slotIdOrErr --- source: an absolute path, a LOVE DroppedFile, or raw 32768 bytes. On success +-- importToSlot(source, version, force) -> ok, slotIdOrErr | (false, nil, info) +-- source: an absolute path, a LOVE DroppedFile, or raw 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) +-- returns false + a friendly message. force only matters for a file LARGER +-- than 32768 bytes whose first 32768 bytes carry a valid main-data checksum +-- (i.e. a cartridge save padded with an emulator RTC footer): without force +-- this returns false, nil, { needsConfirm = true, size = #bytes } so the +-- launcher can ask the player before truncating; with force the extra bytes +-- are dropped and the 32768-byte save imports. +function SaveFileIO.importToSlot(source, version, force) 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) + local check = SaveConvert.mainChecksumValid(bytes) + if check == nil then + return false, ("A save file must be %d bytes (32 KB); this one is %d.") + :format(SAVE_SIZE, #bytes) + end + if check == false then + return false, "save data checksum invalid (main data checksum mismatch)" + end + if #bytes > SAVE_SIZE and not force then + return false, nil, { needsConfirm = true, size = #bytes } + end + bytes = #bytes > SAVE_SIZE and bytes:sub(1, SAVE_SIZE) + or (bytes .. string.rep("\0", SAVE_SIZE - #bytes)) end -- 3rd arg: the crosswalk has to come from THIS game's ROM cache. The -- launcher imports before the cache is mounted on the un-prefixed paths, so diff --git a/src/save_convert/GenSave.lua b/src/save_convert/GenSave.lua index 8c226292..9f1c3274 100644 --- a/src/save_convert/GenSave.lua +++ b/src/save_convert/GenSave.lua @@ -190,6 +190,18 @@ local function checksum(bytes, from, to) return bit.band(bit.bnot(sum), 0xFF) end +-- Main-data checksum gate used before an import policy is decided. Returns +-- nil when the buffer is too short to even carry the stored checksum byte +-- (offset O.mainChecksum, the last byte of wMainData), false on a mismatch, +-- true when it matches. Works on any length >= O.mainChecksum + 1, so a +-- caller can classify a truncated or footer-padded file without a full +-- decode -- the checksummed region (0x2598..0x3522) always sits entirely +-- inside the first 0x3524 bytes of a save. +function GenSave.mainChecksumValid(bytes) + if #bytes < O.mainChecksum + 1 then return nil end + return checksum(bytes, O.checksumStart, O.checksumEnd) == u8(bytes, O.mainChecksum) +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 diff --git a/src/save_convert/SaveConvert.lua b/src/save_convert/SaveConvert.lua index ef2547e3..f6ca12d8 100644 --- a/src/save_convert/SaveConvert.lua +++ b/src/save_convert/SaveConvert.lua @@ -25,6 +25,7 @@ local GenSave = require("src.save_convert.GenSave") local SaveConvert = {} SaveConvert.SAVE_SIZE = GenSave.SAVE_SIZE +SaveConvert.mainChecksumValid = GenSave.mainChecksumValid -- ------------------------------------------------------------------ -- Crosswalk data loading (cached). Mirrors src/core/Data.lua: prefer diff --git a/src/ui/BagMenu.lua b/src/ui/BagMenu.lua index e522533f..b0c5c738 100644 --- a/src/ui/BagMenu.lua +++ b/src/ui/BagMenu.lua @@ -255,36 +255,30 @@ local function useOn(game, battle, id, target, list, moveIndex, picker) if result == "consumed" then consume(game, id) + -- refresh counts in the list + for i, it in ipairs(list.items) do + if it.value == id then + local left = game.save.inventory[id] + if left then it.right = "x" .. left else table.remove(list.items, i) end + break + end + end + list.index = math.min(list.index, math.max(1, #list.items)) if extra and extra.evolveTo then list:close() local Evolution = require("src.pokemon.Evolution") Evolution.evolve(game, target, extra.evolveTo) return end - -- refresh counts in the list. Hoisted out of the tail below because the - -- RARE CANDY path returns early and still leaves the list on screen, so - -- both paths have to agree on what the row reads (#796). - local function refreshCounts() - for i, it in ipairs(list.items) do - if it.value == id then - local left = game.save.inventory[id] - if left then it.right = "x" .. left else table.remove(list.items, i) end - break - end - end - list.index = math.min(list.index, math.max(1, #list.items)) - end -- RARE CANDY: after the level text, the stat window, any level-up -- moves and a level evolution follow (item_effects.asm .useRareCandy -- runs PrintStatsBox, LearnMoveFromLevelUp and TryEvolvingMon) if extra and extra.leveledTo and target then - -- .useItem_partyMenu re-enters StartMenu_Item after the stat box, it - -- does not CloseStartMenu, so out of battle the bag list stays up with - -- the decremented count showing. In battle the item spends the turn - -- and the bag has to go (dead today: ItemUseVitamin refuses RARE CANDY - -- mid-battle, kept so the boundary stays explicit). #796 - refreshCounts() - if battle then list:close() end + -- ...but the bag stays open underneath it all: RARE_CANDY is in + -- pokered's UsableItems_PartyMenu (data/items/use_party.asm), and + -- .useItem_partyMenu jumps back to StartMenu_Item once UseItem + -- returns, cursor still on the candy (start_sub_menus.asm) -- so + -- mashing A burns through a stack of them (#796) showMessages(game, payload, function() local StatBox = require("src.battle.BattleState").StatBox game.stack:push(StatBox.new(game, target, function() @@ -323,7 +317,6 @@ local function useOn(game, battle, id, target, list, moveIndex, picker) end) return end - refreshCounts() -- HP medicine: fill the bar in the still-open picker first, then print -- and close, the order item_effects.asm .doneHealing runs in -- (SFX_HEAL_HP -> UpdateHPBar2 -> RedrawPartyMenu prints the message). diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 10efbea7..ebca790e 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -3021,6 +3021,22 @@ function OverworldState:engageTrainer(npc, onDone) end)) end +-- Shared GiveItem step for the victory rewards (pokered home/give.asm): +-- the item goes through the bag's capacity check, and only a successful +-- add sets the reward's gotFlag (EVENT_GOT_TM*) and copies the item name +-- into wStringBuffer for the "{RAM:wStringBuffer}" received texts. +local function giveVictoryItem(reward) + if not require("src.inventory.Bag").add(Game.save, reward.item, 1, Game.data) then + return false + end + if reward.gotFlag then + Game.save.flags[reward.gotFlag] = true + end + local idef = Game.data.items[reward.item] + Game.stringBuffer = idef and idef.name or reward.item + return true +end + -- Badges/items awarded after specific battles (data/scripts/victories.lua). -- `deactivate` retires unfought gym/dojo trainers the way the originals' -- SetEvent / SetEventRange do after the leader victory. @@ -3049,49 +3065,45 @@ function OverworldState:checkVictoryRewards(trainerClass, partyIndex) if reward.badge then Game.save.inventory[reward.badge] = 1 end - -- GiveItem can refuse at the bag cap: the leaders' post-battle scripts - -- (`call GiveItem` / `jr nc, .BagFull` in scripts/PewterGym.asm - -- PewterGymScriptReceiveTM34 and its siblings) then skip the received-TM - -- text and print the gym's *TM*NoRoomText instead, while .gymVictory - -- still awards the badge. Going through Bag.add keeps the cap and the - -- wBagItems order honest -- the raw inventory write bypassed both (#797). - local gotItem = false + local tmGiven = false if reward.item then - gotItem = require("src.inventory.Bag").add( - Game.save, reward.item, 1, Game.data) - local idef = Game.data.items[reward.item] - -- GiveItem -> CopyToStringBuffer for "{RAM:wStringBuffer}" received texts - Game.stringBuffer = idef and idef.name or reward.item + -- pokered GiveItem (home/give.asm): AddItemToInventory first, and a + -- full bag (jr nc, .BagFull) skips the received lines for the "make + -- room" text, leaving EVENT_GOT_TM* unset so the leader's talk script + -- retries the hand-over later (offerGymTm via gyms.lua) + tmGiven = giveVictoryItem(reward) end local lines = {} if reward.dialogue then local text = Game.data.text or {} - -- itemDialogue is the received-TM tail the original prints only when - -- GiveItem succeeded; bagFull is the alternate line for when it did not - local chain = reward.dialogue - if reward.itemDialogue or reward.bagFull then - chain = {} - for _, label in ipairs(reward.dialogue) do chain[#chain + 1] = label end - if gotItem then - for _, label in ipairs(reward.itemDialogue or {}) do - chain[#chain + 1] = label - end - elseif reward.bagFull then - chain[#chain + 1] = reward.bagFull - end - end - for _, label in ipairs(chain) do + for _, label in ipairs(reward.dialogue) do if text[label] and text[label] ~= "" then table.insert(lines, text[label]) end end + if reward.item then + for _, label in ipairs(reward.tmPre or {}) do + if text[label] and text[label] ~= "" then + table.insert(lines, text[label]) + end + end + if tmGiven then + for _, label in ipairs(reward.tmDialogue or {}) do + if text[label] and text[label] ~= "" then + table.insert(lines, text[label]) + end + end + elseif reward.noRoom and text[reward.noRoom] and text[reward.noRoom] ~= "" then + table.insert(lines, text[reward.noRoom]) + 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, Strings("%s received\nthe %s!", Game.save.player.name, name)) end - if reward.item and gotItem then + if tmGiven then local name = Game.stringBuffer or reward.item table.insert(lines, Strings("%s received\n%s!", Game.save.player.name, name)) end @@ -3102,6 +3114,33 @@ function OverworldState:checkVictoryRewards(trainerClass, partyIndex) self:runVictoryHook() end +-- A beaten leader re-running their ReceiveTM script when the bag was full +-- at the victory (pokered's middle branch, e.g. PewterGymBrockText +-- CheckEventReuseA EVENT_GOT_TM34 -> call PewterGymScriptReceiveTM34). +-- The script's lead-in lines (tmPre: badge info / "Wait! Take this!") +-- show again, then the same GiveItem check decides between the received +-- lines and the "make room" text. +function OverworldState:offerGymTm(reward, done) + local text = Game.data.text or {} + local lines = {} + local function addLine(label) + if label and text[label] and text[label] ~= "" then + table.insert(lines, text[label]) + end + end + for _, label in ipairs(reward.tmPre or {}) do addLine(label) end + if giveVictoryItem(reward) then + for _, label in ipairs(reward.tmDialogue or {}) do addLine(label) end + else + addLine(reward.noRoom) + end + if #lines > 0 then + Game.stack:push(TextBox.new(Game, table.concat(lines, "\f"), done)) + elseif done then + done() + end +end + -- pokered reloads the map after every battle, re-running the map -- script (e.g. LoreleiShowOrHideExitBlock); this hook is the port's -- equivalent so seals/toggles refresh without leaving the map diff --git a/tests/engine/input_hold_reconcile_test.lua b/tests/engine/input_hold_reconcile_test.lua new file mode 100644 index 00000000..7fc0fe5e --- /dev/null +++ b/tests/engine/input_hold_reconcile_test.lua @@ -0,0 +1,87 @@ +-- Held directions must survive lifecycle resets while physically held +-- (#799). Input state is event-driven, so any Input:reset (focus or +-- visibility flip, joystick add/remove, resume) wipes a hold that never +-- re-fires keypressed afterwards -- on macOS a Bluetooth controller +-- re-enumerating mid-walk fired joystickadded under a held key and parked +-- the player until the direction was released and pressed again. Game's +-- lifecycle handlers rebuild holds from device ground truth after each +-- reset; only what is physically down comes back, so the swallowed-release +-- hazards the resets guard against stay cleared. +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 check = T.check + +local Input = require("src.core.Input") +local Game = require("src.core.Game") + +local realIsDown = love.keyboard.isDown +local realJoystick = love.joystick +local function restore() + love.keyboard.isDown = realIsDown + love.joystick = realJoystick +end + +Input:init() + +-- Keyboard: direction still physically held across a spurious pad +-- re-enumeration must keep walking (the #799 report). +love.keyboard.isDown = function(key) return key == "up" end +Input:reset() +Input:keypressed("up") +Input:step() +check(Input:isDown("up"), "up held before joystick re-enumeration") +Game:joystickadded({ getName = function() return "Wireless Controller" end }) +check(Input:isDown("up"), "held key survives a spurious joystickadded") + +-- Same hold across a focus bounce (another reset source). +Game:focus(false) +check(not Input:isDown("up"), "focus loss still drops the hold") +Game:focus(true) +check(Input:isDown("up"), "held key re-arms on focus regain") + +-- A key released while unfocused (keyup swallowed by the OS, the hazard +-- the resets exist for) must NOT come back. +love.keyboard.isDown = function() return false end +Game:focus(false) +Game:focus(true) +check(not Input:isDown("up"), "swallowed release still clears the hold") + +-- Controller: held d-pad across a disconnect/reconnect bounce. +local pad = { + isGamepad = function() return true end, + isGamepadDown = function(_, button) return button == "dpleft" end, + getGamepadAxis = function() return 0 end, +} +love.joystick = { getJoysticks = function() return { pad } end } +love.keyboard.isDown = function() return false end +Input:reset() +Input:gamepadpressed(pad, "dpleft") +Input:step() +check(Input:isDown("left"), "d-pad held before reconnect") +Game:joystickremoved(pad) +Game:joystickadded(pad) +check(Input:isDown("left"), "held d-pad survives a reconnect bounce") + +-- Held stick across the same bounce (axis ground truth re-derived). +pad.isGamepadDown = function() return false end +pad.getGamepadAxis = function(_, axis) return axis == "leftx" and -0.9 or 0 end +Input:reset() +Input:gamepadaxis(pad, "leftx", -0.9) +Input:step() +check(Input:isDown("left"), "stick held before re-enumeration") +Game:joystickadded(pad) +check(Input:isDown("left"), "held stick survives re-enumeration") + +-- A pad that vanished for real reports nothing held: its stale hold must +-- stay cleared (the stuck-flag hazard reset-on-remove guards against). +love.joystick = { getJoysticks = function() return {} end } +Input:reset() +Input:gamepadpressed(pad, "dpleft") +Input:step() +Game:joystickremoved(pad) +check(not Input:isDown("left"), "vanished pad's hold stays cleared") + +restore() +T.finish() diff --git a/tests/engine/save_file_io_tests.lua b/tests/engine/save_file_io_tests.lua index 90a7fd07..d452c1f5 100644 --- a/tests/engine/save_file_io_tests.lua +++ b/tests/engine/save_file_io_tests.lua @@ -204,6 +204,112 @@ do check(type(erre) == "string", "the empty-export failure carries a message") end +-- ---------------------------------------------- oversize / truncated policy +-- A .sav LARGER than 32768 bytes whose first 32768 bytes carry a valid +-- main-data checksum is a cartridge save with a trailing emulator RTC footer +-- (VBA appends 44/48 bytes -- bgb.bircd.org/rtcsave.html). Without force the +-- import must NOT happen silently: it returns (false, nil, {needsConfirm}) +-- so the launcher can ask. With force the surplus is dropped. A file +-- SHORTER than 32768 is refused unless its checksum region is intact, in +-- which case it imports zero-padded (a truncated box region, not a loss). + +-- DroppedFile-shaped source for arbitrary bytes (readSource disambiguates a +-- raw string of length != 32768 as a path, so tests hand a file object). +local function fileSource(bytes) + return { + _bytes = bytes, + open = function() return true end, + getSize = function(self) return #self._bytes end, + read = function(self) return self._bytes end, + close = function() return true end, + } +end + +-- A realistic 44-byte VBA MBC3 RTC footer (4 dwords, 16-byte latched copies, +-- 8-byte unix timestamp, 4-byte unix timestamp -- bgb.bircd.org/rtcsave.html). +local function rtcFooter() + local parts = {} + local function pushLe(v) + parts[#parts + 1] = string.char(v % 256, math.floor(v / 256) % 256, + math.floor(v / 65536) % 256, math.floor(v / 16777216) % 256) + end + pushLe(27) -- days + pushLe(29) -- hours + pushLe(11) -- minutes + pushLe(200) -- seconds + parts[#parts + 1] = string.rep("\0", 16) -- latched RTC copies + parts[#parts + 1] = string.rep("\0", 8) -- 64-bit unix timestamp + pushLe(0x669A00BF) -- 32-bit unix timestamp (2024-07-09) + return table.concat(parts) +end + +do + local oversize = syntheticSave("OVS") .. rtcFooter() + eq(#oversize, 32768 + 44, "the oversize fixture is 32812 bytes like the real VBA save") + + local files = fresh() + local ok, res, info = SaveFileIO.importToSlot(fileSource(oversize), "red") + eq(ok, false, "an oversize valid save is not imported without confirmation") + eq(res, nil, "the oversize result carries no error string") + check(info ~= nil and info.needsConfirm == true, "the oversize result requests confirmation") + eq(info and info.size, #oversize, "the confirmation carries the actual file size") + eq(#SaveData.listSlots("red"), 0, "no slot is created before confirmation") + + -- forcing the import truncates the footer away + local fok, slotId = SaveFileIO.importToSlot(fileSource(oversize), "red", true) + eq(fok, true, "force imports the truncated save") + local loaded = SaveData.load("red") + eq(loaded and loaded.player.name, "OVS", "the forced import keeps the player name") + eq(SaveData.activeSlot("red"), slotId, "the forced import becomes active") + + local eok, path = SaveFileIO.exportActiveSlot("red") + eq(eok, true, "the forced import exports") + local rel = path:gsub("^/fake/save/", "") + local outBytes = files[rel] + eq(outBytes and #outBytes, GenSave.SAVE_SIZE, + "the export of a forced import is exactly 32768 bytes (footer dropped)") + check(outBytes and mainChecksumValid(outBytes), + "the forced-import export carries a valid main-data checksum") +end + +do + -- oversize but corrupt: flip a byte inside the checksummed region + local bad = syntheticSave("BAD") .. rtcFooter() + bad = bad:sub(1, OFF.money) + .. string.char((bad:byte(OFF.money + 1) + 1) % 256) + .. bad:sub(OFF.money + 2) + fresh() + local ok, res = SaveFileIO.importToSlot(fileSource(bad), "red") + eq(ok, false, "an oversize file with a bad checksum is rejected") + check(type(res) == "string" and res:find("checksum", 1, true) ~= nil, + "the oversize bad-checksum error mentions the checksum") + eq(#SaveData.listSlots("red"), 0, "no slot is created for a bad oversize file") +end + +do + -- truncated to 14000 bytes: >= 13572 keeps the whole checksum region and the + -- stored checksum byte intact, so the checksum validates and the save imports + -- with the missing tail (box banks) zero-filled. + local truncated = syntheticSave("SHORT"):sub(1, 14000) + check(truncated:len() >= OFF.mainChecksum + 1, + "the truncated fixture still carries the stored checksum byte") + fresh() + local ok, slotId = SaveFileIO.importToSlot(fileSource(truncated), "red") + eq(ok, true, "a truncated file with a valid checksum imports zero-padded") + local loaded = SaveData.load("red") + eq(loaded and loaded.player.name, "SHORT", "the truncated import keeps the player name") + eq(#SaveData.listSlots("red"), 1, "the truncated import creates a slot") + eq(SaveData.activeSlot("red"), slotId, "the truncated import becomes active") + + -- truncated but too short to even carry the checksum byte -> refused + local short = truncated:sub(1, OFF.mainChecksum) + local sok, serr = SaveFileIO.importToSlot(fileSource(short), "red") + eq(sok, false, "a file too short to hold a checksum byte is refused") + check(type(serr) == "string" and serr:find("32", 1, true) ~= nil, + "the too-short error names the required size") + eq(#SaveData.listSlots("red"), 1, "the too-short refusal creates no new slot") +end + -- ---------------------------------------------- fixture-gated real save do diff --git a/tests/parity_A.lua b/tests/parity_A.lua index 7c8bd473..7c15caa0 100644 --- a/tests/parity_A.lua +++ b/tests/parity_A.lua @@ -183,7 +183,7 @@ do new = function(game, text, done) return { text = text, onDone = done } end, } - local function driveLeader(mapId, textConst, beatFlag) + local function driveLeader(mapId, textConst, beatFlag, gotFlag) local pushed, engaged local game = { save = { flags = {} }, @@ -198,6 +198,9 @@ do mapId .. " leader talk (no badge) engages the leader battle") engaged, pushed = nil, nil game.save.flags[beatFlag] = true + -- the advice/farewell branch is pokered's .afterBeat, reached only + -- once EVENT_GOT_TM* is set (the TM went into the bag) + if gotFlag then game.save.flags[gotFlag] = true end local state = {} script(game, ow, { def = {} }, function() state.doneCalled = true end) check(pushed and not engaged, @@ -206,17 +209,17 @@ do end local _, box = driveLeader("CERULEAN_GYM", "TEXT_CERULEANGYM_MISTY", - "EVENT_BEAT_MISTY") + "EVENT_BEAT_MISTY", "EVENT_GOT_TM11") eq(box and box.text, Data.text._CeruleanGymMistyTM11ExplanationText, "Misty (beaten) shows the TM11 explanation text") _, box = driveLeader("CINNABAR_GYM", "TEXT_CINNABARGYM_BLAINE", - "EVENT_BEAT_BLAINE") + "EVENT_BEAT_BLAINE", "EVENT_GOT_TM38") eq(box and box.text, Data.text._CinnabarGymBlainePostBattleAdviceText, "Blaine (beaten) shows his post-battle advice text") local game, gbox, state = driveLeader("VIRIDIAN_GYM", "TEXT_VIRIDIANGYM_GIOVANNI", - "EVENT_BEAT_GIOVANNI") + "EVENT_BEAT_GIOVANNI", "EVENT_GOT_TM27") eq(gbox and gbox.text, Data.text._ViridianGymGiovanniPostBattleAdviceText, "Giovanni (beaten) shows his farewell text") @@ -392,6 +395,59 @@ do check(cinnabarNpc and ow:trainerDefeated(cinnabarNpc), "unfought Cinnabar trainer is defeated via seeded header event") + -- #797: a full bag at the victory skips the TM hand-over (pokered's + -- `call GiveItem` / `jr nc, .BagFull`): badge and beat flag still land, + -- but EVENT_GOT_TM34 stays unset and the "make room" line replaces the + -- received/explanation texts. Talking to Brock afterwards re-runs the + -- ReceiveTM script and grants the TM once there is room. + while Game.stack:top() do Game.stack:pop() end + Game.save = SaveData.newGame() + Game.save.flags = {} + Game.save.inventory = {} + Game.save.defeatedTrainers = {} + local Bag = require("src.inventory.Bag") + for i = 1, Bag.capacity(Data) do + Bag.add(Game.save, "FULLBAG_" .. i, 1, Data) + end + eq(Bag.slots(Game.save), Bag.capacity(Data), "bag is full before Brock") + Game.stack:push(OW, "PEWTER_GYM", 4, 13, "up") + ow = Game.stack:top() + ow:checkVictoryRewards("OPP_BROCK", 1) + local fullBagText = stackedDialogue() + check(Game.save.flags.EVENT_BEAT_BROCK, + "full bag: Brock victory still sets EVENT_BEAT_BROCK") + check(Game.save.inventory.BOULDERBADGE == 1, + "full bag: Brock victory still awards BOULDERBADGE") + check(not Game.save.flags.EVENT_GOT_TM34, + "full bag: EVENT_GOT_TM34 stays unset (bag_full branch)") + check(Game.save.inventory.TM_BIDE == nil, + "full bag: TM34 is not forced into the bag") + check(fullBagText:find("room", 1, true) ~= nil, + "full bag: victory dialogue shows Brock's make-room line") + check(fullBagText:find("BIDE", 1, true) == nil, + "full bag: received/explanation texts are skipped") + + -- make room, then talk to Brock: the middle branch re-runs ReceiveTM34 + while Game.stack:top() do Game.stack:pop() end + Bag.remove(Game.save, "FULLBAG_1", 1) + local brockTalk = init.talkScript("PEWTER_GYM", "TEXT_PEWTERGYM_BROCK") + check(brockTalk ~= nil, "Brock's talk script is registered") + brockTalk(Game, ow, { def = {} }, function() end) + local retryText = stackedDialogue() + check(retryText:find("Wait!", 1, true) ~= nil, + "retry: ReceiveTM34 lead-in (Wait! Take this!) shows again") + check(retryText:find("BIDE", 1, true) ~= nil, + "retry: received/explanation texts show once the TM fits") + eq(Game.save.inventory.TM_BIDE, 1, "retry: TM34 goes into the bag") + check(Game.save.flags.EVENT_GOT_TM34, "retry: EVENT_GOT_TM34 is set") + + -- once the TM is handed over, Brock falls back to his advice text + while Game.stack:top() do Game.stack:pop() end + brockTalk(Game, ow, { def = {} }, function() end) + local adviceText = stackedDialogue() + check(adviceText:find("CERULEAN", 1, true) ~= nil, + "after the TM: Brock shows his post-battle advice text") + while Game.stack:top() do Game.stack:pop() end end diff --git a/tests/parity_gym_tm_bag_full_bug797.lua b/tests/parity_gym_tm_bag_full_bug797.lua index e0101d13..2117fe87 100644 --- a/tests/parity_gym_tm_bag_full_bug797.lua +++ b/tests/parity_gym_tm_bag_full_bug797.lua @@ -13,9 +13,10 @@ -- The port drives this through OverworldState:checkVictoryRewards over -- data/scripts/victories.lua (gym leaders are not def_trainers entries, so -- src/script/Commands.lua give_item -- which has always handled a full bag --- -- is never on this path). checkVictoryRewards used to write the TM --- straight into save.inventory, bypassing Bag.add's BAG_ITEM_CAPACITY --- check and producing a 21-of-20 bag while still printing "received TM". +-- -- is never on this path). Each gym entry splits the hand-over into +-- tmPre (the lead-in), tmDialogue (GiveItem succeeded) and noRoom (the +-- .BagFull line), with gotFlag (EVENT_GOT_TM*) set only on success so the +-- leader's talk script can retry later (offerGymTm via gyms.lua). package.path = "./?.lua;./?/init.lua;" .. package.path if not _G.love then _G.love = require("tests.love_stub") end local Data = require("src.core.Data") @@ -34,17 +35,19 @@ do for key, entry in pairs(victories) do if entry.badge then n = n + 1 - check(type(entry.itemDialogue) == "table" and #entry.itemDialogue > 0, - key .. " has an itemDialogue tail (the GiveItem-succeeded text)") - check(type(entry.bagFull) == "string", - key .. " names a bagFull text (.BagFull branch)") - local body = entry.bagFull and (Data.text or {})[entry.bagFull] + check(type(entry.tmDialogue) == "table" and #entry.tmDialogue > 0, + key .. " has a tmDialogue tail (the GiveItem-succeeded text)") + check(type(entry.noRoom) == "string", + key .. " names a noRoom text (.BagFull branch)") + local body = entry.noRoom and (Data.text or {})[entry.noRoom] check(type(body) == "string" and body ~= "", - key .. " bagFull label resolves to extracted text") + key .. " noRoom label resolves to extracted text") + check(type(entry.gotFlag) == "string" and entry.gotFlag:find("EVENT_GOT_"), + key .. " carries the EVENT_GOT_TM* retry flag") -- the received-TM tail must not still be baked into `dialogue`, -- or the full-bag path would print it anyway for _, label in ipairs(entry.dialogue or {}) do - for _, tail in ipairs(entry.itemDialogue or {}) do + for _, tail in ipairs(entry.tmDialogue or {}) do check(label ~= tail, key .. " dialogue no longer repeats " .. tostring(tail)) end @@ -115,6 +118,8 @@ check(fullText:find("BIDE", 1, true) == nil, "full bag: the TM34 explanation is skipped") check(fullText:find("FLASH", 1, true) ~= nil, "full bag: the BoulderBadge speech still runs") +check(not Game.save.flags.EVENT_GOT_TM34, + "full bag: EVENT_GOT_TM34 stays unset so the talk script retries") -- --- empty bag: the success tail still appends and the TM lands --- freshSave() @@ -129,7 +134,9 @@ local order = Bag.order(Game.save) check(order[1] == "TM_BIDE", "empty bag: Bag.add kept the wBagItems order (bagOrder) honest") check(okText:find("BIDE", 1, true) ~= nil, - "empty bag: itemDialogue (TM34 explanation) still appends") + "empty bag: tmDialogue (TM34 explanation) still appends") +check(Game.save.flags.EVENT_GOT_TM34, + "empty bag: EVENT_GOT_TM34 is set on a successful give") check(okText:find("room for this", 1, true) == nil, "empty bag: the NoRoom line is not printed") diff --git a/tests/parity_rare_candy_menu.lua b/tests/parity_rare_candy_menu.lua new file mode 100644 index 00000000..ed03d62b --- /dev/null +++ b/tests/parity_rare_candy_menu.lua @@ -0,0 +1,179 @@ +-- Parity test: using a RARE CANDY from the bag returns to the bag (#796). +-- +-- pokered files RARE_CANDY under UsableItems_PartyMenu (data/items/ +-- use_party.asm), so after UseItem runs, start_sub_menus.asm's +-- .useItem_partyMenu jumps back to StartMenu_Item with wBagSavedMenuItem +-- still pointing at the candy -- the level text, PrintStatsBox, the +-- level-up moves and any level evolution all happen first, then the item +-- list is back with the cursor on the candy, which is what lets the +-- original button-mash through a stack of them. The port closed the bag +-- list before the level-up sequence and never reopened it, so every candy +-- cost a full START -> ITEM trip. +-- +-- Self-contained; run via `luajit tests/parity_rare_candy_menu.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 rare candy menu") +local check, eq = S.check, S.eq + +local Data = require("src.core.Data") +Data:load() + +local Pokemon = require("src.pokemon.Pokemon") +local Bag = require("src.inventory.Bag") + +-- Real TextBoxes want a Font atlas; the flow under test only cares which +-- states land on the stack and what each onDone does. BagMenu binds +-- TextBox at require time, so it is reloaded against the stub here and +-- dropped again at the bottom. +local realTextBox = package.loaded["src.render.TextBox"] +local realBag = package.loaded["src.ui.BagMenu"] +local realParty = package.loaded["src.ui.PartyMenu"] +package.loaded["src.render.TextBox"] = { + new = function(_, text, done) return { textBox = true, text = text, done = done } end, +} +package.loaded["src.ui.BagMenu"] = nil +package.loaded["src.ui.PartyMenu"] = nil +local BagMenu = require("src.ui.BagMenu") +local PartyMenu = require("src.ui.PartyMenu") +require("src.ui.Screens").invalidate() + +-- A stack that behaves like StateStack for the two things this flow reads: +-- top() identity (ListMenu:close) and push/pop ordering. +local function newStack() + local stack = { states = {} } + function stack:push(s) self.states[#self.states + 1] = s end + function stack:pop() return table.remove(self.states) end + function stack:top() return self.states[#self.states] end + return stack +end + +-- One button per call: PartyMenu:update reads game.input once per fixed step. +local function newInput() + local input = { pressed = nil } + function input:wasPressed(b) return self.pressed == b end + return input +end + +-- CHARIZARD learns nothing at level 51 and has no evolution left, so a +-- candy on it ends after the stat window -- no MoveLearnMenu, no +-- EvolutionState clouding which state the stack returns to. +local function freshGame(candies) + local lead = Pokemon.new(Data, "CHARIZARD", 50) + local game = { + data = Data, + stack = newStack(), + input = newInput(), + save = { + party = { lead }, + player = { name = "RED" }, + inventory = {}, + options = { battleStyle = "set", battleAnim = "on" }, + pokedex = { seen = {}, owned = {} }, + flags = {}, + money = 0, + }, + } + Bag.add(game.save, "RARE_CANDY", candies or 3) + return game, lead +end + +local function isPicker(s) return getmetatable(s) == PartyMenu end +local function isBox(s) return type(s) == "table" and s.textBox == true end + +-- TextBox pops itself BEFORE firing onDone. +local function dismiss(stack, box) + if stack:top() == box then stack:pop() end + if box.done then box.done() end +end + +local function rowFor(list, id) + for i, r in ipairs(list.items) do + if r.value == id then return i end + end + return nil +end + +-- From an already-open bag list: choose the candy, take USE off the +-- submenu, press A on the party picker. Returns the level-up text box. +local function useCandy(game, list) + local row = rowFor(list, "RARE_CANDY") + if not row then return nil, "no RARE CANDY row in the bag" end + list.index = row + list.onChoose(list.items[row], list) + -- out of battle the bag offers USE / TOSS first (start_sub_menus.asm) + local sub = game.stack:top() + if sub and sub.items and sub.items[1] and sub.items[1].onSelect then + game.stack:pop() + sub.items[1].onSelect() + end + local picker = game.stack:top() + if not isPicker(picker) then return nil, "party picker never opened" end + game.input.pressed = "a" + picker:update(1 / 60) + game.input.pressed = nil + return game.stack:top() +end + +-- Walk the rest of the level-up sequence: dismiss the level text, then A +-- through the stat window (PrintStatsBox). +local function finishLevelUp(game, box) + dismiss(game.stack, box) + local top = game.stack:top() + if isBox(top) then return end -- a "learned MOVE" line, dismissed by caller + if top and top.update then + game.input.pressed = "a" + top:update(1 / 60) + game.input.pressed = nil + end +end + +-- ---- the report: one candy closed the whole menu ------------------------- +do + local game, lead = freshGame(3) + local list = BagMenu.new(game, {}) + game.stack:push(list) + local row = rowFor(list, "RARE_CANDY") + check(row ~= nil, "the candy is in the bag") + + local box = useCandy(game, list) + check(isBox(box), "the level text opened") + eq(lead.level, 51, "the candy leveled the mon") + eq(game.save.inventory.RARE_CANDY, 2, "the candy was consumed") + check(game.stack.states[1] == list, + "the bag list is STILL on the stack under the level text (#796)") + + finishLevelUp(game, box) + eq(game.stack:top(), list, + "after the stat window the bag is back on top (StartMenu_Item)") + eq(list.index, row, "the cursor is still on the RARE CANDY row") + eq(list.items[row] and list.items[row].right, "x2", + "the count refreshed in place") + + -- the point of the original's behavior: a second candy needs no menu trip + local box2 = useCandy(game, list) + check(isBox(box2), "a second candy fires from the still-open bag") + eq(lead.level, 52, "and it levels the mon again") + finishLevelUp(game, box2) + eq(game.stack:top(), list, "still on the bag after the second candy") + eq(game.save.inventory.RARE_CANDY, 1, "two candies spent") +end + +-- ---- the last candy: the row empties but the bag still stays up ---------- +do + local game = freshGame(1) + local list = BagMenu.new(game, {}) + game.stack:push(list) + local box = useCandy(game, list) + check(isBox(box), "the last candy levels too") + finishLevelUp(game, box) + eq(game.stack:top(), list, "the bag stays open after the last candy") + eq(#list.items, 0, "the emptied row left the list") + eq(game.save.inventory.RARE_CANDY, nil, "no candies left in the inventory") +end + +package.loaded["src.render.TextBox"] = realTextBox +package.loaded["src.ui.BagMenu"] = realBag +package.loaded["src.ui.PartyMenu"] = realParty +require("src.ui.Screens").invalidate() +S.finish() diff --git a/tests/save_editor_task7_tests.lua b/tests/save_editor_task7_tests.lua index b45179fc..b6c8e4c7 100644 --- a/tests/save_editor_task7_tests.lua +++ b/tests/save_editor_task7_tests.lua @@ -39,6 +39,7 @@ print("== save editor task 7 tests (Events + Dex) ==") local Ops = require("Ops") local State = require("State") +local Catalog = require("Catalog") local function newState() local S = State.new() @@ -209,5 +210,118 @@ do check(owned > 0, "the dex was not wiped by the unrelated click") end +-- Dex sort ----------------------------------------------------------- + +do + -- Ops.dexList orders the grid by the active mode. This block drives the + -- real generated data (State.new alone carries no Data), so the vanilla + -- 1-151 numbering is what the "dex" mode asserts against. + local Data = require("src.core.Data") + Data:load() + local S = State.new() + S.data = Data + S.cat = Catalog.build(Data) + S.save = require("src.core.SaveData").newGame() + + -- default mode is "dex": number order + eq(S.dexSort, "dex", "a fresh state sorts the dex by number by default") + local byDex = Ops.dexList(S) + eq(#byDex, #S.cat.species, "dexList covers every species") + eq(byDex[1], "BULBASAUR", "dex order starts at #1") + eq(byDex[4], "CHARMANDER", "dex order puts Charmander fourth") + eq(byDex[25], "PIKACHU", "dex order puts Pikachu at #25") + eq(byDex[151], "MEW", "dex order ends at #151") + + -- "name" mode: alphabetical by display name + Ops.dexSort(S, "name") + eq(S.dexSort, "name", "dexSort switches the mode") + local byName = Ops.dexList(S) + eq(#byName, #S.cat.species, "the name sort covers every species too") + eq(byName[1], "ABRA", "the name sort leads with ABRA") + local sorted = true + for i = 2, #byName do + local a = S.data.pokemon[byName[i - 1]] + local b = S.data.pokemon[byName[i]] + local an = (a and a.name or byName[i - 1]):lower() + local bn = (b and b.name or byName[i]):lower() + if an > bn then sorted = false break end + end + check(sorted, "the name sort is alphabetical over display names") + local mi = nil + for i, id in ipairs(byName) do + if id == "MEW" then mi = i + elseif id == "MR_MIME" and mi then + check(i > mi, "MEW sorts before MR.MIME in the name sort") + end + end + local fIdx, mIdx = nil, nil + for i, id in ipairs(byName) do + if id == "NIDORAN_F" then fIdx = i elseif id == "NIDORAN_M" then mIdx = i end + end + check(fIdx and mIdx and fIdx < mIdx, "NIDORAN_F sorts before NIDORAN_M") + + -- switching back to the number order restores the original sequence + Ops.dexSort(S, "dex") + local back = Ops.dexList(S) + eq(back[1], "BULBASAUR", "switching back restores number order") +end + +do + -- the switch is view-only: it resets the grid scroll but never dirties the + -- save, and a re-click on the active mode is a narrated no-op + local S = newState() + S.data = { pokemon = { BULBASAUR = { dex = 1, name = "BULBASAUR" }, + CHARMANDER = { dex = 4, name = "CHARMANDER" }, + PIKACHU = { dex = 25, name = "PIKACHU" }, + SQUIRTLE = { dex = 7, name = "SQUIRTLE" } } } + S.cat = { species = { "BULBASAUR", "CHARMANDER", "SQUIRTLE", "PIKACHU" }, + items = {}, moves = {} } + S.dexOffset = 9 + S.dirty = false + + check(Ops.dexSort(S, "name") == true, "dexSort switches the mode without dirtying") + eq(S.dirty, false, "a sort never dirties the save") + eq(S.dexOffset, 0, "changing the sort resets the grid scroll") + eq(S.status, "", "a sort leaves the status bar alone") + + S.dexOffset = 4 + check(Ops.dexSort(S, "name") == false, "re-clicking the active mode is a no-op") + eq(S.dexOffset, 4, "a no-op sort leaves the scroll alone") + eq(S.dirty, false, "a no-op sort does not dirty either") + eq(S.status, "", "a no-op sort does not narrate either") + + check(Ops.dexSort(S, "bogus") == false, "an unknown mode is refused") + eq(S.dexSort, "name", "a refused mode leaves the sort unchanged") + + -- the keyed list sorts against this mini dataset too + local byDex = Ops.dexList(S) + eq(byDex[1], "BULBASAUR", "mini-catalog dex order is #1 first") + eq(byDex[2], "CHARMANDER", "mini-catalog dex order is #4 second") + Ops.dexSort(S, "name") + eq(Ops.dexList(S)[1], "BULBASAUR", "mini-catalog name order leads with BULBASAUR") +end + +do + -- robustness: a mod-shaped partial record (no name, no dex) must not crash + -- the sort or disappear from the grid -- it just sorts last + local S = newState() + S.data = { pokemon = { BULBASAUR = { dex = 1, name = "BULBASAUR" }, + PARTIAL = { baseStats = { hp = 40 } } } } + S.cat = { species = { "BULBASAUR", "PARTIAL" }, items = {}, moves = {} } + + local byDex = Ops.dexList(S) + eq(#byDex, 2, "a partial record still appears in the dex order") + eq(byDex[2], "PARTIAL", "a record without a dex number sorts last") + + Ops.dexSort(S, "name") + local byName = Ops.dexList(S) + eq(#byName, 2, "a partial record still appears in the name order") + eq(byName[2], "PARTIAL", "a record without a name sorts last, by its id") + + -- and with no data/catalog at all, the list degrades to empty, not nil + local bare = State.new() + eq(#Ops.dexList(bare), 0, "a state with no catalog yields an empty list") +end + print(string.format("save editor task 7 tests: %d passed, %d failed", passed, failed)) if failed > 0 then os.exit(1) end diff --git a/tests/save_oversize_vendor_test.lua b/tests/save_oversize_vendor_test.lua new file mode 100644 index 00000000..c1c0452a --- /dev/null +++ b/tests/save_oversize_vendor_test.lua @@ -0,0 +1,174 @@ +-- Independent-oracle test for the oversize-save import path in +-- src/import/SaveFileIO.lua (importToSlot force/truncate when a .sav exceeds +-- 32768 bytes with a valid main-data checksum -- i.e. a cartridge save padded +-- with an emulator RTC footer). +-- +-- The fixture is built by the VENDOR codec (tools/save_convert/vendor/ +-- gen1lib.lua, a PKHeX-derived Gen1 .sav<->JSON codec): the bytes GenSave +-- later imports were never produced by GenSave. The post-truncation export is +-- then re-parsed by that SAME vendor codec -- a second source independent of +-- GenSave -- confirming the forced truncation drops only the footer. +-- +-- Runs under stock Lua 5.3/5.4/5.5 (gen1lib needs native bitwise operators and +-- cannot even be parsed by LuaJIT); GenSave gets a `bit` shim backed by those +-- operators, exactly like tools/save_convert/crosscheck.lua. +-- lua tests/save_oversize_vendor_test.lua +-- +-- The luajit side of this policy lives in save_file_io_tests.lua; this file is +-- the out-of-band vendor oracle (see save_convert_tests.lua for the same +-- split). It lives OUTSIDE tests/engine/ on purpose: tier_runner globs that +-- directory under luajit, which cannot parse gen1lib. scripts/test.sh runs it +-- as its own lua5.4 tier when that interpreter is available. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +-- `bit` shim backed by native Lua 5.3+ operators (crosscheck.lua's). +if not pcall(require, "bit") then + package.preload["bit"] = function() + local M = {} + 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 + +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 gen1 = dofile("tools/save_convert/vendor/gen1lib.lua") + +-- The vendor fixture needs no data/generated for BUILDING, but the import +-- (SaveConvert.importSav -> GenSave.decode) needs the crosswalk tables, so +-- skip cleanly on a checkout that never imported a ROM (like the luajit suite). +local loadPokemon = loadfile("data/generated/pokemon.lua") +if not loadPokemon then + print("save_oversize_vendor skipped (needs data/generated/ for GenSave codec)") + os.exit(0) +end + +local realFS = love.filesystem + +-- Same love.filesystem stub as save_file_io_tests.lua: keyed by full path with +-- the export surface SaveFileIO reaches (createDirectory/getSaveDirectory). +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 + +-- DroppedFile-shaped source (readSource treats a raw string of length != 32768 +-- as a path, so hand a file object). +local function fileSource(bytes) + return { + _bytes = bytes, + open = function() return true end, + getSize = function(self) return #self._bytes end, + read = function(self) return self._bytes end, + close = function() return true end, + } +end + +-- A realistic 44-byte VBA MBC3 RTC footer (bgb.bircd.org/rtcsave.html). +local function rtcFooter() + local parts = {} + local function pushLe(v) + parts[#parts + 1] = string.char(v % 256, math.floor(v / 256) % 256, + math.floor(v / 65536) % 256, math.floor(v / 16777216) % 256) + end + pushLe(27); pushLe(29); pushLe(11); pushLe(200) + parts[#parts + 1] = string.rep("\0", 16) + parts[#parts + 1] = string.rep("\0", 8) + pushLe(0x669A00BF) + return table.concat(parts) +end + +-- Build a 32768-byte save ENTIRELY through the vendor codec: zero base buffer, +-- trainer/party/box fields written by gen1lib, checksum recomputed by +-- gen1lib. GenSave had no part in producing these bytes. +local function vendorSave() + local data = { + raw_base64 = gen1.base64_encode(string.rep("\0", GenSave.SAVE_SIZE)), + trainer = { + name = "VENDOR", id = 12345, rival_name = "BLUE", + money = 4321, coins = 0, badges = 0, options = 0, starter = 0, + pikachu_friendship = 0, pikachu_beach_score = 0, + }, + current_box = 1, + party = {}, + boxes = {}, + } + return gen1.build_save(data) +end + +-- ---------------------------------------------- vendor-built oversize round trip + +do + local base = vendorSave() + eq(#base, GenSave.SAVE_SIZE, "the vendor codec builds a 32768-byte save") + eq(SaveConvert.mainChecksumValid(base), true, + "the vendor-built save carries a valid main-data checksum") + + local oversize = base .. rtcFooter() + eq(#oversize, 32768 + 44, "the oversize fixture is 32812 bytes") + + local files = fresh() + -- the save the project has never seen imports cleanly once force truncates + local ok, slotId = SaveFileIO.importToSlot(fileSource(oversize), "red", true) + eq(ok, true, "the vendor-built oversize save imports with force") + local loaded = SaveData.load("red") + eq(loaded and loaded.player.name, "VENDOR", "the imported save keeps the vendor-written name") + eq(loaded and loaded.money, 4321, "the imported save keeps the vendor-written money") + + local eok, path = SaveFileIO.exportActiveSlot("red") + eq(eok, true, "the forced import exports") + local rel = path:gsub("^/fake/save/", "") + local outBytes = files[rel] + eq(outBytes and #outBytes, GenSave.SAVE_SIZE, "the export is exactly 32768 bytes") + + -- INDEPENDENT ORACLE: the vendor codec re-parses the project's export. If + -- the truncation had damaged the save, or GenSave's codec self-consistently + -- corrupted it, parse_save would disagree here. + local outBuf = gen1.string_to_bytes(outBytes) + local parsed = gen1.parse_save(outBuf) + eq(parsed.trainer.name, "VENDOR", "vendor parse of the export: name intact") + eq(parsed.trainer.money, 4321, "vendor parse of the export: money intact") + eq(parsed.trainer.id, 12345, "vendor parse of the export: trainer id intact") + eq(#parsed.party, 0, "vendor parse of the export: empty party preserved") + eq(#parsed.boxes, 12, "vendor parse of the export: 12 boxes present") +end + +love.filesystem = realFS + +T.finish("save_oversize_vendor") diff --git a/tools/save-editor/Ops.lua b/tools/save-editor/Ops.lua index 78d72092..853c2d6b 100644 --- a/tools/save-editor/Ops.lua +++ b/tools/save-editor/Ops.lua @@ -717,6 +717,58 @@ function Ops.dexClear(S) return Ops.mark(S, "Pokedex wiped") end +-- ------------------------------------------------------------------ dex sort +-- The DEX grid's row order. Sorting is view-only: it never touches the save, +-- so the list itself is computed here (pure, testable) and the switch is +-- narrated through Ops.say, never Ops.mark. +-- +-- "dex" -- by Pokedex number (1-151), the panel default +-- "name" -- by display name, alphabetical (case-insensitive) +-- +-- A species whose record lacks the sort key (a partial mod record) sorts +-- last, ordered by its id, so the grid can never drop a row or crash. +-- table.sort is not stable, so every sort carries the id as a tiebreak and +-- the order is fully deterministic. +local SORT_KEYS = { + dex = function(def, id) + return def and def.dex or math.huge + end, + name = function(def, id) + local name = def and def.name + return (name and tostring(name):lower()) or tostring(id):lower() + end, +} + +function Ops.dexList(S) + local list = S and S.cat and S.cat.species + if not list then return {} end + local make = SORT_KEYS[S.dexSort == "name" and "name" or "dex"] + local data = S.data + local rows = {} + for _, id in ipairs(list) do + rows[#rows + 1] = { key = make(data and data.pokemon and data.pokemon[id], id), + id = id } + end + table.sort(rows, function(a, b) + if a.key ~= b.key then return a.key < b.key end + return a.id < b.id + end) + local out = {} + for i, r in ipairs(rows) do out[i] = r.id end + return out +end + +-- View-only verb: switching the DEX grid's order resets its scroll but never +-- dirties the save or narrates in the status bar (the active chip carries +-- the mode). Returns true when the mode changed, false on a no-op. +function Ops.dexSort(S, mode) + if mode ~= "name" and mode ~= "dex" then return false end + if S.dexSort == mode then return false end + S.dexSort = mode + S.dexOffset = 0 + return true +end + -- -------------------------------------------------------------------- map -- Outdoor is detected the way the game treats LAST_MAP sources: -- OVERWORLD/PLATEAU tilesets, maps with connections, or fly spots the save diff --git a/tools/save-editor/State.lua b/tools/save-editor/State.lua index 47742c78..cdbf84ea 100644 --- a/tools/save-editor/State.lua +++ b/tools/save-editor/State.lua @@ -77,6 +77,7 @@ function State.new() eventsOffset = 0, -- dex + dexSort = "dex", -- how the DEX grid is ordered: "dex" (by number) | "name" (A-Z) dexOffset = 0, -- map diff --git a/tools/save-editor/panels/Dex.lua b/tools/save-editor/panels/Dex.lua index f8b1629c..d7929485 100644 --- a/tools/save-editor/panels/Dex.lua +++ b/tools/save-editor/panels/Dex.lua @@ -8,6 +8,7 @@ local Theme = require("Theme") local Ops = require("Ops") +local MonEditor = require("MonEditor") local PAL = Theme.PAL local M = {} @@ -24,7 +25,7 @@ function M.draw(S, Kit, x, y, w, h) local s = Kit.scale local pad = 20 * s local dex = Ops.dex(S) - local species = S.cat.species + local species = Ops.dexList(S) local seen, owned, total = Ops.dexCounts(S) Kit.card(x, y, w, h) @@ -44,12 +45,20 @@ function M.draw(S, Kit, x, y, w, h) -- and FLOW, wrapping to further rows when even one is too narrow, so the -- cluster can never paint over the headline or over itself. local actH = 34 * s + -- The two sort chips are view-only (Ops.dexSort never dirties the save); + -- the active mode reads as the accent chip, the other as ghost. They ride + -- the same wrap-aware cluster as the bulk actions so a narrow window flows + -- them to their own rows instead of painting over the headline (#715). local buttons = { { label = "Own party + boxes", kind = "ghost", fn = Ops.dexStamp }, { label = "See all", kind = "accent", fn = Ops.dexSeeAll }, { label = "Own all", kind = "good", fn = Ops.dexOwnAll }, { label = Ops.armLabel(S, "dex-clear", "Wipe dex"), kind = "danger", fn = Ops.dexClear }, + { label = "Dex #", kind = (S.dexSort ~= "name") and "accent" or "ghost", + fn = function(s) Ops.dexSort(s, "dex") end }, + { label = "A-Z", kind = (S.dexSort == "name") and "accent" or "ghost", + fn = function(s) Ops.dexSort(s, "name") end }, } local clusterW = -10 * s for _, b in ipairs(buttons) do @@ -140,9 +149,13 @@ function M.draw(S, Kit, x, y, w, h) Theme.row(rx, ry, colW, rowH, 9 * s, 0.6) local def = S.data.pokemon[id] - Kit.text("micro", ("%03d"):format(def and def.dex or 0), rx + 10 * s, + local dexText = ("%03d"):format(def and def.dex or 0) + Kit.text("micro", dexText, rx + 10 * s, ry + (rowH - Kit.textHeight("micro")) / 2, PAL.faint) - local nameX = rx + 44 * s + local spriteS = 24 * s + local spriteX = rx + 10 * s + Kit.textWidth("micro", dexText) + 8 * s + MonEditor.drawSprite(S, Kit, id, spriteX, ry + (rowH - spriteS) / 2, spriteS) + local nameX = spriteX + spriteS + 6 * s local nameW = colW - 10 * s - 2 * (chipW + 6 * s) - (nameX - rx) Kit.text("mono", Kit.ellipsize("mono", id, nameW), nameX, ry + (rowH - Kit.textHeight("mono")) / 2,