This commit is contained in:
bryanthaboi
2026-08-05 11:15:49 -04:00
23 changed files with 1128 additions and 132 deletions
+37 -14
View File
@@ -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
+50 -36
View File
@@ -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
+6 -2
View File
@@ -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
+11
View File
@@ -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 ""
+10 -3
View File
@@ -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
+65
View File
@@ -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
+2
View File
@@ -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
+24 -4
View File
@@ -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.
+22 -6
View File
@@ -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
+12
View File
@@ -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
+1
View File
@@ -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
+14 -21
View File
@@ -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).
+67 -28
View File
@@ -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
@@ -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()
+106
View File
@@ -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
+60 -4
View File
@@ -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
+18 -11
View File
@@ -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")
+179
View File
@@ -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()
+114
View File
@@ -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
+174
View File
@@ -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")
+52
View File
@@ -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
+1
View File
@@ -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
+16 -3
View File
@@ -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,