mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 00:10:56 +02:00
Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev
This commit is contained in:
+37
-14
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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).
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user