From 0ae29744c87b74a1372e991089db2141783968f1 Mon Sep 17 00:00:00 2001 From: Martin Date: Thu, 6 Aug 2026 14:06:55 +0200 Subject: [PATCH 01/63] Fix Blue ROM source setup --- scripts/run.sh | 11 ++++-- scripts/test.sh | 1 + src/import/RomImporter.lua | 20 +++++----- tests/build_rom_data_cli_test.py | 65 ++++++++++++++++++++++++++++++ tools/build_rom_data.py | 68 ++++++++++++++++++++++++-------- 5 files changed, 135 insertions(+), 30 deletions(-) create mode 100644 tests/build_rom_data_cli_test.py diff --git a/scripts/run.sh b/scripts/run.sh index dc08f50d..f0110d21 100755 --- a/scripts/run.sh +++ b/scripts/run.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash # Run the LÖVE2D Pokémon Red port (macOS-friendly). # -# Assumes scripts/setup.sh has been run once (generated data present and -# LÖVE installed). Extra arguments are passed through to LÖVE. +# Assumes scripts/setup.sh has been run once for at least one game (generated +# data present and LÖVE installed). Extra arguments are passed through to LÖVE. # # Link play is peer-to-peer (lua-enet, bundled with LÖVE): one player # picks HOST A GAME in START > LINK and reads out the address shown; @@ -15,8 +15,11 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd)" fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; } -[ -f "$ROOT/data/generated/maps.lua" ] \ - || fail "generated data missing, run scripts/setup.sh first" +if [ ! -f "$ROOT/data/generated/maps.lua" ] \ + && [ ! -f "$ROOT/blue/data/generated/maps.lua" ] \ + && [ ! -f "$ROOT/yellow/data/generated/maps.lua" ]; then + fail "generated data missing, run scripts/setup.sh first" +fi find_love() { command -v love >/dev/null 2>&1 && { echo "love"; return; } diff --git a/scripts/test.sh b/scripts/test.sh index fe1a3120..d88e6128 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -66,6 +66,7 @@ run_tier() { # ------- ROM-free tiers: these are what CI runs +run_tier "T0 ROM builder version routing" python3 tests/build_rom_data_cli_test.py run_tier "T0 switch CI workflow content gate" "$LUA" tests/switch_ci_workflows_test.lua run_tier "T0 switch transfer docs gate" "$LUA" tests/switch_transfer_docs_test.lua # NX Blue/Yellow asset overlay: ROM-free, must run on every checkout so a diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 0b4570e0..de7f1280 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -152,14 +152,16 @@ local function allRequiredFilesExist(version) return ok end --- A developer checkout / Python build leaves Red's generated data in the --- physfs SOURCE at the un-prefixed root; that is always current. Only Red --- ships this way (Blue is import-only), so this stays a Red-root check. -local function sourceTreeHasData() - if not allRequiredFilesExist("red") or not love.filesystem.getRealDirectory then +-- A developer checkout / Python build leaves generated data in the physfs +-- source: Red at the historical root, Blue/Yellow in their versioned trees. +-- Source data is produced from the current manifest, so it needs no runtime +-- import marker; still verify the whole version-specific required-file set. +local function sourceTreeHasData(version) + if not allRequiredFilesExist(version) or not love.filesystem.getRealDirectory then return false end - local real = love.filesystem.getRealDirectory(REQUIRED_FILES[1]) + local path = GameVersion.cachePrefix(version) .. REQUIRED_FILES[1] + local real = love.filesystem.getRealDirectory(path) return real == love.filesystem.getSource() end @@ -236,10 +238,8 @@ function RomImporter.isReady(version) -- save-directory copy that would otherwise shadow it at runtime. purgeSaveDirCache() end - -- Red generated data in the physfs source (developer checkout / Python - -- build) is always current; Blue is import-only and falls through to the - -- version-marker gate. - if version == "red" and sourceTreeHasData() then return true end + -- Generated data in a developer checkout / Python build is always current. + if sourceTreeHasData(version) then return true end local saved = CacheFs.prefix CacheFs.prefix = GameVersion.cachePrefix(version) local marker = CacheFs.read(MARKER_PATH) diff --git a/tests/build_rom_data_cli_test.py b/tests/build_rom_data_cli_test.py new file mode 100644 index 00000000..e8800c2a --- /dev/null +++ b/tests/build_rom_data_cli_test.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""ROM-free regression tests for source-build version detection and routing.""" + +from contextlib import redirect_stderr, redirect_stdout +from io import StringIO +from pathlib import Path +from types import SimpleNamespace +from unittest import TestCase, main, mock + +import sys + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools")) +import build_rom_data # noqa: E402 + + +class BuildRomDataCliTest(TestCase): + def run_builder(self, sha1, *extra): + manifest = {"romSha1": sha1, "symbols": {}} + rom = SimpleNamespace(sha1=sha1) + with mock.patch.object(build_rom_data, "RomImage", return_value=rom), \ + mock.patch.object(build_rom_data, "load_manifest", return_value=manifest), \ + mock.patch.object(build_rom_data, "build") as build, \ + mock.patch.object(build_rom_data.os, "makedirs"), \ + redirect_stdout(StringIO()), redirect_stderr(StringIO()): + result = build_rom_data.main([ + "--rom", "fixture.gb", "--only", "constants", *extra]) + return result, build + + def test_blue_rom_selects_blue_manifest_and_cache_paths(self): + result, build = self.run_builder( + build_rom_data.CANONICAL_BLUE_SHA1) + + self.assertEqual(result, 0) + args = build.call_args.args + self.assertEqual(args[3], "blue/data/generated") + self.assertEqual(args[4], "blue/assets/generated") + + def test_red_rom_keeps_historical_root_paths(self): + result, build = self.run_builder(build_rom_data.CANONICAL_RED_SHA1) + + self.assertEqual(result, 0) + args = build.call_args.args + self.assertEqual(args[3], "data/generated") + self.assertEqual(args[4], "assets/generated") + + def test_explicit_output_paths_are_preserved(self): + result, build = self.run_builder( + build_rom_data.CANONICAL_YELLOW_SHA1, + "--out", "/tmp/custom-data", "--assets", "/tmp/custom-assets") + + self.assertEqual(result, 0) + args = build.call_args.args + self.assertEqual(args[3], "/tmp/custom-data") + self.assertEqual(args[4], "/tmp/custom-assets") + + def test_unknown_rom_is_rejected_before_build(self): + unknown = "0" * 40 + result, build = self.run_builder(unknown) + + self.assertEqual(result, 1) + build.assert_not_called() + + +if __name__ == "__main__": + main() diff --git a/tools/build_rom_data.py b/tools/build_rom_data.py index 8f0e02a4..c4020d30 100755 --- a/tools/build_rom_data.py +++ b/tools/build_rom_data.py @@ -92,6 +92,17 @@ def version_for_manifest(manifest, requested_version=None, manifest_explicit=Fal return detected or requested_version or "red" +def detect_rom_version(path): + """Read a canonical ROM once and return its supported game version.""" + rom = RomImage(path, None) + version = SHA1_TO_VERSION.get(rom.sha1) + if version is None: + expected = ", ".join(VERSION_SHA1[name] for name in VERSION_MANIFESTS) + raise ValueError( + f"unsupported ROM SHA-1 {rom.sha1}; expected one of {expected}") + return version, rom + + def extract_constants(manifest, out_dir): data = manifest["constants"] util.write_lua( @@ -2086,48 +2097,73 @@ def build(rom, symbols, manifest, out_dir, assets_dir, datasets): return results -def main(): +def main(argv=None): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "--rom", required=True, help="canonical US Pokemon Red, Blue, or Yellow ROM") parser.add_argument( - "--version", choices=sorted(VERSION_MANIFESTS), default="red", - help="select the shipped manifest for this version (default: red)") + "--version", choices=["auto", *sorted(VERSION_MANIFESTS)], default="auto", + help="select the shipped manifest for this version (default: detect from ROM)") parser.add_argument( "--manifest", default=None, help="explicit manifest path (overrides --version default path; " "RomImage hash still comes from the file's romSha1)") - parser.add_argument("--out", default="data/generated") - parser.add_argument("--assets", default="assets/generated") + parser.add_argument( + "--out", default=None, + help="generated data directory (default: version-specific cache path)") + parser.add_argument( + "--assets", default=None, + help="generated assets directory (default: version-specific cache path)") parser.add_argument("--clean", action="store_true") parser.add_argument( "--only", action="append", choices=DATASETS, help="build one dataset (repeatable); default builds all implemented") - args = parser.parse_args() + args = parser.parse_args(argv) try: manifest_explicit = args.manifest is not None - manifest_path = resolve_manifest_path(args.version, args.manifest) - manifest = load_manifest(manifest_path) - version = version_for_manifest( - manifest, args.version, manifest_explicit=manifest_explicit) - expected_sha1 = manifest.get("romSha1") or VERSION_SHA1[version] - rom = RomImage(args.rom, expected_sha1) + requested_version = None if args.version == "auto" else args.version + if manifest_explicit: + manifest_path = resolve_manifest_path( + requested_version or "red", args.manifest) + manifest = load_manifest(manifest_path) + version = version_for_manifest( + manifest, requested_version, manifest_explicit=True) + expected_sha1 = manifest.get("romSha1") or VERSION_SHA1[version] + rom = RomImage(args.rom, expected_sha1) + elif requested_version is None: + version, rom = detect_rom_version(args.rom) + manifest_path = resolve_manifest_path(version, None) + manifest = load_manifest(manifest_path) + expected_sha1 = manifest.get("romSha1") or VERSION_SHA1[version] + if rom.sha1 != expected_sha1: + raise ValueError( + f"unsupported ROM SHA-1 {rom.sha1}; expected {expected_sha1}") + else: + version = requested_version + manifest_path = resolve_manifest_path(version, None) + manifest = load_manifest(manifest_path) + version = version_for_manifest(manifest, version) + expected_sha1 = manifest.get("romSha1") or VERSION_SHA1[version] + rom = RomImage(args.rom, expected_sha1) symbols = SymbolTable(manifest["symbols"]) except (OSError, ValueError, KeyError, json.JSONDecodeError) as exc: print(f"error: {exc}", file=sys.stderr) return 1 + prefix = "" if version == "red" else version + os.sep + out_dir = args.out or prefix + os.path.join("data", "generated") + assets_dir = args.assets or prefix + os.path.join("assets", "generated") if args.clean: - for path in (args.out, args.assets): + for path in (out_dir, assets_dir): if os.path.isdir(path): shutil.rmtree(path) - os.makedirs(args.out, exist_ok=True) - os.makedirs(args.assets, exist_ok=True) + os.makedirs(out_dir, exist_ok=True) + os.makedirs(assets_dir, exist_ok=True) datasets = tuple(args.only) if args.only else DATASETS try: - build(rom, symbols, manifest, args.out, args.assets, datasets) + build(rom, symbols, manifest, out_dir, assets_dir, datasets) except (ValueError, KeyError, IndexError) as exc: print(f"error: {exc}", file=sys.stderr) return 1 From 9cc72f951b2035e7f4bfe51be5dd3a2086fc5890 Mon Sep 17 00:00:00 2001 From: kikimanjaro Date: Thu, 6 Aug 2026 18:51:27 +0200 Subject: [PATCH 02/63] Add nickname editing to the save editor's mon inspector The mon inspector now has a NICKNAME section: a text field with Set / Clear, commit-on-Enter and discard-on-Escape. Input is gated at the field instead of refused at commit. Only glyphs the game font can actually draw AND that round-trip through a .sav are accepted, capped at the naming screen's 10 glyphs, so a bad keystroke or paste never appears and Set always succeeds. Chars like "@" (the Gen1 string terminator), "#" and the dakuten kana have codec entries but no font tile so they are blocked rather than silently mangled. --- tests/run_save_editor_tests.lua | 177 +++++++++++++++++++++++++ tools/save-editor/App.lua | 18 +++ tools/save-editor/Kit.lua | 10 +- tools/save-editor/Ops.lua | 153 +++++++++++++++++++++ tools/save-editor/State.lua | 2 + tools/save-editor/panels/MonEditor.lua | 43 +++++- 6 files changed, 401 insertions(+), 2 deletions(-) diff --git a/tests/run_save_editor_tests.lua b/tests/run_save_editor_tests.lua index 000d2a31..986e3dfc 100644 --- a/tests/run_save_editor_tests.lua +++ b/tests/run_save_editor_tests.lua @@ -291,6 +291,106 @@ do eq(mon.level, 1, "stepSpecies keeps the level") end +do + -- Nicknames: the editor edits mon.nickname, which is nil when un-nicknamed + -- (every display site reads `mon.nickname or def.name`, GenSave.lua). The + -- game's naming screen caps at 10 glyphs and treats an empty confirm as "no + -- nickname", so the verbs below mirror that: "" clears, a name matching the + -- species' standard name normalizes back to nil, too-long or unrenderable + -- names refuse with a status line, and nothing silently no-ops. + local S = State.new() + S.data = Data + S.cat = Catalog.build(Data) + S.save = SaveData.newGame() + local mon = MonOps.create(Data, "CHARIZARD", 50) + S.save.party = { mon } + S.editingMon = mon + + eq(Ops.nicknameLength("POKEMON"), 7, "nicknameLength counts ASCII glyphs") + eq(Ops.nicknameLength("ééé"), 3, "nicknameLength counts a multi-byte char as one glyph") + eq(Ops.nicknameLength("♂♀!"), 3, "nicknameLength counts symbol glyphs") + check(Ops.nicknameUsable(S, "CHARIZARD"), "ASCII letters are renderable") + check(Ops.nicknameUsable(S, "Nidoking"), "lower case is renderable") + check(Ops.nicknameUsable(S, "é") == true, "a charmap glyph is renderable") + check(Ops.nicknameUsable(S, "PIKA€") == false, "a non-charmap glyph is not renderable") + check(Ops.nicknameUsable(S, "🤖") == false, "an emoji is not renderable") + -- "@" is the Gen1 string terminator: the codec has an entry for it but the + -- game font has no tile, so Font.encode draws it as a space in-game + check(Ops.nicknameUsable(S, "POKE@MON") == false, + "the terminator @ is not a renderable nickname glyph") + check(Ops.nicknameUsable(S, "POKE#MON") == false, + "the # marker is not a renderable nickname glyph") + + -- the field gate: sanitize skips unrenderable glyphs and clamps at 10, so + -- what reaches the mon can only ever be a legal Gen 1 nickname + eq(Ops.nicknameSanitize(S, "PIKA\226\130\172"), "PIKA", + "sanitize drops an unrenderable glyph") + eq(Ops.nicknameSanitize(S, "PIKA\226\130\172CHU"), "PIKACHU", + "sanitize skips a bad glyph mid-name instead of aborting the rest") + eq(Ops.nicknameSanitize(S, "POKE@MON"), "POKEMON", + "sanitize strips the invisible @ terminator") + eq(Ops.nicknameSanitize(S, "1234567890123"), "1234567890", + "sanitize clamps the draft at 10 glyphs") + eq(Ops.nicknameSanitize(S, "\195\169"), "\195\169", + "sanitize keeps a charmap glyph") + eq(Ops.nicknameSanitize(S, ""), "", "sanitize of empty is empty") + + Ops.setNickname(S, mon, "SPARKY") + eq(mon.nickname, "SPARKY", "setNickname stores the name") + check(S.dirty == true, "setNickname marks the save dirty") + eq(S.status:match("SPARKY") ~= nil, true, "setNickname narrates the new name") + S.dirty = false + + check(Ops.setNickname(S, mon, "SPARKY") == false, + "setting the same nickname again is a no-op") + check(S.dirty == false, "the no-op did not dirty the save") + check(S.status:match("Already nicknamed") ~= nil, "the no-op explains itself") + + -- a name matching the species' standard name is the un-nicknamed state + Ops.setNickname(S, mon, "CHARIZARD") + eq(mon.nickname, nil, "a name equal to the standard name normalizes to nil") + eq(S.status:match("standard name") ~= nil, true, "the normalization explains itself") + + check(Ops.clearNickname(S, mon) == false, + "clearing an already-un-nicknamed mon is a no-op") + check(S.status:match("no nickname") ~= nil, "the no-op explains itself") + + -- empty input means clear, like an empty naming-screen confirm + Ops.setNickname(S, mon, "SPARKY") + eq(mon.nickname, "SPARKY", "re-nicknamed for the empty-clear check") + check(Ops.setNickname(S, mon, "") == true, "an empty name is a valid clear") + eq(mon.nickname, nil, "an empty name clears the nickname") + check(S.status:match("Cleared") ~= nil, "the clear narrates") + + Ops.setNickname(S, mon, "1234567890") + eq(mon.nickname, "1234567890", "a 10-glyph name is accepted") + S.dirty = false + check(Ops.setNickname(S, mon, "12345678901") == false, + "an 11-glyph name is refused") + eq(mon.nickname, "1234567890", "a refused name leaves the mon alone") + check(S.dirty == false, "a refused name does not dirty the save") + check(S.status:match("capped at 10") ~= nil, "the length refusal explains itself") + + check(Ops.setNickname(S, mon, "PIKA€") == false, + "a name with an unrenderable glyph is refused") + eq(mon.nickname, "1234567890", "a refused glyph leaves the mon alone") + check(S.status:match("cannot render") ~= nil, "the glyph refusal explains itself") + check(Ops.setNickname(S, mon, "POKE@MON") == false, + "a name with the invisible @ terminator is refused") + eq(mon.nickname, "1234567890", "a refused @ name leaves the mon alone") + check(S.status:match("cannot render") ~= nil, "the @ refusal explains itself") + + check(Ops.setNickname(S, nil, "X") == false, "setNickname without a mon refuses") + check(S.status:match("Pick a slot") ~= nil, "and explains itself") + check(Ops.clearNickname(S, nil) == false, "clearNickname without a mon refuses") + + -- the canonical round trip: what the game reads back is the same either way + mon.nickname = "SPARKY" + local encoded = SaveData.encode(S.save) + local back = SaveData.decode(encoded) + eq(back.party[1].nickname, "SPARKY", "a nickname survives a save round trip") +end + -- App.load corrupt-save vs missing-save (Important fix #2): App.load takes -- an optional path override precisely so tests can drive this without -- touching the real default save file. @@ -799,6 +899,83 @@ do for _, bak in ipairs(FsIo.globPrefix(tmpPath .. ".bak-")) do os.remove(bak) end end +do + -- The inspector's nickname field is commit-on-Enter: the draft lives in + -- S.nicknameDraft while typing, Enter commits it through Ops.setNickname, + -- and Escape discards it. Drive it through App the way a player would: + -- focus the field (a click is just a Kit.focus assignment here), type, + -- drain the edits with a draw, then press Enter / Escape. + local Kit = require("Kit") + local tmpPath = os.tmpname() .. "-nickname-save.lua" + local data = SaveData.newGame() + data.party = { MonOps.create(Data, "CHARIZARD", 50) } + local f = io.open(tmpPath, "wb") + f:write(SaveData.encode(data)) + f:close() + + App.load(tmpPath, { version = "red" }) + local S = App.getState() + S.tab = "party" + Ops.selectParty(S, 1) + local mon = S.editingMon + eq(mon.nickname, nil, "the save starts un-nicknamed") + + -- type "SPARKY" and commit with Enter + Kit.focus = "mon-nickname" + App.textinput("SPARKY") + App.draw() + eq(S.nicknameDraft, "SPARKY", "typed text lands in the draft") + App.keypressed("return") + eq(mon.nickname, "SPARKY", "Enter commits the draft to the mon") + check(S.dirty == true, "the commit marks the save dirty") + check(Kit.focus == nil, "Enter blurs the field") + S.dirty = false + + -- The field has no select-all, so a rename is backspace-then-type (the + -- caret parks at the end, exactly like the editor's other fields). + local function clearField(n) + Kit.focus = "mon-nickname" + for _ = 1, n do App.keypressed("backspace") end + App.draw() + end + + -- type junk, then Escape: nothing is committed and the draft is discarded + clearField(#mon.nickname) + App.textinput("ZEPTO") + App.draw() + eq(S.nicknameDraft, "ZEPTO", "the draft holds the new typing") + App.keypressed("escape") + eq(mon.nickname, "SPARKY", "Escape does not commit") + eq(S.nicknameDraft, "SPARKY", "Escape resets the draft to the committed name") + check(Kit.focus == nil, "Escape blurs the field") + + -- an unrenderable glyph is blocked AT INPUT: the euro sign never reaches + -- the draft, so the field can only ever hold what the game can render + clearField(#mon.nickname) + App.textinput("PIKA\226\130\172") -- PIKA + euro sign, not a charmap glyph + App.draw() + eq(S.nicknameDraft, "PIKA", "an unrenderable glyph is dropped at input") + App.keypressed("return") + eq(mon.nickname, "PIKA", "the clean draft commits on Enter") + + -- and the 10-glyph cap blocks extra input the same way + clearField(#mon.nickname) + App.textinput("123456789012345") + App.draw() + eq(S.nicknameDraft, "1234567890", "typing past 10 glyphs clamps at 10") + + -- the @ terminator never reaches the draft either: it draws as a space + -- in-game, so the field strips it like any other unrenderable glyph. The + -- clamp test above left an uncommitted draft, so clear the whole draft. + clearField(#S.nicknameDraft) + App.textinput("POKE@MON") + App.draw() + eq(S.nicknameDraft, "POKEMON", "the @ terminator is stripped at input") + + os.remove(tmpPath) + for _, bak in ipairs(FsIo.globPrefix(tmpPath .. ".bak-")) do os.remove(bak) end +end + do -- #541 modal shield. Kit hit-tests without a z-order, so the picker cannot -- simply be drawn last: the chrome and the panel underneath would take the diff --git a/tools/save-editor/App.lua b/tools/save-editor/App.lua index 5d421f8e..7b5d3c21 100644 --- a/tools/save-editor/App.lua +++ b/tools/save-editor/App.lua @@ -842,6 +842,24 @@ function App.keypressed(key) return end end + -- The inspector's nickname field is a commit-on-Enter field, unlike the + -- search fields, which are live view state. Enter commits the draft through + -- Ops and blurs; Escape discards it and blurs. Both must run before + -- Kit.keypressed, which maps return/escape to the same "\r" edit and cannot + -- tell "commit" from "cancel". + if Kit.focus == "mon-nickname" then + if key == "return" or key == "kpenter" then + if S.editingMon and Ops.setNickname(S, S.editingMon, S.nicknameDraft) then + S.nicknameDraft = S.editingMon.nickname or "" + end + Kit.blur() + return + elseif key == "escape" then + Kit.blur() + if S.editingMon then S.nicknameDraft = S.editingMon.nickname or "" end + return + end + end -- A focused text field eats the keys it cares about (typing "s" into the -- map filter must not trigger Save). if Kit.keypressed(key) then return end diff --git a/tools/save-editor/Kit.lua b/tools/save-editor/Kit.lua index 4c40d920..2197d7a4 100644 --- a/tools/save-editor/Kit.lua +++ b/tools/save-editor/Kit.lua @@ -426,7 +426,12 @@ end -- state because Kit had no input widget; this replaces that hack, and App -- routes love.textinput / love.keypressed in through Kit.textinput / -- Kit.keypressed. Returns the (possibly edited) value; the caller stores it. -function Kit.textfield(id, x, y, w, h, value, placeholder) +-- +-- `opts.sanitize(value)` (optional) is a post-filter run on the merged text +-- right after this frame's edits and BEFORE it draws, so a keystroke or paste +-- the filter rejects never even flashes on screen. It gets the whole value +-- because a paste arrives as one textinput chunk alongside existing text. +function Kit.textfield(id, x, y, w, h, value, placeholder, opts) audit("control", x, y, w, h, id) value = tostring(value or "") if Kit.press(x, y, w, h) then Kit.focus = id end @@ -444,6 +449,9 @@ function Kit.textfield(id, x, y, w, h, value, placeholder) value = value .. e end end + if opts and opts.sanitize then + value = opts.sanitize(value) + end end if G then local r = 8 * Kit.scale diff --git a/tools/save-editor/Ops.lua b/tools/save-editor/Ops.lua index 853c2d6b..5da44907 100644 --- a/tools/save-editor/Ops.lua +++ b/tools/save-editor/Ops.lua @@ -16,12 +16,17 @@ local PartyMod = require("src.pokemon.Party") local BoxesMod = require("src.pokemon.Boxes") local Bag = require("src.inventory.Bag") local MonOps = require("MonOps") +local Charmap = require("src.save_convert.data.charmap") local Ops = {} Ops.MONEY_MAX = 999999 Ops.STACK_MAX = 99 Ops.ARM_SECONDS = 2.5 +-- The in-game naming screen caps a nickname at 10 glyphs +-- (BattleState:askNicknameUI / src/ui/NamingScreen.lua maxLen = 10); the +-- editor mirrors that cap instead of inventing its own. +Ops.NICKNAME_MAX = 10 local function clamp(n, lo, hi) if n < lo then return lo end @@ -400,6 +405,154 @@ function Ops.healMon(S, mon) return Ops.mark(S, ("Healed %s to %d/%d HP"):format(mon.species, mon.hp, mon.stats.hp)) end +-- ----------------------------------------------------------------- nicknames +-- Gen1 has no "is nicknamed" bit: an un-nicknamed mon is mon.nickname == nil, +-- and every display site reads `mon.nickname or def.name` +-- (src/save_convert/GenSave.lua). The editor edits that field directly. + +-- The byte length of the UTF-8 glyph starting at lead byte `b`. Self-contained +-- so this (and eachGlyph) also runs headless under luajit, which has no `utf8` +-- standard library. +local function glyphByteLen(b) + if b < 0x80 then return 1 end + if b < 0xE0 then return 2 end + if b < 0xF0 then return 3 end + return 4 +end + +-- Walk `name` one UTF-8 glyph at a time; fn(glyph) returning false stops the +-- walk early and eachGlyph returns false. Returns true when every glyph was +-- visited. The single place that walks a name, so the count / validate / +-- sanitize paths cannot drift apart (a glyph is "é" or "♂", not one of its +-- bytes, exactly as the naming screen counts its grid cells). +local function eachGlyph(name, fn) + local i, n = 1, #name + while i <= n do + local b = name:byte(i) + local ch = name:sub(i, i + glyphByteLen(b) - 1) + if fn(ch) == false then return false end + i = i + #ch + end + return true +end + +-- Glyph count, not byte count: "é" or "♂" is ONE game character, exactly as +-- the naming screen counts its grid cells and GenSave.encodeName counts a +-- charmap sequence. +function Ops.nicknameLength(name) + local n = 0 + eachGlyph(tostring(name or ""), function() n = n + 1 end) + return n +end + +-- The set of glyphs a nickname may hold: present in BOTH the Gen1 text codec +-- charmap (so the name round-trips through a .sav) and the game's font +-- charmap (so it actually draws). The codec alone is not enough: "@" is the +-- string-terminator byte, and "#" plus the dakuten kana have codec entries +-- but no font tile, so Font.encode (src/render/Font.lua) draws them as a +-- space -- an invisible nickname. Only single-codepoint entries qualify: +-- multi-character macros ("", the 'd ligature) cannot be typed one +-- character at a time, so they have no place in the input gate. +-- Built once per loaded font table (a mod replacing the font rebuilds it); +-- falls back to the codec-only set when no font data is loaded (headless +-- suites that never call Data:load). +local glyphCache, glyphCacheFont +local function nameGlyphSet(S) + local font = S and S.data and S.data.font + if not (font and font.charmap) then return Charmap.byToken end + if glyphCache and glyphCacheFont == font then return glyphCache end + local set = {} + for _, e in ipairs(font.charmap) do + local s = e.seq + if type(s) == "string" and s ~= "" and Charmap.byToken[s] + and #s == glyphByteLen(s:byte(1)) then + set[s] = true + end + end + glyphCache, glyphCacheFont = set, font + return set +end + +-- True when every glyph is a legal nickname glyph (see nameGlyphSet): the +-- name can be stored in a .sav AND draws in the game. Anything else either +-- encodes as "?" (GenSave.encodeName) or renders as a space (Font.encode), +-- which the user did not ask for, so it is refused rather than mangled. +function Ops.nicknameUsable(S, name) + local set = nameGlyphSet(S) + return eachGlyph(tostring(name or ""), function(ch) + return set[ch] ~= nil + end) +end + +-- The species' display name, what an un-nicknamed mon reads as. +local function speciesName(S, species) + local def = species and S.data.pokemon[species] + return (def and def.name) or tostring(species or "") +end + +-- The input gate for the inspector's nickname field. Given the whole draft +-- (existing text plus this frame's keystrokes and any paste), return the +-- version the game can actually hold: every glyph kept draws in the game +-- (see nameGlyphSet) and the result never exceeds the naming screen's +-- 10-glyph cap. Unrenderable glyphs are skipped, not used to abort the rest +-- of the string, so a paste of "PIKA€CHU" lands as "PIKACHU". The field runs +-- this through Kit.textfield's opts.sanitize, so a blocked character never +-- appears at all. +function Ops.nicknameSanitize(S, name) + local set = nameGlyphSet(S) + local out, count = {}, 0 + eachGlyph(tostring(name or ""), function(ch) + if count < Ops.NICKNAME_MAX and set[ch] then + out[#out + 1] = ch + count = count + 1 + end + end) + return table.concat(out) +end + +-- One verb for both writing and clearing. An empty field means "no nickname", +-- exactly like an empty confirm on the in-game naming screen (which falls +-- through to the species' standard name). A name that equals the species' +-- standard name is the un-nicknamed state in this save format +-- (importedNickname in GenSave.lua maps exactly that to nil), so it is +-- normalized to nil rather than stored as a literal copy of the default. +function Ops.setNickname(S, mon, name) + if not mon then return Ops.say(S, "Pick a slot first") end + name = tostring(name or "") + if name == "" then + return Ops.clearNickname(S, mon) + end + if name == mon.nickname then + return Ops.say(S, ("Already nicknamed %s"):format(name)) + end + if name == speciesName(S, mon.species) then + if mon.nickname == nil then + return Ops.say(S, ("%s is already un-nicknamed"):format(mon.species)) + end + mon.nickname = nil + return Ops.mark(S, ("%s matches its standard name; nickname cleared") + :format(name)) + end + if Ops.nicknameLength(name) > Ops.NICKNAME_MAX then + return Ops.say(S, ("Nicknames are capped at %d characters"):format(Ops.NICKNAME_MAX)) + end + if not Ops.nicknameUsable(S, name) then + return Ops.say(S, + "That name has characters the game cannot render or export cleanly") + end + mon.nickname = name + return Ops.mark(S, ("Nicknamed %s \"%s\""):format(mon.species, name)) +end + +function Ops.clearNickname(S, mon) + if not mon then return Ops.say(S, "Pick a slot first") end + if mon.nickname == nil then + return Ops.say(S, ("%s has no nickname to clear"):format(mon.species)) + end + mon.nickname = nil + return Ops.mark(S, ("Cleared %s's nickname"):format(mon.species)) +end + -- ------------------------------------------------------------------ boxes function Ops.boxes(S) return BoxesMod.ensure(S.save) diff --git a/tools/save-editor/State.lua b/tools/save-editor/State.lua index cdbf84ea..258e32d2 100644 --- a/tools/save-editor/State.lua +++ b/tools/save-editor/State.lua @@ -43,6 +43,8 @@ function State.new() partyOffset = 0, -- roster scroll position (#715) inspectorScroll = 0, -- MonEditor body pixel scroll (#715) editingMon = nil, -- reference into party or a box + nicknameDraft = nil, -- text being typed in the inspector's nickname field + nicknameMon = nil, -- the mon the draft belongs to (nil for none) -- species picker overlay: nil when closed, otherwise { query, offset } -- plus mode = "box-add" when it is adding to a box instead of changing a -- species (Ops.openBoxAddPicker). Modal in the literal sense -- App diff --git a/tools/save-editor/panels/MonEditor.lua b/tools/save-editor/panels/MonEditor.lua index 0a2230ee..8ccdc13d 100644 --- a/tools/save-editor/panels/MonEditor.lua +++ b/tools/save-editor/panels/MonEditor.lua @@ -218,7 +218,11 @@ function MonEditor.draw(S, Kit, x, y, w, h) else colsH = capH + 10 * s + colRowsH + 12 * s + actH end + -- the nickname section: a caption line (with the Clear button on it) plus + -- the field + Set row + local nickFieldH = 30 * s local contentH = pad + headerH + 18 * s + + capH + 10 * s + nickFieldH + 18 * s + capH + 10 * s + cellH + 18 * s + colsH + pad @@ -266,8 +270,45 @@ function MonEditor.draw(S, Kit, x, y, w, h) drawLevelRow(S, Kit, mon, cx, cy + math.max(sprite, titleH) + 12 * s) end + -- ---------------------------------------------------------- nickname + -- Editing the field is a draft (S.nicknameDraft) held on the mon it belongs + -- to; Set / Enter commit it through Ops.setNickname, which clears on an + -- empty value, and Clear goes through Ops.clearNickname. The draft resets + -- when the selection moves so one mon's typing can never leak onto another. + local nickY = cy + headerH + 18 * s + Kit.caption(cx, nickY, "NICKNAME") + local clearW = 74 * s + local clearH = 24 * s + if Kit.button(cx + inner - clearW, nickY + (capH - clearH) / 2, clearW, clearH, + "Clear", { kind = "danger", font = "micro", radius = 6 * s }) then + Ops.clearNickname(S, mon) + S.nicknameDraft = "" + end + local fieldY = nickY + capH + 10 * s + local setW = 64 * s + local fieldW = inner - setW - 10 * s + if S.nicknameMon ~= mon then + local switching = S.nicknameMon ~= nil + S.nicknameMon = mon + S.nicknameDraft = mon.nickname or "" + -- a still-focused field would keep appending keystrokes to the newly + -- selected mon; the selection move counts as leaving the field. The + -- first sync (nicknameMon starts nil) never blurs: the species picker + -- owns focus when it opens, and blurring there drops the player's typing. + if switching and Kit.focus == "mon-nickname" then Kit.blur() end + end + S.nicknameDraft = Kit.textfield("mon-nickname", cx, fieldY, fieldW, nickFieldH, + S.nicknameDraft or "", "no nickname", + { sanitize = function(value) return Ops.nicknameSanitize(S, value) end }) + if Kit.button(cx + fieldW + 10 * s, fieldY, setW, nickFieldH, "Set", + { kind = "accent", font = "small", radius = 8 * s }) then + if Ops.setNickname(S, mon, S.nicknameDraft) then + S.nicknameDraft = mon.nickname or "" + end + end + -- ------------------------------------------------------- derived stats - local statsY = cy + headerH + 18 * s + local statsY = nickY + capH + 10 * s + nickFieldH + 18 * s Kit.caption(cx, statsY, "STATS . recalculated from level + DVs") statsY = statsY + capH + 10 * s local gap = 12 * s From 00f6e3a7b4ee00e22a60126d2050305bc231e50a Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Thu, 6 Aug 2026 14:36:01 -0400 Subject: [PATCH 03/63] CLOSES #889 --- src/import/RomExtractor.lua | 24 ++ src/import/RomImporter.lua | 6 +- src/save_convert/GenSave.lua | 39 ++++ src/save_convert/MapContext.lua | 263 ++++++++++++++++++++++ src/save_convert/SaveConvert.lua | 11 +- tests/drivers/export_sav_bug889_test.lua | 51 +++++ tests/engine/save_import_retry_bug420.lua | 9 +- tests/engine/save_map_context_bug889.lua | 201 +++++++++++++++++ 8 files changed, 600 insertions(+), 4 deletions(-) create mode 100644 src/save_convert/MapContext.lua create mode 100644 tests/drivers/export_sav_bug889_test.lua create mode 100644 tests/engine/save_map_context_bug889.lua diff --git a/src/import/RomExtractor.lua b/src/import/RomExtractor.lua index 88cad0fa..d9b25c77 100644 --- a/src/import/RomExtractor.lua +++ b/src/import/RomExtractor.lua @@ -198,6 +198,12 @@ function RomExtractor:extractTilesets() out[constName] = { id = constName, source = ("ROM:Tilesets[%d]"):format(index - 1), + -- The raw Tilesets row, verbatim. A .sav export has to reproduce what + -- LoadTilesetHeader (engine/overworld/tilesets.asm) would have left in + -- wTilesetBank..wGrassTile, because a Continue never re-runs it -- see + -- src/save_convert/MapContext.lua (#889). Byte 12 is the tile + -- animation id, which rides in sTileAnimations. + header = self.rom:bytes(headers.bank, rowAddress, 12), image = "assets/generated/tilesets/" .. base .. ".png", imageWidth = spec.imageWidth, imageHeight = spec.imageHeight, @@ -268,6 +274,11 @@ function RomExtractor:extractMaps() assert(tilesetId < #tilesets, constName .. ": unknown tileset id") local blockPointer = self.rom:word(header.bank, address + 3) local connectionFlags = self.rom:byte(header.bank, address + 9) + -- wCurMapHeader verbatim (tileset, height, width, data/text/script + -- pointers, connection flags). A save restores this window instead of + -- rebuilding it, so an export has to carry the real bytes (#889). + local headerBytes = self.rom:bytes(header.bank, address, 10) + local connectionStart = address + 10 address = address + 10 local connections = {} @@ -289,6 +300,8 @@ function RomExtractor:extractMaps() end assert(bit.band(connectionFlags, 0xF0) == 0, constName .. ": unknown connection flags") + local connectionBytes = self.rom:bytes( + header.bank, connectionStart, address - connectionStart) local objectPointer = self.rom:word(header.bank, address) local objectAddress = objectPointer local borderBlock = self.rom:byte(header.bank, objectAddress) @@ -376,6 +389,17 @@ function RomExtractor:extractMaps() width = width, height = height, blocks = blocks, borderBlock = borderBlock, connections = connections, warps = warps, signs = signs, objects = objects, + -- Raw ROM bytes a .sav export replays through LoadMapHeader's WRAM + -- writes (src/save_convert/MapContext.lua, #889). Kept as the original + -- bytes rather than re-encoded from the decoded tables above: the + -- pointers in them (wCurMapDataPtr, the connection strip src/dest + -- addresses, sign text ids) have no equivalent in the port's own model. + sram = { + header = headerBytes, + connections = connectionBytes, + objects = self.rom:bytes( + header.bank, objectPointer, objectAddress - objectPointer), + }, } self:tick("Maps", mapIndex, #keys) end diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 3544c7f4..71fa08aa 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -30,7 +30,11 @@ end -- Cache generation tag; bump to force every imported version to re-extract. -- v9: Yellow audio re-anchored on pokeyellow.sym (#522) -- stale caches -- carry Red's bank $1f header, wave-table, and CryData offsets. -local CACHE_FORMAT = "rom-cache-v9:" +-- v10: maps carry their raw map-header/connection/object bytes and tilesets +-- their Tilesets row (#889), which a .sav export replays so a Continue on +-- real hardware has a map to load; a v9 cache has none of them and exports +-- the same unbootable save as before. +local CACHE_FORMAT = "rom-cache-v10:" -- The completion marker is written under each version's cache prefix -- (rom-cache.complete for Red, blue/rom-cache.complete for Blue). local MARKER_PATH = "rom-cache.complete" diff --git a/src/save_convert/GenSave.lua b/src/save_convert/GenSave.lua index 6a465acf..0f754359 100644 --- a/src/save_convert/GenSave.lua +++ b/src/save_convert/GenSave.lua @@ -27,6 +27,7 @@ -- game regenerates all of it from wCurMap on the next map load anyway. local bit = require("bit") +local MapContext = require("src.save_convert.MapContext") local GenSave = {} @@ -1023,6 +1024,44 @@ function GenSave.encode(save, data, template) setByte(buf, O.lastMap, cw.mapsIndex[save.lastOutdoor.id] or 0) end + -- Current-map engine state (see src/save_convert/MapContext.lua). A + -- Continue restores this window from the save and never rebuilds it, so a + -- zero-filled one boots into a garbled map on a silent hang (#889). + -- + -- Rebuilt when there is no template at all (a save that began as a New Game + -- in this port), and when the template was saved on a DIFFERENT map than the + -- one the player is standing on now -- an imported save that has since been + -- played carries the old map's header, which is just as unbootable. A + -- template still on its own map keeps its bytes untouched: they are the + -- game's own, including live NPC positions, and preserving them is what + -- makes import -> export byte-identical. + local mapId = save.player and save.player.map + if mapId then + local rebuild = true + if src then + -- compare the way the byte was written (masked), and rebuild when the + -- map has no index at all rather than trusting a stale template + local index = cw.mapsIndex[mapId] + rebuild = index == nil or u8(src, O.curMap) ~= bit.band(index, 0xFF) + end + if rebuild then + local ctx = MapContext.build(data, mapId, + (save.player and save.player.x) or 0, (save.player and save.player.y) or 0) + if ctx then + for offset, values in pairs(ctx.writes) do + for i, value in ipairs(values) do + setByte(buf, O.mainData + offset + i - 1, value) + end + end + for i, value in ipairs(ctx.spriteData) do + setByte(buf, O.spriteData + i - 1, value) + end + -- sTileAnimations, the byte between sCurBoxData and the checksum + setByte(buf, O.checksumEnd - 1, ctx.tileAnimations) + end + end + end + -- play time: split save.playTime (seconds) back into H/M/S/F. The real -- game freezes the clock at 255h and sets wPlayTimeMaxed once past it, so -- mirror that cap rather than letting hours overflow a single byte. diff --git a/src/save_convert/MapContext.lua b/src/save_convert/MapContext.lua new file mode 100644 index 00000000..0443a992 --- /dev/null +++ b/src/save_convert/MapContext.lua @@ -0,0 +1,263 @@ +-- MapContext -- the current map's engine state, as a vanilla Gen1 save has to +-- carry it (#889). +-- +-- Continuing a real save never re-loads the map header. LoadMainData +-- (engine/menus/save.asm) copies sMainData back into WRAM and then sets +-- BIT_NO_PREVIOUS_MAP in wCurMapTileset; LoadMapHeader (home/overworld.asm) +-- clears that bit and RETURNS IMMEDIATELY when it was set, so every byte it +-- would otherwise have written -- the map header, connections, warps, signs, +-- sprites, the tileset header, the map's music -- comes straight out of the +-- save file. A cartridge save always has them because the game saved them. +-- +-- An export from this port did not: GenSave models the player's progress, not +-- the engine scratch around it, and left that whole window zero-filled unless +-- it had an imported SRAM image to copy from. The game then continued into +-- tileset 0 with a $0000 map-data pointer and sound id 0, which is the +-- garbled overworld / silent hang users reported after exporting a save that +-- began as a New Game in the port. +-- +-- This module rebuilds the window from the extracted ROM data, replaying the +-- same writes LoadMapHeader makes, so a port-origin save boots on hardware. +-- The offsets are all relative to wMainDataStart (= sMainData in a .sav) and +-- were summed from ram/wram.asm, then checked byte-for-byte against a real +-- cartridge save: rebuilding its current map reproduces the bytes it already +-- held, everywhere except the stale tails of disabled connection slots (which +-- the engine never reads once the connected-map byte is $FF). +-- +-- Pure Lua, no love.*: shared by the runtime exporter, the CLI and the tests. + +local MapContext = {} + +-- wMainDataStart-relative offsets. Anchors: wCurMap is +103 (the offset +-- GenSave already decodes the player's map from), and the run from there is +-- wCurMap, wCurrentTileBlockMapViewPointer(2), wYCoord, wXCoord, wYBlockCoord, +-- wXBlockCoord, wLastMap, wUnusedLastMapWidth, wCurMapHeader -- so the header +-- lands at +112, and every later label follows from the declaration sizes in +-- ram/wram.asm (MAX_WARP_EVENTS 32, MAX_BG_EVENTS 16, MAX_OBJECT_EVENTS 16, +-- SPRITE_SET_LENGTH 11). +local O = { + mapMusicSoundID = 100, -- wMapMusicSoundID + mapMusicROMBank = 101, -- wMapMusicROMBank + viewPointer = 104, -- wCurrentTileBlockMapViewPointer (2) + yCoord = 106, + xCoord = 107, + yBlockCoord = 108, + xBlockCoord = 109, + curMapHeader = 112, -- wCurMapHeader (10) + connectionHeaders = 122, -- wNorth/South/West/EastConnectionHeader (4 x 11) + mapBackgroundTile = 182, + numberOfWarps = 183, + warpEntries = 184, -- MAX_WARP_EVENTS x 4 + numSigns = 441, + signCoords = 442, -- MAX_BG_EVENTS x 2 + signTextIDs = 474, -- MAX_BG_EVENTS + numSprites = 490, + mapSpriteData = 493, -- MAX_OBJECT_EVENTS x 2 + mapSpriteExtra = 525, -- MAX_OBJECT_EVENTS x 2 + currentMapHeight2 = 557, + currentMapWidth2 = 558, + tilesetHeader = 564, -- wTilesetBank .. wGrassTile (11) +} +MapContext.OFFSETS = O + +local MAX_WARP_EVENTS = 32 +local MAX_BG_EVENTS = 16 +local MAX_OBJECT_EVENTS = 16 +local CONNECTION_STRUCT = 11 -- map_connection_struct (macros/ram.asm) +local SPRITE_STRUCT = 16 -- one wSpriteStateData1/2 entry +local NUM_SPRITE_STRUCTS = 16 + +-- wOverworldMap, the tile-block map the view pointer indexes. Recovered from +-- the event_displacement formula below applied to a real save's coordinates, +-- and it is the same $C6E8 every Gen1 reference quotes. +local OVERWORLD_MAP = 0xC6E8 + +-- Audio header tables start at $4000 in each of the three audio banks, and +-- constants/music_constants.asm derives every song id arithmetically from +-- that base ("Song ids are calculated by address", music_const: +-- (Music_X - SFX_Headers_1) / 3). So the extracted header address IS the id, +-- and no separate MapSongBanks extraction is needed. +local SFX_HEADERS_BASE = 0x4000 + +local function soundId(address) + if type(address) ~= "number" then return nil end + local delta = address - SFX_HEADERS_BASE + if delta < 0 or delta % 3 ~= 0 then return nil end + local id = delta / 3 + if id > 0xFF then return nil end + return id +end + +-- LoadMapHeader parses the map's object data (data/maps/objects/*.asm) into +-- five separate WRAM arrays. Walk it exactly as the engine does; `bytes` is +-- the 1-based array of raw object-data bytes the extractor captured. +local function parseObjects(bytes) + local pos = 1 + local function take() + local b = bytes[pos] + pos = pos + 1 + return b + end + local out = { warps = {}, signCoords = {}, signTexts = {}, + spriteData = {}, spriteExtra = {}, sprites = {} } + + out.backgroundTile = take() + + local warpCount = take() + if warpCount == nil then return nil end + out.warpCount = warpCount + for _ = 1, math.min(warpCount, MAX_WARP_EVENTS) * 4 do + out.warps[#out.warps + 1] = take() + end + + local signCount = take() + if signCount == nil then return nil end + out.signCount = signCount + for _ = 1, math.min(signCount, MAX_BG_EVENTS) do + out.signCoords[#out.signCoords + 1] = take() -- Y + out.signCoords[#out.signCoords + 1] = take() -- X + out.signTexts[#out.signTexts + 1] = take() + end + + local spriteCount = take() + if spriteCount == nil then return nil end + out.spriteCount = spriteCount + for _ = 1, math.min(spriteCount, MAX_OBJECT_EVENTS) do + local picture, mapY, mapX = take(), take(), take() + local movement1, movement2, textId = take(), take(), take() + if textId == nil then return nil end + out.sprites[#out.sprites + 1] = { + picture = picture, mapY = mapY, mapX = mapX, movement1 = movement1, + } + -- wMapSpriteData: movement byte 2, then the text id with its trainer/item + -- flag bits masked off (LoadMapHeader's `and $3f`). + out.spriteData[#out.spriteData + 1] = movement2 + out.spriteData[#out.spriteData + 1] = textId % 0x40 + -- BIT_TRAINER ($40) is tested before BIT_ITEM ($80), as LoadMapHeader does + if textId % 0x80 >= 0x40 then -- trainer: class, then party id + out.spriteExtra[#out.spriteExtra + 1] = take() + out.spriteExtra[#out.spriteExtra + 1] = take() + elseif textId >= 0x80 then -- item ball: item id, second byte unused + out.spriteExtra[#out.spriteExtra + 1] = take() + out.spriteExtra[#out.spriteExtra + 1] = 0 + else + out.spriteExtra[#out.spriteExtra + 1] = 0 + out.spriteExtra[#out.spriteExtra + 1] = 0 + end + end + return out +end + +-- build(data, mapId, x, y) -> ctx, err +-- +-- ctx.writes [wMainDataStart-relative offset] = array of bytes +-- ctx.spriteData 512 bytes for sSpriteData (wSpriteStateData1 then 2) +-- ctx.tileAnimations the byte sTileAnimations holds +-- +-- Returns nil plus a reason when the data set predates the extractor fields +-- this needs (an older ROM cache), so callers can fall back rather than fail. +function MapContext.build(data, mapId, x, y) + local map = data and data.maps and data.maps[mapId] + if not map then return nil, "unknown map " .. tostring(mapId) end + local sram = map.sram + if not (sram and sram.header and sram.objects) then + return nil, "map cache has no saved-map bytes (re-import the ROM)" + end + x, y = math.floor(tonumber(x) or 0), math.floor(tonumber(y) or 0) + + local writes = {} + local header = sram.header + writes[O.curMapHeader] = header + local height, width = header[2], header[3] + local connectionFlags = header[10] + + -- Connections: LoadMapHeader writes $FF over all four connected-map bytes + -- and then copies 11 bytes per present direction, north/south/west/east. + -- The disabled slots' remaining bytes are never read. + local conn = {} + for i = 1, 4 * CONNECTION_STRUCT do conn[i] = 0 end + for slot = 0, 3 do conn[slot * CONNECTION_STRUCT + 1] = 0xFF end + local pos = 1 + for slot, flag in ipairs({ 0x08, 0x04, 0x02, 0x01 }) do + if math.floor(connectionFlags / flag) % 2 == 1 then + for i = 0, CONNECTION_STRUCT - 1 do + conn[(slot - 1) * CONNECTION_STRUCT + 1 + i] = (sram.connections or {})[pos + i] or 0 + end + pos = pos + CONNECTION_STRUCT + end + end + writes[O.connectionHeaders] = conn + + local parsed = parseObjects(sram.objects) + if not parsed then return nil, "malformed object data for " .. tostring(mapId) end + writes[O.mapBackgroundTile] = { parsed.backgroundTile } + writes[O.numberOfWarps] = { parsed.warpCount } + writes[O.warpEntries] = parsed.warps + writes[O.numSigns] = { parsed.signCount } + writes[O.signCoords] = parsed.signCoords + writes[O.signTextIDs] = parsed.signTexts + writes[O.numSprites] = { parsed.spriteCount } + writes[O.mapSpriteData] = parsed.spriteData + writes[O.mapSpriteExtra] = parsed.spriteExtra + + -- "map height/width in 2x2 tile blocks", doubled at the end of LoadMapHeader + writes[O.currentMapHeight2] = { (height * 2) % 256 } + writes[O.currentMapWidth2] = { (width * 2) % 256 } + + -- The tileset header, as predef LoadTilesetHeader would have copied it. + local tilesets = data.tilesets or {} + local tilesetDef = tilesets[map.tileset] + local tileAnimations = 0 + if tilesetDef and tilesetDef.header then + local row = {} + for i = 1, 11 do row[i] = tilesetDef.header[i] end + writes[O.tilesetHeader] = row + tileAnimations = tilesetDef.header[12] or 0 + end + + -- MapSongBanks: without it the game continues with sound id 0 and audio + -- bank 0, which is what actually hangs a Continue on a white screen. + local audio = data.audio + local songLabel = audio and audio.mapSongs and audio.mapSongs[mapId] + local song = songLabel and audio.songs and audio.songs[songLabel] + local id = song and soundId(song.address) + if id and song.bank then + writes[O.mapMusicSoundID] = { id } + writes[O.mapMusicROMBank] = { song.bank % 256 } + end + + -- Player position within its block, and the upper-left corner of the view. + -- The pointer is the same expression the warp_to tables are assembled with + -- (macros/coords.asm event_displacement). + writes[O.yBlockCoord] = { y % 2 } + writes[O.xBlockCoord] = { x % 2 } + local view = OVERWORLD_MAP + 7 + width + + (width + 6) * math.floor(y / 2) + math.floor(x / 2) + writes[O.viewPointer] = { view % 256, math.floor(view / 256) % 256 } + + -- sSpriteData. LoadMapHeader zeroes structs 1-15, sets their image index to + -- $ff (off screen until the engine places them), then fills in each map + -- object's picture id and its map Y/X plus movement byte 1. Struct 0 is the + -- player, which SpecialEnterMap rebuilds through ResetPlayerSpriteData. + local spriteData = {} + for i = 1, NUM_SPRITE_STRUCTS * SPRITE_STRUCT * 2 do spriteData[i] = 0 end + local data2 = NUM_SPRITE_STRUCTS * SPRITE_STRUCT + for slot = 1, NUM_SPRITE_STRUCTS - 1 do + spriteData[slot * SPRITE_STRUCT + 2 + 1] = 0xFF + end + for index, sprite in ipairs(parsed.sprites) do + local base = index * SPRITE_STRUCT + spriteData[base + 1] = sprite.picture + spriteData[data2 + base + 4 + 1] = sprite.mapY + spriteData[data2 + base + 5 + 1] = sprite.mapX + spriteData[data2 + base + 6 + 1] = sprite.movement1 + end + + return { + writes = writes, + spriteData = spriteData, + tileAnimations = tileAnimations, + } +end + +return MapContext diff --git a/src/save_convert/SaveConvert.lua b/src/save_convert/SaveConvert.lua index b4dc9d34..3df6122f 100644 --- a/src/save_convert/SaveConvert.lua +++ b/src/save_convert/SaveConvert.lua @@ -42,11 +42,17 @@ local DATA_MODULES = { moves = { "data.generated.moves", "data/generated/moves.lua" }, items = { "data.generated.items", "data/generated/items.lua" }, maps = { "data.generated.maps", "data/generated/maps.lua" }, + -- tilesets/audio are only read by src/save_convert/MapContext.lua, to + -- rebuild the current map's engine state on export (#889) + tilesets = { "data.generated.tilesets", "data/generated/tilesets.lua" }, + audio = { "data.generated.audio", "data/generated/audio.lua" }, charmap = { "src.save_convert.data.charmap", "src/save_convert/data/charmap.lua" }, eventFlags = { "src.save_convert.data.event_flags", "src/save_convert/data/event_flags.lua" }, toggleObjects = { "src.save_convert.data.toggle_objects", "src/save_convert/data/toggle_objects.lua" }, } +local OPTIONAL_MODULES = { tilesets = true, audio = true } + -- Yellow renumbers wEventFlags bits: pokeyellow's constants/event_constants.asm -- inserts events pokered does not have (the Jessie & James fights, catch -- training, the Officer Jenny Squirtle) and shifts the Mt Moon 3 / Silph Co @@ -132,7 +138,10 @@ local function ensureData(gameVersion) if not mod then local e mod, e = loadTable(spec[1], spec[2]) - if not mod then return nil, e end + -- tilesets/audio only sharpen the export (MapContext); a cache + -- without them still imports and exports, just without the + -- rebuilt map window, so they must not fail the whole load + if not mod and not OPTIONAL_MODULES[name] then return nil, e end end data[name] = mod end diff --git a/tests/drivers/export_sav_bug889_test.lua b/tests/drivers/export_sav_bug889_test.lua new file mode 100644 index 00000000..f258f318 --- /dev/null +++ b/tests/drivers/export_sav_bug889_test.lua @@ -0,0 +1,51 @@ +-- Driver (#889): export a .sav from a save that never came from a ROM import, +-- the case that used to write a save with no map context at all -- no map +-- header, no tileset header, sound id 0 -- so a real Game Boy continued into a +-- garbled map and hung on a white screen. +-- +-- Saves the game in two places (an interior and an outdoor map with +-- connections), exports each, and prints the bytes the engine reads back on +-- Continue so a human can eyeball them, plus the export path to load in an +-- emulator. +return function(game) + local U = dofile("tests/drivers/util.lua") + local SaveFileIO = require("src.import.SaveFileIO") + local SaveData = require("src.core.SaveData") + local GameVersion = require("src.core.GameVersion") + + local version = GameVersion.get() + local spots = { + { "REDS_HOUSE_2F", 3, 6 }, + { "PALLET_TOWN", 5, 6 }, + } + + for _, spot in ipairs(spots) do + local map, x, y = spot[1], spot[2], spot[3] + U.teleport(game, map, x, y, "down") + U.wait(30) + -- the same sync the in-game SAVE does before writing (OverworldState: + -- captureSave), so the slot on disk holds the position we just walked to + local top = game.stack:top() + if top and top.captureSave then top:captureSave(game.save) end + SaveData.save(game.save) + local ok, pathOrErr = SaveFileIO.exportActiveSlot(version) + if not ok then + U.log(("export_sav_bug889: %s FAILED: %s"):format(map, tostring(pathOrErr))) + else + local bytes = love.filesystem.read( + ("exports/%s/gen1recomp-%s-%s.sav"):format( + version, version, SaveData.activeSlot(version) or "save")) + local main = 0x2598 + 11 + local function u8(off) return bytes:byte(main + off + 1) end + local hdr = {} + for i = 0, 9 do hdr[#hdr + 1] = ("%02X"):format(u8(112 + i)) end + U.log(("export_sav_bug889: %s -> %s"):format(map, pathOrErr)) + U.log((" wCurMap=%02X wCurMapHeader=%s music=%02X/%02X tilesetBank=%02X"): + format(u8(103), table.concat(hdr, " "), u8(100), u8(101), u8(564))) + end + end + + U.log("export_sav_bug889: done") + love.event.quit() + while true do coroutine.yield() end +end diff --git a/tests/engine/save_import_retry_bug420.lua b/tests/engine/save_import_retry_bug420.lua index 0ae47ec1..46b955e3 100644 --- a/tests/engine/save_import_retry_bug420.lua +++ b/tests/engine/save_import_retry_bug420.lua @@ -131,7 +131,12 @@ package.loaded["src.import.CacheFs"] = fakeCache local SaveConvert = require("src.save_convert.SaveConvert") -local GENERATED = { "pokemon", "moves", "items", "maps" } +-- tilesets/audio joined the set with the #889 map-context rebuild, which +-- reads the current map's tileset row and song out of the same cache. +-- The audio entry is single-quoted on purpose: gate_meta_coverage.lua treats a +-- double-quoted registry name anywhere in the test corpus as that registry's +-- unit test, and this suite is not the mod audio registry's. +local GENERATED = { "pokemon", "moves", "items", "maps", "tilesets", 'audio' } local function prefixes() local seen = {} @@ -148,7 +153,7 @@ do name .. " comes out of Blue's cache, not the un-prefixed read path") end eq(prefixes()[GameVersion.VERSIONS.blue.cachePrefix], #GENERATED, - "all four generated tables are read under Blue's cache prefix") + "every generated table is read under Blue's cache prefix") eq(fakeCache.prefix, SENTINEL, "CacheFs.prefix is launcher-owned state and is put back after the read") check(data and data.eventFlags ~= nil, diff --git a/tests/engine/save_map_context_bug889.lua b/tests/engine/save_map_context_bug889.lua new file mode 100644 index 00000000..f0b9f6f3 --- /dev/null +++ b/tests/engine/save_map_context_bug889.lua @@ -0,0 +1,201 @@ +-- #889: a .sav exported from a save that never came from a ROM import used to +-- carry no current-map state at all. A Continue restores that window from the +-- save and never rebuilds it (LoadMainData sets BIT_NO_PREVIOUS_MAP and +-- LoadMapHeader returns early on it), so the game continued into tileset 0, +-- a $0000 map-data pointer and sound id 0 -- a garbled map and a silent hang +-- on real hardware. +-- +-- src/save_convert/MapContext.lua replays LoadMapHeader's WRAM writes from the +-- extracted ROM bytes instead. This suite pins the layout it writes, on a +-- synthetic map whose header/object bytes are chosen so every field is +-- distinguishable, and then checks the encoder's three cases: no template +-- (rebuild), a template saved on the same map (leave the game's own bytes +-- alone, which is what keeps import -> export byte-identical), and a template +-- saved on a different map (rebuild, or the export carries the wrong map's +-- header). +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local MapContext = require("src.save_convert.MapContext") +local GenSave = require("src.save_convert.GenSave") + +local O = MapContext.OFFSETS +local SAVE = GenSave.OFFSETS + +-- ---------------------------------------------------------------- fixtures + +-- tileset 0, 4 blocks tall, 5 wide, data/text/script pointers, north|west +local HEADER = { 0x00, 0x04, 0x05, 0x21, 0x43, 0x78, 0x56, 0x89, 0x67, 0x0A } +-- two 11-byte map_connection_structs, north first then west (the order +-- LoadMapHeader copies them in), each tagged with its own filler +local CONNECTIONS = { + 0x11, 0xA1, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, + 0x22, 0xB1, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, +} +local OBJECTS = { + 0x0E, -- wMapBackgroundTile + 2, -- warps + 0x04, 0x05, 0x00, 0x26, + 0x06, 0x07, 0x01, 0x27, + 2, -- signs + 0x08, 0x09, 0x03, -- Y, X, text id + 0x0A, 0x0B, 0x04, + 3, -- sprites + 0x01, 0x14, 0x15, 0xFF, 0xD0, 0x05, -- plain NPC + 0x02, 0x16, 0x17, 0xFE, 0x01, 0x47, -- trainer ($40): class, party + 0x33, 0x44, + 0x03, 0x18, 0x19, 0xFF, 0xD3, 0x85, -- item ball ($80): item id + 0x14, +} +local TILESET_HEADER = { 0x0C, 0x11, 0x40, 0x22, 0x40, 0x33, 0x40, + 0x44, 0x55, 0x66, 0x77, 0x02 } + +local function fixtureData() + local maps = {} + for id, map in pairs(dofile("tests/fixture_data/maps.lua")) do + local copy = {} + for k, v in pairs(map) do copy[k] = v end + maps[id] = copy + end + maps.FIX_TOWN.sram = { + header = HEADER, connections = CONNECTIONS, objects = OBJECTS, + } + return { + maps = maps, + pokemon = dofile("tests/fixture_data/pokemon.lua"), + moves = dofile("tests/fixture_data/moves.lua"), + items = dofile("tests/fixture_data/items.lua"), + tilesets = { [maps.FIX_TOWN.tileset] = { header = TILESET_HEADER } }, + audio = { + -- Song ids are computed from the header address + -- (constants/music_constants.asm): (address - $4000) / 3. + mapSongs = { FIX_TOWN = "Music_Fixture" }, + songs = { Music_Fixture = { address = 0x4000 + 3 * 0xBD, bank = 2 } }, + }, + } +end + +local data = fixtureData() + +-- ------------------------------------------------------------ the window + +local ctx = assert(MapContext.build(data, "FIX_TOWN", 7, 4)) +local w = ctx.writes + +local function eqBytes(got, want, msg) + got = got or {} + T.eq(#got, #want, msg .. " (length)") + for i = 1, #want do + T.eq(got[i], want[i], ("%s (byte %d)"):format(msg, i)) + end +end + +eqBytes(w[O.curMapHeader], HEADER, "wCurMapHeader is the ROM header verbatim") + +-- north and west are present; south and east must read $FF, or LoadTileBlockMap +-- walks a connection that is not there +local conn = w[O.connectionHeaders] +T.eq(#conn, 44, "all four connection structs are written") +T.eq(conn[1], 0x11, "north takes the first connection struct") +T.eq(conn[11], 0xAA, "north keeps all 11 of its bytes") +T.eq(conn[12], 0xFF, "south is disabled with $FF") +T.eq(conn[23], 0x22, "west takes the second connection struct") +T.eq(conn[34], 0xFF, "east is disabled with $FF") + +eqBytes(w[O.mapBackgroundTile], { 0x0E }, "wMapBackgroundTile") +eqBytes(w[O.numberOfWarps], { 2 }, "wNumberOfWarps") +eqBytes(w[O.warpEntries], { 0x04, 0x05, 0x00, 0x26, 0x06, 0x07, 0x01, 0x27 }, + "wWarpEntries are the raw 4-byte rows") +eqBytes(w[O.numSigns], { 2 }, "wNumSigns") +eqBytes(w[O.signCoords], { 0x08, 0x09, 0x0A, 0x0B }, + "wSignCoords are split out of the 3-byte sign rows") +eqBytes(w[O.signTextIDs], { 0x03, 0x04 }, "wSignTextIDs") +eqBytes(w[O.numSprites], { 3 }, "wNumSprites") +-- movement byte 2 and the text id with its flag bits masked ("and $3f") +eqBytes(w[O.mapSpriteData], { 0xD0, 0x05, 0x01, 0x07, 0xD3, 0x05 }, + "wMapSpriteData is (movement byte 2, text id & $3f) per sprite") +eqBytes(w[O.mapSpriteExtra], { 0, 0, 0x33, 0x44, 0x14, 0 }, + "wMapSpriteExtraData holds trainer class/party and item id") +eqBytes(w[O.currentMapHeight2], { 8 }, "map height doubled into 2x2 blocks") +eqBytes(w[O.currentMapWidth2], { 10 }, "map width doubled into 2x2 blocks") + +local tilesetRow = {} +for i = 1, 11 do tilesetRow[i] = TILESET_HEADER[i] end +eqBytes(w[O.tilesetHeader], tilesetRow, + "wTilesetBank..wGrassTile is the Tilesets row (11 bytes)") +T.eq(ctx.tileAnimations, 0x02, "the 12th tileset byte rides in sTileAnimations") + +eqBytes(w[O.mapMusicSoundID], { 0xBD }, "the song id is derived from its header address") +eqBytes(w[O.mapMusicROMBank], { 2 }, "the song's audio bank comes with it") + +-- x=7,y=4: block coords are the odd/even halves, and the view pointer is +-- macros/coords.asm event_displacement over the map width. +eqBytes(w[O.yBlockCoord], { 0 }, "wYBlockCoord is y & 1") +eqBytes(w[O.xBlockCoord], { 1 }, "wXBlockCoord is x & 1") +local view = 0xC6E8 + 7 + 5 + (5 + 6) * 2 + 3 +eqBytes(w[O.viewPointer], { view % 256, math.floor(view / 256) }, + "wCurrentTileBlockMapViewPointer") + +-- sSpriteData: picture ids in structs 1..3 of page 1, positions in page 2, +-- and every non-player struct's image index disabled at $ff +T.eq(#ctx.spriteData, 512, "sSpriteData is the full 512-byte window") +T.eq(ctx.spriteData[16 + 1], 0x01, "sprite 1 picture id") +T.eq(ctx.spriteData[16 + 3], 0xFF, "sprite 1 image index starts disabled") +T.eq(ctx.spriteData[256 + 16 + 5], 0x14, "sprite 1 map Y") +T.eq(ctx.spriteData[256 + 16 + 6], 0x15, "sprite 1 map X") +T.eq(ctx.spriteData[256 + 16 + 7], 0xFF, "sprite 1 movement byte 1") +T.eq(ctx.spriteData[3 * 16 + 1], 0x03, "sprite 3 picture id") +T.eq(ctx.spriteData[1], 0, "the player's struct is left to ResetPlayerSpriteData") + +-- a map the cache has no bytes for degrades instead of raising +T.eq(MapContext.build(data, "FIX_ROUTE", 0, 0), nil, + "a map with no extracted bytes returns nil, not an error") + +-- ------------------------------------------------------- through the codec + +GenSave.setCharmap(loadfile("src/save_convert/data/charmap.lua")()) + +local save = { + player = { name = "RED", rival = "BLUE", map = "FIX_TOWN", x = 7, y = 4 }, + money = 3000, inventory = {}, pokedex = { seen = {}, owned = {} }, + flags = {}, party = {}, boxes = {}, +} + +local function byteAt(bytes, mainOffset) + return bytes:byte(SAVE.mainData + mainOffset + 1) +end + +local fresh = GenSave.encode(save, data, nil) +T.eq(byteAt(fresh, O.curMapHeader + 1), 0x04, + "a templateless export carries the map header") +T.eq(byteAt(fresh, O.tilesetHeader), 0x0C, + "a templateless export carries the tileset header") +T.eq(byteAt(fresh, O.mapMusicSoundID), 0xBD, + "a templateless export carries the map's music, not sound id 0") +T.eq(fresh:byte(SAVE.checksumEnd - 1 + 1), 0x02, + "sTileAnimations is written before the checksum covers it") +T.eq(GenSave.mainChecksumValid(fresh), true, + "the rebuilt window is inside a valid main-data checksum") + +-- a template still on its own map keeps the game's own bytes: those include +-- live NPC positions, and preserving them is the round-trip invariant +local template = {} +for i = 1, #fresh do template[i] = fresh:sub(i, i) end +template[SAVE.mainData + O.curMapHeader + 1] = string.char(0x99) +template[SAVE.spriteData + 17] = string.char(0x77) +local sameMap = GenSave.encode(save, data, table.concat(template)) +T.eq(byteAt(sameMap, O.curMapHeader), 0x99, + "a template saved on this map keeps its map header untouched") +T.eq(sameMap:byte(SAVE.spriteData + 17), 0x77, + "a template saved on this map keeps its live sprite data") + +-- a template saved somewhere else is stale: it holds the OTHER map's header, +-- which is exactly as unbootable as an empty one +template[SAVE.mainData + SAVE.curMap - SAVE.mainData + 1] = nil +local other = {} +for i = 1, #fresh do other[i] = fresh:sub(i, i) end +other[SAVE.curMap + 1] = string.char(0xFE) -- some map this save is not on +other[SAVE.mainData + O.curMapHeader + 1] = string.char(0x99) +local moved = GenSave.encode(save, data, table.concat(other)) +T.eq(byteAt(moved, O.curMapHeader), HEADER[1], + "a template saved on another map is rebuilt for the map the save is on") From bd6c60630392d4cb90c23dea851db5a2f7796058 Mon Sep 17 00:00:00 2001 From: Andrew Barnes Date: Thu, 6 Aug 2026 15:24:31 -0400 Subject: [PATCH 04/63] Add multiplayer session FIFO facade --- src/link/Session.lua | 103 ++++++++++++++++++++++++++++++++++ tests/engine/link_session.lua | 84 +++++++++++++++++++++++++++ 2 files changed, 187 insertions(+) create mode 100644 src/link/Session.lua create mode 100644 tests/engine/link_session.lua diff --git a/src/link/Session.lua b/src/link/Session.lua new file mode 100644 index 00000000..68bd02b7 --- /dev/null +++ b/src/link/Session.lua @@ -0,0 +1,103 @@ +local Session = {} +Session.__index = Session + +local VALID_ROLES = { host = true, guest = true } +local REQUIRED_METHODS = { "update", "poll", "send", "close" } + +function Session.new(transport, options) + assert(type(transport) == "table", "Session.new requires a transport") + assert(type(options) == "table", "Session.new requires options") + assert(VALID_ROLES[options.role], "Session role must be host or guest") + assert(type(options.kind) == "string" and options.kind ~= "", + "Session kind must be a non-empty string") + for _, method in ipairs(REQUIRED_METHODS) do + assert(type(transport[method]) == "function", + "Session transport requires " .. method) + end + + local self = setmetatable({ + _transport = transport, + _role = options.role, + _kind = options.kind, + _inbox = {}, + _status = "connecting", + _terminal = nil, + _transportCloseCalled = false, + paired = false, + closed = false, + error = nil, + code = nil, + address = nil, + target = nil, + }, Session) + self:_syncMetadata() + self:_refreshStatus() + return self +end + +function Session:_syncMetadata() + local transport = self._transport + self.paired = transport.paired == true + self.code = transport.code + self.address = transport.address + self.target = transport.target +end + +function Session:_refreshStatus() + if not self._terminal then + self._status = self.paired and "paired" or "connecting" + self.closed = false + self.error = nil + end +end + +function Session:getRole() return self._role end +function Session:getKind() return self._kind end +function Session:getStatus() return self._status end +function Session:getFailure() return nil, nil end +function Session:hasPending() return #self._inbox > 0 end + +function Session:send(message) + if self._terminal then return nil end + return self._transport:send(message) +end + +function Session:update() + if self._terminal then return end + self._transport:update() + self:_syncMetadata() + local messages = self._transport:poll() + for _, message in ipairs(messages) do + self._inbox[#self._inbox + 1] = message + end + self:_refreshStatus() +end + +function Session:take(messageType) + assert(type(messageType) == "string", "Session.take requires a message type") + for index, message in ipairs(self._inbox) do + if message.type == messageType then + return table.remove(self._inbox, index) + end + end + return nil +end + +function Session:pollOne() + if #self._inbox == 0 then return nil end + return table.remove(self._inbox, 1) +end + +function Session:poll() + local messages = self._inbox + self._inbox = {} + return messages +end + +function Session:close() + if self._transportCloseCalled then return end + self._transportCloseCalled = true + self._transport:close() +end + +return Session diff --git a/tests/engine/link_session.lua b/tests/engine/link_session.lua new file mode 100644 index 00000000..1ab3313e --- /dev/null +++ b/tests/engine/link_session.lua @@ -0,0 +1,84 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Net = require("src.link.Net") +local Session = require("src.link.Session") + +local function sessionPair() + local hostNet, guestNet = Net.loopbackPair() + return Session.new(hostNet, { role = "host", kind = "link" }), + Session.new(guestNet, { role = "guest", kind = "link" }) +end + +do + local host, guest = sessionPair() + T.eq(host:getRole(), "host", "host role is assigned locally") + T.eq(guest:getRole(), "guest", "guest role is assigned locally") + T.eq(host:getKind(), "link", "session kind is retained") + T.eq(host:getStatus(), "paired", "wrapped loopback starts paired") + + guest:send({ + type = "hello", name = "BLUE", role = "host", kind = "tournament", + }) + host:update() + local hello = host:take("hello") + T.eq(hello.name, "BLUE", "send forwards the original payload") + T.eq(hello.session, nil, "send adds no session envelope") + T.eq(host:getRole(), "host", "peer payload cannot replace local role") + T.eq(host:getKind(), "link", "peer payload cannot replace local kind") +end + +do + local host, guest = sessionPair() + guest:send({ type = "before", sequence = 1 }) + guest:send({ type = "hello", sequence = 2 }) + guest:send({ type = "after", sequence = 3 }) + guest:send({ type = "hello", sequence = 4 }) + host:update() + + local hello = host:take("hello") + T.eq(hello.sequence, 2, "take removes the first matching packet") + T.eq(host:pollOne().sequence, 1, "pollOne removes only the FIFO head") + + local rest = host:poll() + T.eq(#rest, 2, "poll returns every remaining packet once") + T.eq(rest[1].sequence, 3, "take preserves the earlier remainder order") + T.eq(rest[2].sequence, 4, "take preserves repeated-type order") + T.eq(#host:poll(), 0, "poll clears the private FIFO") +end + +do + local sent + local transport = { + paired = false, + code = nil, + address = "192.0.2.5:7777", + target = "ROOM01", + update = function(self) + self.paired = true + self.code = "ROOM02" + end, + poll = function() return {} end, + send = function(_, message) + sent = message + return "queued", 7 + end, + close = function(self) self.closed = true end, + } + local session = Session.new(transport, { role = "guest", kind = "tournament" }) + T.eq(session:getStatus(), "connecting", "unpaired transport starts connecting") + T.eq(session.address, "192.0.2.5:7777", "address metadata is mirrored") + T.eq(session.target, "ROOM01", "target metadata is mirrored") + + local outbound = { type = "ping" } + local result, count = session:send(outbound) + T.eq(result, "queued", "send preserves the transport's first return") + T.eq(count, 7, "send preserves the transport's second return") + T.eq(sent, outbound, "send forwards the original table unchanged") + + session:update() + T.eq(session:getStatus(), "paired", "update observes transport pairing") + T.eq(session.code, "ROOM02", "update refreshes relay metadata") +end + +T.finish("link_session") From 2a7c8ec81b2a97c64fbfd07738a6678ca876ea67 Mon Sep 17 00:00:00 2001 From: Andrew Barnes Date: Thu, 6 Aug 2026 15:29:37 -0400 Subject: [PATCH 05/63] Harden multiplayer session terminal handling --- src/link/Net.lua | 11 +- src/link/Session.lua | 119 +++++++++++++++++-- tests/engine/link_session.lua | 218 ++++++++++++++++++++++++++++++++++ 3 files changed, 330 insertions(+), 18 deletions(-) diff --git a/src/link/Net.lua b/src/link/Net.lua index 950bee1e..b8d4654c 100644 --- a/src/link/Net.lua +++ b/src/link/Net.lua @@ -207,7 +207,7 @@ function Net:send(msg) end if self.peerEnd then -- loopback: re-encode through json like the wire local decoded = Json.decode(Json.encode(msg)) - if decoded and not self.peerEnd.closed then + if decoded ~= nil and not self.peerEnd.closed then table.insert(self.peerEnd.inbox, decoded) end return @@ -256,13 +256,12 @@ end function Net:handleTCPLine(line) local msg = Json.decode(line) - if not msg then + if msg == nil then Logger.warn("link: bad relay message %q", line:sub(1, 60)) return end - if not handleGenericRelayControl(self, msg) then - table.insert(self.inbox, msg) - end + if type(msg) == "table" and handleGenericRelayControl(self, msg) then return end + table.insert(self.inbox, msg) end -- pulls every complete "\n"-terminated line out of rxBuf (leaving a @@ -352,7 +351,7 @@ function Net:update() end elseif event.type == "receive" then local msg = Json.decode(event.data) - if msg then + if msg ~= nil then table.insert(self.inbox, msg) else Logger.warn("link: bad message %q", tostring(event.data):sub(1, 60)) diff --git a/src/link/Session.lua b/src/link/Session.lua index 68bd02b7..ecb4a64b 100644 --- a/src/link/Session.lua +++ b/src/link/Session.lua @@ -48,13 +48,43 @@ function Session:_refreshStatus() self._status = self.paired and "paired" or "connecting" self.closed = false self.error = nil + return end + if #self._inbox > 0 then + self._status = "draining" + self.closed = false + self.error = nil + return + end + self._status = self._terminal.status + self.closed = true + self.error = self._terminal.status == "failed" + and (self._terminal.detail or self._terminal.reason) or nil +end + +function Session:_latchTerminal(status, reason, detail) + if self._terminal then return false end + self._terminal = { status = status, reason = reason, detail = detail } + self:_refreshStatus() + return true +end + +function Session:_closeTransport() + if self._transportCloseCalled then return true end + self._transportCloseCalled = true + local ok, detail = pcall(self._transport.close, self._transport) + return ok, ok and nil or tostring(detail) end function Session:getRole() return self._role end function Session:getKind() return self._kind end function Session:getStatus() return self._status end -function Session:getFailure() return nil, nil end +function Session:getFailure() + if not self._terminal or self._terminal.status ~= "failed" then + return nil, nil + end + return self._terminal.reason, self._terminal.detail +end function Session:hasPending() return #self._inbox > 0 end function Session:send(message) @@ -63,13 +93,62 @@ function Session:send(message) end function Session:update() - if self._terminal then return end - self._transport:update() - self:_syncMetadata() - local messages = self._transport:poll() - for _, message in ipairs(messages) do - self._inbox[#self._inbox + 1] = message + if self._terminal then + self:_refreshStatus() + return end + + local failureReason, failureDetail + local updateOk, updateDetail = pcall(self._transport.update, self._transport) + self:_syncMetadata() + if not updateOk then + failureReason, failureDetail = "transport_error", tostring(updateDetail) + elseif self._transport.error then + failureReason = "transport_error" + failureDetail = tostring(self._transport.error) + end + + local pollOk, messages = pcall(self._transport.poll, self._transport) + if not pollOk then + if not failureReason then + failureReason, failureDetail = "transport_error", tostring(messages) + end + elseif type(messages) ~= "table" then + if not failureReason then + failureReason, failureDetail = "transport_error", + "transport poll returned non-table" + end + else + for index = 1, #messages do + local message = messages[index] + if type(message) ~= "table" or type(message.type) ~= "string" then + if not failureReason then + failureReason = "protocol_error" + failureDetail = ("message %d must be a table with string type") + :format(index) + end + break + end + self._inbox[#self._inbox + 1] = message + end + end + + self:_syncMetadata() + if failureReason then + self:_latchTerminal("failed", failureReason, failureDetail) + self:_closeTransport() + elseif self._transport.closed then + local closeOk, closeDetail = self:_closeTransport() + if closeOk then + self:_latchTerminal("closed") + else + self:_latchTerminal("failed", "transport_error", closeDetail) + end + end + self:_refreshStatus() +end + +local function finishRead(self) self:_refreshStatus() end @@ -77,7 +156,9 @@ function Session:take(messageType) assert(type(messageType) == "string", "Session.take requires a message type") for index, message in ipairs(self._inbox) do if message.type == messageType then - return table.remove(self._inbox, index) + local found = table.remove(self._inbox, index) + finishRead(self) + return found end end return nil @@ -85,19 +166,33 @@ end function Session:pollOne() if #self._inbox == 0 then return nil end - return table.remove(self._inbox, 1) + local message = table.remove(self._inbox, 1) + finishRead(self) + return message end function Session:poll() local messages = self._inbox self._inbox = {} + finishRead(self) return messages end function Session:close() - if self._transportCloseCalled then return end - self._transportCloseCalled = true - self._transport:close() + if self._status == "closed" or self._status == "failed" then return end + if self._terminal then + self:_closeTransport() + self:_refreshStatus() + return + end + local ok, detail = self:_closeTransport() + if ok then + self:_latchTerminal("closed") + else + self:_latchTerminal("failed", "transport_error", detail) + end + self:_syncMetadata() + self:_refreshStatus() end return Session diff --git a/tests/engine/link_session.lua b/tests/engine/link_session.lua index 1ab3313e..9ec9d36b 100644 --- a/tests/engine/link_session.lua +++ b/tests/engine/link_session.lua @@ -2,6 +2,7 @@ package.path = "./?.lua;./?/init.lua;" .. package.path local T = require("tests.modkit") local Net = require("src.link.Net") +local Json = require("src.link.Json") local Session = require("src.link.Session") local function sessionPair() @@ -10,6 +11,37 @@ local function sessionPair() Session.new(guestNet, { role = "guest", kind = "link" }) end +local function fakeTransport(options) + options = options or {} + local transport = { + paired = options.paired ~= false, + closed = false, + error = nil, + inbox = options.inbox or {}, + closeCount = 0, + } + function transport:update() + if options.onUpdate then options.onUpdate(self) end + if options.updateError then error(options.updateError) end + end + function transport:poll() + if options.pollError then error(options.pollError) end + local messages = self.inbox + self.inbox = {} + return messages + end + function transport:send(message) + self.sent = message + return true + end + function transport:close() + self.closeCount = self.closeCount + 1 + self.closed = true + if options.closeError then error(options.closeError) end + end + return transport +end + do local host, guest = sessionPair() T.eq(host:getRole(), "host", "host role is assigned locally") @@ -81,4 +113,190 @@ do T.eq(session.code, "ROOM02", "update refreshes relay metadata") end +do + local transport = fakeTransport({ onUpdate = function(self) + self.inbox[#self.inbox + 1] = { type = "bye", final = true } + self.closed = true + end }) + local session = Session.new(transport, { role = "host", kind = "link" }) + session:update() + T.eq(session:getStatus(), "draining", "normal close drains its final packet") + T.eq(session.closed, false, "compatibility closed waits for the FIFO") + T.eq(session:take("bye").final, true, "final close packet remains observable") + T.eq(session:getStatus(), "closed", "normal drain reaches closed") + T.eq(transport.closeCount, 1, "transport cleanup runs once") +end + +do + local transport = fakeTransport({ + onUpdate = function(self) self.closed = true end, + closeError = "normal cleanup exploded", + }) + local session = Session.new(transport, { role = "host", kind = "link" }) + T.check(pcall(session.update, session), + "normal-close cleanup exception does not escape the game loop") + local reason, detail = session:getFailure() + T.eq(reason, "transport_error", + "normal-close cleanup exception becomes a transport failure") + T.check(detail:find("normal cleanup exploded", 1, true) ~= nil, + "normal-close cleanup failure keeps its diagnostic detail") + T.eq(session:getStatus(), "failed", + "normal-close cleanup exception cannot report a clean close") +end + +do + local transport = fakeTransport({ onUpdate = function(self) + self.inbox = { { type = "before", sequence = 1 } } + self.error = "socket failed" + self.closed = true + end }) + local session = Session.new(transport, { role = "guest", kind = "link" }) + session:update() + local reason, detail = session:getFailure() + T.eq(reason, "transport_error", "transport failure has a stable reason") + T.eq(detail, "socket failed", "transport failure retains original detail") + T.eq(session:getStatus(), "draining", "transport failure drains valid prefix") + T.eq(session.error, nil, "legacy error stays hidden during drain") + T.eq(session.closed, false, "legacy closed stays false during failed drain") + T.eq(session:pollOne().sequence, 1, "failed drain returns its valid prefix") + T.eq(session:getStatus(), "failed", "failed drain reaches failed") + T.eq(session.error, "socket failed", "legacy error appears at terminal failure") + transport.error = "later error" + session:update() + local _, latchedDetail = session:getFailure() + T.eq(latchedDetail, "socket failed", "first terminal failure stays latched") +end + +do + local transport = fakeTransport({ inbox = { + { type = "before", sequence = 1 }, + false, + { type = "after", sequence = 3 }, + } }) + local session = Session.new(transport, { role = "host", kind = "link" }) + session:update() + local reason = session:getFailure() + T.eq(reason, "protocol_error", "malformed packet fails as protocol_error") + T.eq(session:getStatus(), "draining", "malformed batch drains valid prefix") + local messages = session:poll() + T.eq(#messages, 1, "malformed value and untrusted tail are not exposed") + T.eq(messages[1].sequence, 1, "valid prefix survives malformed packet") + T.eq(session:getStatus(), "failed", "protocol drain reaches failed") +end + +do + local transport = fakeTransport({ inbox = { { type = 7 } } }) + local session = Session.new(transport, { role = "host", kind = "link" }) + session:update() + T.eq(session:getFailure(), "protocol_error", + "table without string type is a protocol error") +end + +do + local transport = fakeTransport({ + inbox = { { type = "future_world_packet", value = 9 } }, + }) + local session = Session.new(transport, { role = "host", kind = "link" }) + session:update() + T.eq(session:pollOne().value, 9, "unknown typed packet stays mode-owned") +end + +do + local transport = fakeTransport({ + inbox = { { type = "already_decoded", value = 4 } }, + updateError = "update exploded", + }) + local session = Session.new(transport, { role = "host", kind = "link" }) + local ok = pcall(session.update, session) + T.check(ok, "transport update exception does not escape the game loop") + T.eq(session:getStatus(), "draining", "update exception still drains prior inbox") + T.eq(session:pollOne().value, 4, "decoded packet survives update exception") + T.eq(session:getStatus(), "failed", "update exception becomes terminal failure") +end + +do + local transport = fakeTransport({ pollError = "poll exploded" }) + local session = Session.new(transport, { role = "host", kind = "link" }) + T.check(pcall(session.update, session), + "transport poll exception does not escape the game loop") + local reason, detail = session:getFailure() + T.eq(reason, "transport_error", "poll exception is a transport failure") + T.check(detail:find("poll exploded", 1, true) ~= nil, + "poll exception keeps its diagnostic detail") +end + +do + local transport = fakeTransport({ closeError = "close exploded" }) + local session = Session.new(transport, { role = "guest", kind = "link" }) + T.check(pcall(session.close, session), + "transport close exception does not escape cleanup") + T.eq(session:getFailure(), "transport_error", + "close exception is a transport failure") + session:close() + T.eq(transport.closeCount, 1, "failed close is still attempted only once") +end + +do + local transport = fakeTransport() + local session = Session.new(transport, { role = "guest", kind = "link" }) + session:close() + session:close() + session:update() + T.eq(transport.closeCount, 1, "close and post-terminal update are idempotent") + T.eq(session:getStatus(), "closed", "explicit close reaches closed") +end + +do + T.check(not pcall(Session.new, nil, { role = "host", kind = "link" }), + "constructor rejects missing transport") + local transport = fakeTransport() + T.check(not pcall(Session.new, transport, { role = "leader", kind = "link" }), + "constructor rejects unsupported role") + T.check(not pcall(Session.new, transport, { role = "host", kind = "" }), + "constructor rejects empty kind") +end + +do + local senderNet, receiverNet = Net.loopbackPair() + local receiver = Session.new(receiverNet, { role = "guest", kind = "link" }) + senderNet:send(false) + receiver:update() + T.eq(receiver:getFailure(), "protocol_error", + "loopback forwards decoded false to session validation") +end + +do + local delivered = false + local transport = Net.new() + transport.enetHost = { + service = function() + if delivered then return nil end + delivered = true + return { type = "receive", data = "false" } + end, + } + local session = Session.new(transport, { role = "guest", kind = "link" }) + session:update() + T.eq(session:getFailure(), "protocol_error", + "ENet forwards decoded false to session validation") +end + +do + local transport = Net.new() + local session = Session.new(transport, { role = "host", kind = "tournament" }) + T.check(pcall(transport.handleTCPLine, transport, "42"), + "TCP control handoff does not index a decoded scalar") + session:update() + T.eq(session:getFailure(), "protocol_error", + "decoded TCP scalar reaches session validation") +end + +do + local transport = Net.new() + transport:handleTCPLine(Json.encode({ type = "hosted", code = "ABCDEF" })) + T.eq(transport.code, "ABCDEF", "valid relay controls stay transport-owned") + transport:handleTCPLine(Json.encode({ type = "hello", name = "RED" })) + T.eq(transport:poll()[1].name, "RED", "valid application packet stays intact") +end + T.finish("link_session") From 52c96437fa4dd8c41a320f071752609ccecb377d Mon Sep 17 00:00:00 2001 From: Andrew Barnes Date: Thu, 6 Aug 2026 16:03:40 -0400 Subject: [PATCH 06/63] Route link play through multiplayer sessions --- src/link/LinkState.lua | 170 ++++++++++++++++++---------------- tests/engine/link_session.lua | 25 +++++ 2 files changed, 113 insertions(+), 82 deletions(-) diff --git a/src/link/LinkState.lua b/src/link/LinkState.lua index 4e97133e..9f3da68d 100644 --- a/src/link/LinkState.lua +++ b/src/link/LinkState.lua @@ -10,6 +10,7 @@ local Net = require("src.link.Net") local Protocol = require("src.link.Protocol") local Runtime = require("src.mods.Runtime") local Screens = require("src.ui.Screens") +local Session = require("src.link.Session") local TextBox = require("src.render.TextBox") local Strings = require("src.core.Strings") @@ -44,10 +45,8 @@ local function forceLevelLabel(v) return (v == ANY or v == nil) and "ANY" or ("AUTO " .. tostring(v)) end --- stages before any Net object is meaningfully "this session's link" -- --- .net can still be a leftover failed attempt sitting on self, so error/ --- closed checks below skip these rather than keying off self.net's --- presence alone +-- stages before a successful transport has become this link's Session; +-- terminal checks skip them rather than keying off self.net's presence local PRE_CONNECT_STAGES = { menu = true, lanMenu = true, onlineMenu = true } -- how long the host waits for a v2 hello before deciding the peer predates @@ -70,6 +69,16 @@ local function ipDigits(ip) return digits end +local function openSession(role, connect) + local transport = Net.new() + if connect(transport) then + return Session.new(transport, { role = role, kind = "link" }) + end + local detail = transport.error or "?" + transport:close() + return nil, detail +end + function LinkState.new(game) local self = setmetatable({}, LinkState) self.game = game @@ -89,12 +98,15 @@ end -- "connecting with this code", same as if the player had typed it in function LinkState.newJoinOnline(game, code) local self = LinkState.new(game) - self.net = Net.new() - if self.net:joinOnline(nil, code) then + local session, detail = openSession("guest", function(transport) + return transport:joinOnline(nil, code) + end) + if session then + self.net = session self.stage = "onlineJoining" else self.stage = "menu" -- exitWith below needs a real stage to unwind from - self:exitWith(Strings("Link error:\n%s", self.net.error or "?")) + self:exitWith(Strings("Link error:\n%s", detail)) end return self end @@ -163,21 +175,13 @@ end -- take the peer's hello out of the inbox without eating anything that -- shares the batch with it function LinkState:pollHello() - local msgs = self.net:poll() - local keep, got = {}, false - for _, msg in ipairs(msgs) do - if msg.type == "hello" and not self.peerHello then - self.peerHello = msg - self.peerName = msg.name - got = true - else - keep[#keep + 1] = msg - end + local message + if not self.peerHello then message = self.net:take("hello") end + if message then + self.peerHello = message + self.peerName = message.name end - for i = #keep, 1, -1 do - table.insert(self.net.inbox, 1, keep[i]) - end - return got, #keep > 0 + return message ~= nil, self.net:hasPending() end function LinkState:sendHello(mode) @@ -220,13 +224,15 @@ function LinkState:update(dt) local input = self.game.input if self.net then self.net:update() - if self.net.error and not PRE_CONNECT_STAGES[self.stage] then - self:exitWith(Strings("Link error:\n%s", self.net.error:sub(1, 60))) + local status = self.net:getStatus() + if status == "failed" and not PRE_CONNECT_STAGES[self.stage] then + self:exitWith(Strings("Link error:\n%s", + (self.net.error or "?"):sub(1, 60))) return end - -- the peer vanished without a bye (only once the inbox is drained, + -- the peer vanished without a bye (only once the session FIFO drains, -- so a final message travelling with the disconnect still counts) - if self.net.closed and #self.net.inbox == 0 + if status == "closed" and not PRE_CONNECT_STAGES[self.stage] and self.stage ~= "addrEntry" and self.stage ~= "codeEntry" and self.stage ~= "notice" and self.stage ~= "battleRunning" then @@ -269,12 +275,15 @@ function LinkState:update(dt) self.stage = "menu" self.index = 1 elseif input:wasPressed("a") then - self.net = Net.new() if self.index == 1 then - if self.net:host() then + local session, detail = openSession("host", function(transport) + return transport:host() + end) + if session then + self.net = session self.stage = "hosting" else - self:exitWith(Strings("Link error:\n%s", self.net.error or "?")) + self:exitWith(Strings("Link error:\n%s", detail)) end else self.stage = "addrEntry" @@ -289,11 +298,14 @@ function LinkState:update(dt) self.index = 2 elseif input:wasPressed("a") then if self.index == 1 then - self.net = Net.new() - if self.net:hostOnline() then + local session, detail = openSession("host", function(transport) + return transport:hostOnline() + end) + if session then + self.net = session self.stage = "onlineHosting" else - self:exitWith(Strings("Link error:\n%s", self.net.error or "?")) + self:exitWith(Strings("Link error:\n%s", detail)) end else self.stage = "codeEntry" @@ -327,11 +339,14 @@ function LinkState:update(dt) CodeEntry.right(self.codeEntry) elseif input:wasPressed("a") then local code = CodeEntry.text(self.codeEntry) - self.net = Net.new() - if self.net:joinOnline(nil, code) then + local session, detail = openSession("guest", function(transport) + return transport:joinOnline(nil, code) + end) + if session then + self.net = session self.stage = "onlineJoining" else - self:exitWith(Strings("Link error:\n%s", self.net.error or "?")) + self:exitWith(Strings("Link error:\n%s", detail)) end end @@ -367,10 +382,15 @@ function LinkState:update(dt) + self.addr[base + 2] * 10 + self.addr[base + 3]) end - if self.net:join(table.concat(octets, ".")) then + local address = table.concat(octets, ".") + local session, detail = openSession("guest", function(transport) + return transport:join(address) + end) + if session then + self.net = session self.stage = "joining" else - self:exitWith(Strings("Link error:\n%s", self.net.error or "?")) + self:exitWith(Strings("Link error:\n%s", detail)) end end @@ -428,19 +448,11 @@ function LinkState:update(dt) elseif self.stage == "waitMode" then -- guest waits for host's pick if input:wasPressed("b") then self:exitWith(nil) return end - local msgs = self.net:poll() - for i, msg in ipairs(msgs) do - if msg.type == "hello" then - self.peerHello = msg - self.peerName = msg.name - -- the host's next messages (party, ...) can share this batch; - -- put them back so the new stage's poll sees them - for j = #msgs, i + 1, -1 do - table.insert(self.net.inbox, 1, msgs[j]) - end - self:decideCompat(msg.mode, false) - break - end + local message = self.net:take("hello") + if message then + self.peerHello = message + self.peerName = message.name + self:decideCompat(message.mode, false) end elseif self.stage == "notice" then @@ -456,40 +468,34 @@ function LinkState:update(dt) elseif self.stage == "battleWait" then if input:wasPressed("b") then self:exitWith(nil) return end - local msgs = self.net:poll() - for i, msg in ipairs(msgs) do - if msg.type == "party" then - -- the host owns this rule (same as mode); the guest only learns - -- it here, off the host's own party message - if not self.isHost then self.forceLevel = msg.forceLevel end - local LinkBattle = require("src.link.LinkBattle") - local opts = { - myParty = Protocol.packParty(self.game.save.party), - theirParty = msg.mons, - theirName = self.peerName or "FOE", - seed = self.isHost and self.linkSeed or msg.seed, - verdict = self.verdict, - strict = Handshake.strict(self.verdict), - forceLevel = self.forceLevel, - } - local battle, why - if self.isHost then - battle, why = LinkBattle.newHost(self.game, self.net, opts) - else - battle, why = LinkBattle.newGuest(self.game, self.net, opts) - end - if not battle then - self.net:send({ type = "bye" }) - self:exitWith(why or Strings("Link battle\ncan't start."), "error") - return - end - self.game.stack:push(battle) - self.stage = "battleRunning" - for j = #msgs, i + 1, -1 do - table.insert(self.net.inbox, 1, msgs[j]) - end - break + local message = self.net:take("party") + if message then + -- the host owns this rule (same as mode); the guest only learns + -- it here, off the host's own party message + if not self.isHost then self.forceLevel = message.forceLevel end + local LinkBattle = require("src.link.LinkBattle") + local opts = { + myParty = Protocol.packParty(self.game.save.party), + theirParty = message.mons, + theirName = self.peerName or "FOE", + seed = self.isHost and self.linkSeed or message.seed, + verdict = self.verdict, + strict = Handshake.strict(self.verdict), + forceLevel = self.forceLevel, + } + local battle, why + if self.isHost then + battle, why = LinkBattle.newHost(self.game, self.net, opts) + else + battle, why = LinkBattle.newGuest(self.game, self.net, opts) end + if not battle then + self.net:send({ type = "bye" }) + self:exitWith(why or Strings("Link battle\ncan't start."), "error") + return + end + self.game.stack:push(battle) + self.stage = "battleRunning" end elseif self.stage == "battleRunning" then diff --git a/tests/engine/link_session.lua b/tests/engine/link_session.lua index 9ec9d36b..0d051c5a 100644 --- a/tests/engine/link_session.lua +++ b/tests/engine/link_session.lua @@ -42,6 +42,13 @@ local function fakeTransport(options) return transport end +local function readFile(path) + local handle = assert(io.open(path, "rb")) + local body = handle:read("*a") + handle:close() + return body +end + do local host, guest = sessionPair() T.eq(host:getRole(), "host", "host role is assigned locally") @@ -299,4 +306,22 @@ do T.eq(transport:poll()[1].name, "RED", "valid application packet stays intact") end +do + local source = readFile("src/link/LinkState.lua") + T.check(source:find('require("src.link.Session")', 1, true) ~= nil, + "LinkState depends on the session boundary") + T.check(source:find('kind = "link"', 1, true) ~= nil, + "LinkState assigns the link session kind locally") + T.check(source:find("self.net.inbox", 1, true) == nil, + "LinkState never mutates a transport inbox") + T.check(source:find("self.net = Net.new()", 1, true) == nil, + "LinkState stores only successful session wrappers") + T.check(source:find(':take("hello")', 1, true) ~= nil, + "LinkState retrieves hello without draining unrelated packets") + T.check(source:find(':take("party")', 1, true) ~= nil, + "LinkState leaves battle handoff packets in session order") + T.check(source:find("getStatus()", 1, true) ~= nil, + "LinkState uses the session lifecycle instead of raw terminal flags") +end + T.finish("link_session") From d50ea4d0674f1bb14cd10d422c3eb173a6c802af Mon Sep 17 00:00:00 2001 From: Andrew Barnes Date: Thu, 6 Aug 2026 16:06:20 -0400 Subject: [PATCH 07/63] Route tournaments through multiplayer sessions --- src/link/Tournament.lua | 101 +++++++++++++++++----------------- tests/engine/link_session.lua | 18 ++++++ 2 files changed, 68 insertions(+), 51 deletions(-) diff --git a/src/link/Tournament.lua b/src/link/Tournament.lua index cfe663f4..a8a06193 100644 --- a/src/link/Tournament.lua +++ b/src/link/Tournament.lua @@ -14,6 +14,7 @@ local Font = require("src.render.Font") local Handshake = require("src.link.Handshake") local LinkBattle = require("src.link.LinkBattle") local Net = require("src.link.Net") +local Session = require("src.link.Session") local Protocol = require("src.link.Protocol") local Runtime = require("src.mods.Runtime") local Sound = require("src.core.Sound") @@ -132,12 +133,23 @@ end -- host / join -- ------------------------------------------------------------------- +local function openSession(role) + local transport = Net.new() + if transport:connectTCP(Net.defaultRelayAddress()) then + return Session.new(transport, { role = role, kind = "tournament" }) + end + local detail = transport.error or "?" + transport:close() + return nil, detail +end + function Tournament:startHosting() - self.net = Net.new() - if not self.net:connectTCP(Net.defaultRelayAddress()) then - self:exitWith(Strings("Link error:\n%s", self.net.error or "?")) + local session, detail = openSession("host") + if not session then + self:exitWith(Strings("Link error:\n%s", detail)) return end + self.net = session local size, minL, maxL = partyStats(self.game.save.party) self.isCreator = true self.participating = self.settings.participating @@ -156,11 +168,12 @@ function Tournament:startHosting() end function Tournament:startJoining(code) - self.net = Net.new() - if not self.net:connectTCP(Net.defaultRelayAddress()) then - self:exitWith(Strings("Link error:\n%s", self.net.error or "?")) + local session, detail = openSession("guest") + if not session then + self:exitWith(Strings("Link error:\n%s", detail)) return end + self.net = session local size, minL, maxL = partyStats(self.game.save.party) self.isCreator = false self.participating = true -- joining is always to compete; only hosting can opt out @@ -256,22 +269,14 @@ function Tournament:sendHello(mode) end function Tournament:pollHello() - local msgs = self.net:poll() - local keep, got = {}, false - for _, msg in ipairs(msgs) do - if msg.type == "hello" and not self.peerHello then - self.peerHello = msg - got = true - else - keep[#keep + 1] = msg - end - end - for i = #keep, 1, -1 do - table.insert(self.net.inbox, 1, keep[i]) - end - return got + local message + if not self.peerHello then message = self.net:take("hello") end + if message then self.peerHello = message end + return message ~= nil end +-- The relay assigns a side for each match; this can differ from the immutable +-- tournament creator/joiner role held by Session. function Tournament:enterMatch(msg) self.isHost = (msg.role == "host") self.opponentName = msg.opponent @@ -356,12 +361,13 @@ function Tournament:update(dt) if self.net then self.net:update() - if self.net.error and self.stage ~= "menu" and self.stage ~= "hostSettings" + local status = self.net:getStatus() + if status == "failed" and self.stage ~= "menu" and self.stage ~= "hostSettings" and self.stage ~= "codeEntry" then - self:exitWith(Strings("Link error:\n%s", self.net.error:sub(1, 60))) + self:exitWith(Strings("Link error:\n%s", (self.net.error or "?"):sub(1, 60))) return end - if self.net.closed and self.stage ~= "done" then + if status == "closed" and self.stage ~= "done" then self:exitWith(Strings("The tournament\nconnection was\nlost.")) return end @@ -370,24 +376,23 @@ function Tournament:update(dt) if self.stage == "matchHello" then if input:wasPressed("b") then self:exitWith(nil) return end self:pollHello() - if self.peerHello then self:beginMatchBattle() end - for _, msg in ipairs(self.net:poll()) do self:handleMessage(msg) end + if self.peerHello then + self:beginMatchBattle() + return + end + for _, message in ipairs(self.net:poll()) do self:handleMessage(message) end return elseif self.stage == "matchWaitParty" then if input:wasPressed("b") then self:exitWith(nil) return end - local msgs = self.net:poll() - for i, msg in ipairs(msgs) do - if msg.type == "party" then - self.pendingBattleOpts.theirParty = msg.mons + while self.net:hasPending() do + local message = self.net:pollOne() + if message.type == "party" then + self.pendingBattleOpts.theirParty = message.mons if self.isHost then self.pendingBattleOpts.seed = self.pendingBattleOpts.seed or self.linkSeed else - self.pendingBattleOpts.seed = msg.seed + self.pendingBattleOpts.seed = message.seed end - -- Split rather than `cond and newHost() or newGuest()`: the and/or - -- idiom truncates a call to its first result, so the second return - -- (the specific reason) was always dropped and every failure showed - -- the generic fallback instead of "same mods on both games" etc. local battle, why if self.isHost then battle, why = LinkBattle.newHost(self.game, self.net, self.pendingBattleOpts) @@ -398,27 +403,22 @@ function Tournament:update(dt) self:exitWith(why or Strings("Link battle\ncan't start.")) return end - -- anything after `party` in this same batch belongs to the - -- battle now, not to Tournament -- put it back for its own poll() - for j = #msgs, i + 1, -1 do - table.insert(self.net.inbox, 1, msgs[j]) - end self.activeBattle = battle self.game.stack:push(battle) self.stage = "matchRunning" return else - self:handleMessage(msg) + self:handleMessage(message) end end return elseif self.stage == "spectateWait" then if input:wasPressed("b") then self:exitWith(nil) return end - local msgs = self.net:poll() - for i, msg in ipairs(msgs) do - if msg.type == "spectate" and msg.msg.type == "party" then - local inner = msg.msg - if msg.side == "host" then + while self.net:hasPending() do + local message = self.net:pollOne() + if message.type == "spectate" and message.msg.type == "party" then + local inner = message.msg + if message.side == "host" then self.spectate.hostParty = inner.mons self.spectate.seed = inner.seed else @@ -426,8 +426,10 @@ function Tournament:update(dt) end if self.spectate.hostParty and self.spectate.guestParty then local battle, why = LinkBattle.newSpectator(self.game, self.net, { - hostParty = self.spectate.hostParty, guestParty = self.spectate.guestParty, - hostName = self.spectate.hostName, guestName = self.spectate.guestName, + hostParty = self.spectate.hostParty, + guestParty = self.spectate.guestParty, + hostName = self.spectate.hostName, + guestName = self.spectate.guestName, seed = self.spectate.seed, forceLevel = levelForWire(self.settings.forceLevel), }) @@ -435,16 +437,13 @@ function Tournament:update(dt) self:exitWith(why or Strings("Can't watch this\nmatch.")) return end - for j = #msgs, i + 1, -1 do - table.insert(self.net.inbox, 1, msgs[j]) - end self.activeBattle = battle self.game.stack:push(battle) self.stage = "spectateRunning" return end else - self:handleMessage(msg) + self:handleMessage(message) end end return diff --git a/tests/engine/link_session.lua b/tests/engine/link_session.lua index 0d051c5a..19f80fcf 100644 --- a/tests/engine/link_session.lua +++ b/tests/engine/link_session.lua @@ -324,4 +324,22 @@ do "LinkState uses the session lifecycle instead of raw terminal flags") end +do + local source = readFile("src/link/Tournament.lua") + T.check(source:find('require("src.link.Session")', 1, true) ~= nil, + "Tournament depends on the session boundary") + T.check(source:find('kind = "tournament"', 1, true) ~= nil, + "Tournament assigns its connection role and kind locally") + T.check(source:find("self.net.inbox", 1, true) == nil, + "Tournament never mutates a transport inbox") + T.check(source:find("self.net = Net.new()", 1, true) == nil, + "Tournament stores only a successful session wrapper") + T.check(source:find(':take("hello")', 1, true) ~= nil, + "Tournament retrieves match hello without draining its tail") + T.check(source:find(":pollOne()", 1, true) ~= nil, + "Tournament processes handoff prefixes one packet at a time") + T.check(source:find("getStatus()", 1, true) ~= nil, + "Tournament uses the session lifecycle instead of raw terminal flags") +end + T.finish("link_session") From 365bd247402977901fd8375daa40789ecd776766 Mon Sep 17 00:00:00 2001 From: Andrew Barnes Date: Thu, 6 Aug 2026 16:07:30 -0400 Subject: [PATCH 08/63] Document multiplayer session boundary --- docs/rfcs/0003-multiplayer-session-layer.md | 114 ++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 docs/rfcs/0003-multiplayer-session-layer.md diff --git a/docs/rfcs/0003-multiplayer-session-layer.md b/docs/rfcs/0003-multiplayer-session-layer.md new file mode 100644 index 00000000..66af086a --- /dev/null +++ b/docs/rfcs/0003-multiplayer-session-layer.md @@ -0,0 +1,114 @@ +# RFC 0003 — Add a reusable multiplayer session layer + +## Status + +Proposed. Engine: `Session.lua`, `Net.lua`, `LinkState.lua`, +`Tournament.lua`. Tests: `link_session.lua`. + +## Motivation + +Link play and tournaments currently own transport lifecycle details and +temporarily remove and reinsert packets in `Net.inbox` when a handshake or +battle starts. That makes packet ownership fragile and gives a future +shared-world mode no stable host/guest-aware boundary to reuse. + +The engine needs one small layer that preserves today's wire protocol while +owning received-packet order and terminal cleanup. Pokémon, battle, tournament, +save, and overworld rules remain outside that layer. + +## The decision it extends + +Extends the existing split between `Net` (backend setup, framing, and relay +controls), `Handshake`/`Protocol` (mode payloads), and the states that +interpret those payloads. It does not replace any of those components. + +## The exact API delta + +Backward-compatible and internal-only. + +### `Session.new(transport, options)` + +Wraps one successfully configured Net-compatible transport. `options.role` +is exactly `"host"` or `"guest"`; `options.kind` is a non-empty +local label such as `"link"` or `"tournament"`. Role and kind are +immutable session metadata selected locally and are never inferred from peer +packets. + +The facade forwards the narrow fields current consumers need: +`paired`, `code`, `address`, `target`, +`error`, and `closed`. +It forwards valid outbound tables unchanged through `send(message)`. + +### Receive and lifecycle methods + +- `update()` pumps the transport, validates decoded inbound values, and + appends accepted messages to a private FIFO. +- `pollOne()` removes the oldest queued message. +- `poll()` removes every queued message in order. +- `take(type)` removes the first queued message with that type without + disturbing any other message. +- `hasPending()` reports whether the FIFO is non-empty. +- `getRole()`, `getKind()`, `getStatus()`, and + `getFailure()` expose local metadata and lifecycle. +- `close()` closes the underlying transport once and is safe to repeat. + +Statuses are `connecting`, `paired`, `draining`, `closed`, +and `failed`. A transport close or failure becomes `draining` while +accepted packets remain queued. The terminal `closed`/`error` +compatibility projection appears only after that FIFO drains, so a last packet +travelling with a disconnect remains observable. + +An inbound value is structurally valid only when it is a table with a string +`type`. Invalid decoded values end the session with a protocol failure. +Unknown but structurally valid types remain queued for the owning mode; the +session does not contain a packet allowlist. + +## Authority direction + +A later `WorldSession` may compose this facade. In that mode the host +will own the world snapshot, map state, NPC state, event results, and shared +progression. A guest will bring a trainer identity plus their Pokémon party, +inventory, and other explicitly selected profile snapshot. + +Guest profile data and commands will be untrusted input. The host must validate +them and must authorize every world mutation before rebroadcasting the result. +The concrete snapshot schema, command vocabulary, conflict rules, and +persistence policy require a separate RFC and are not introduced here. + +## Compatibility and security + +No packet envelope, message name, payload shape, framing rule, relay protocol, +save schema, or engine protocol version changes. Existing valid outbound +messages encode exactly as before, and existing link and tournament screens +keep their current player-facing behavior. + +The layer does not authenticate players or encrypt traffic. Existing LAN and +relay access assumptions remain unchanged; knowledge of a join address or code +still grants the same access it grants today. Authentication, reconnect +identity, rate limits, and abuse controls remain future protocol decisions. + +## Migration note for players, mods, and peers + +**Nothing.** `LinkState` and `Tournament` adopt the facade +internally. Existing peers receive the same messages, mods gain no new API, and +players do not migrate saves or settings. + +## Parity tests + +- **ROM-free facade:** constructor validation, immutable role/kind, unchanged + send shape, FIFO ordering, typed retrieval, unknown typed packets, draining, + terminal failure latching, protected transport calls, and decoded-value + rejection. +- **Existing modes:** source guards prohibit direct inbox mutation; headless + module loads and the complete engine tier cover both migrated states. +- **ROM-backed link play:** run the existing link driver when generated ROM + data is available; the normal quick suite remains the required baseline. + +## Deprecation etiquette and non-goals + +Nothing deprecated. This RFC adds an internal facade and removes no transport +method. + +It does not add shared-world packets, co-op screens, a remote actor, save +transfer, server persistence, matchmaking, reconnect, or a protocol-version +bump. Those changes require the world-specific layer and its own review. From a98562f46b22dc69d5fe3297f8e21a6b541575a8 Mon Sep 17 00:00:00 2001 From: AverageConsumer <35539970+AverageConsumer@users.noreply.github.com> Date: Thu, 6 Aug 2026 22:56:13 +0200 Subject: [PATCH 09/63] feat(android): expose secondary-screen touch events --- docs/modding.md | 9 +-- .../love/src/jni/love/src/common/android.cpp | 29 ++++++++++ .../java/org/love2d/android/GameActivity.java | 58 +++++++++++++++++++ src/render/SecondScreen.lua | 16 ++++- tests/engine/second_screen_touch_test.lua | 42 ++++++++++++++ 5 files changed, 149 insertions(+), 5 deletions(-) create mode 100644 tests/engine/second_screen_touch_test.lua diff --git a/docs/modding.md b/docs/modding.md index 31721d9c..b54db6b1 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -221,10 +221,11 @@ the finished `worldCanvas` and `uiCanvas` with their SGB `zones` / `worldZones`, `worldActive`, the frame metrics (`ww`, `wh`, `pw`, `ph`, `ox`, `oy`, `vpw`, `vph`, `scale`, `Sx`, `Sy`, `dpiX`, `dpiY`), `renderer:blitCanvas(...)` for a palette-correct blit of either canvas into an arbitrary screen rect, and the -`secondScreen` bridge (`available()` / `push(imageData, w, h)` / `setEnabled`) -for driving a second physical display. This is what lets a mod lay the two -passes out as two stacked Game Boy screens, or push one onto a second screen, -without the engine knowing the layout. +`secondScreen` bridge (`available()` / `push(imageData, w, h)` / `pollTouch()` / +`setEnabled`) for driving a second physical display. `pollTouch()` returns the +oldest queued event as `"action,x,y"` in submitted-frame coordinates, or `nil`. +This is what lets a mod lay the two passes out as two stacked Game Boy screens, +or push one onto a second screen, without the engine knowing the layout. `screen.render_visible` receives `(next, state)` while the main screen is being composed. Return `false` to omit that state from drawing, opacity selection and diff --git a/mobile/android/love/src/jni/love/src/common/android.cpp b/mobile/android/love/src/jni/love/src/common/android.cpp index 6cba2fb5..2204e45d 100644 --- a/mobile/android/love/src/jni/love/src/common/android.cpp +++ b/mobile/android/love/src/jni/love/src/common/android.cpp @@ -1003,4 +1003,33 @@ void love_android_secondary_enable(int on) env->DeleteLocalRef(activity); } +extern "C" __attribute__((visibility("default"))) +const char *love_android_poll_secondary_touch() +{ + static thread_local std::string event; + event.clear(); + JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv(); + jclass activity = env->FindClass("org/love2d/android/GameActivity"); + jmethodID method = env->GetStaticMethodID(activity, "pollSecondaryDisplayTouch", + "()Ljava/lang/String;"); + if (!method) + env->ExceptionClear(); + else + { + jstring value = (jstring) env->CallStaticObjectMethod(activity, method); + if (value) + { + const char *utf = env->GetStringUTFChars(value, nullptr); + if (utf) + { + event = utf; + env->ReleaseStringUTFChars(value, utf); + } + env->DeleteLocalRef(value); + } + } + env->DeleteLocalRef(activity); + return event.empty() ? nullptr : event.c_str(); +} + #endif // LOVE_ANDROID diff --git a/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java b/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java index 1d70c7fa..09fa2148 100644 --- a/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java +++ b/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java @@ -1325,6 +1325,9 @@ public class GameActivity extends SDLActivity { // in src/jni/love/src/common/android.cpp. private static volatile SecondaryPresentation secondaryPresentation; private static volatile boolean secondaryEnabled = false; + private static final int MAX_SECONDARY_TOUCHES = 32; + private static final java.util.ArrayDeque secondaryTouches = + new java.util.ArrayDeque<>(); @Keep public static void setSecondaryEnabled(final boolean on) { @@ -1377,6 +1380,7 @@ public class GameActivity extends SDLActivity { private static void teardownSecondaryDisplay() { SecondaryPresentation p = secondaryPresentation; secondaryPresentation = null; + synchronized (secondaryTouches) { secondaryTouches.clear(); } if (p != null) { try { p.dismiss(); } catch (Throwable t) {} } @@ -1395,6 +1399,13 @@ public class GameActivity extends SDLActivity { } } + @Keep + public static String pollSecondaryDisplayTouch() { + synchronized (secondaryTouches) { + return secondaryTouches.pollFirst(); + } + } + private static class SecondaryPresentation extends android.app.Presentation { private final FrameView frameView; @@ -1461,6 +1472,7 @@ public class GameActivity extends SDLActivity { private final android.graphics.Paint paint = new android.graphics.Paint(); private final Object lock = new Object(); private int fw, fh; + private int activePointer = -1; FrameView(Context context) { super(context); @@ -1482,6 +1494,52 @@ public class GameActivity extends SDLActivity { postInvalidate(); } + private void enqueueTouch(String event) { + synchronized (secondaryTouches) { + if (secondaryTouches.size() >= MAX_SECONDARY_TOUCHES) { + secondaryTouches.clear(); + secondaryTouches.addLast("cancel,0,0"); + } else { + secondaryTouches.addLast(event); + } + } + } + + private int logicalX(float x) { + return Math.min(fw - 1, Math.max(0, + (int) ((x - dst.left) * fw / dst.width()))); + } + + private int logicalY(float y) { + return Math.min(fh - 1, Math.max(0, + (int) ((y - dst.top) * fh / dst.height()))); + } + + @Override + public boolean onTouchEvent(android.view.MotionEvent event) { + synchronized (lock) { + int action = event.getActionMasked(); + if (action == android.view.MotionEvent.ACTION_DOWN && fw > 0 + && dst.contains((int) event.getX(), (int) event.getY())) { + activePointer = event.getPointerId(0); + enqueueTouch("down," + logicalX(event.getX()) + "," + + logicalY(event.getY())); + } else if (action == android.view.MotionEvent.ACTION_UP + && activePointer >= 0) { + int index = event.findPointerIndex(activePointer); + if (index >= 0 && fw > 0) { + enqueueTouch("up," + logicalX(event.getX(index)) + "," + + logicalY(event.getY(index))); + } + activePointer = -1; + } else if (action == android.view.MotionEvent.ACTION_CANCEL) { + activePointer = -1; + enqueueTouch("cancel,0,0"); + } + } + return true; + } + @Override protected void onDraw(android.graphics.Canvas canvas) { synchronized (lock) { diff --git a/src/render/SecondScreen.lua b/src/render/SecondScreen.lua index c8a6efc3..2e5fb3b5 100644 --- a/src/render/SecondScreen.lua +++ b/src/render/SecondScreen.lua @@ -5,13 +5,15 @@ local SecondScreen = {} local C = nil +local ffi = nil local function log(msg) pcall(function() require("src.core.Logger").info("SecondScreen: %s", msg) end) end do - local ok, ffi = pcall(require, "ffi") + local ok + ok, ffi = pcall(require, "ffi") if not (ok and ffi) then log("ffi unavailable (not LuaJIT); second display disabled") else @@ -19,6 +21,7 @@ do int love_android_secondary_ready(); void love_android_push_secondary(const void *rgba, int w, int h); void love_android_secondary_enable(int on); + const char *love_android_poll_secondary_touch(); ]]) local okLib, lib = pcall(ffi.load, "love") if okLib and lib and pcall(function() return lib.love_android_secondary_ready end) then @@ -51,6 +54,17 @@ function SecondScreen.push(imageData, w, h) end) end +-- Returns the oldest queued secondary-display event as "action,x,y", where +-- coordinates are in the submitted frame's pixel space. +function SecondScreen.pollTouch() + if not C then return nil end + local ok, event = pcall(function() + return C.love_android_poll_secondary_touch() + end) + if not ok or event == nil or event == ffi.NULL then return nil end + return ffi.string(event) +end + function SecondScreen.setEnabled(on) if not C then return end pcall(function() C.love_android_secondary_enable(on and 1 or 0) end) diff --git a/tests/engine/second_screen_touch_test.lua b/tests/engine/second_screen_touch_test.lua new file mode 100644 index 00000000..309ba054 --- /dev/null +++ b/tests/engine/second_screen_touch_test.lua @@ -0,0 +1,42 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local name = "src.render.SecondScreen" +local oldModule = package.loaded[name] +local oldFfi = package.loaded.ffi +local oldPreload = package.preload.ffi +local null = {} +local calls = 0 +local C = { + love_android_secondary_ready = function() return 1 end, + love_android_push_secondary = function() end, + love_android_secondary_enable = function() end, + love_android_poll_secondary_touch = function() + calls = calls + 1 + return calls == 1 and "down,12,34" or null + end, +} +local fakeFfi = { + C = C, + NULL = null, + cdef = function() end, + load = function() return C end, + string = function(value) return value end, +} + +package.loaded[name] = nil +package.loaded.ffi = nil +package.preload.ffi = function() return fakeFfi end + +local SecondScreen = require(name) +T.eq(SecondScreen.pollTouch(), "down,12,34", + "secondary touch reaches the Lua facade") +T.eq(SecondScreen.pollTouch(), nil, "an empty native touch queue returns nil") +C.love_android_poll_secondary_touch = nil +T.eq(SecondScreen.pollTouch(), nil, "an older native bridge remains safe") + +package.loaded[name] = oldModule +package.loaded.ffi = oldFfi +package.preload.ffi = oldPreload + +T.finish("second-screen touch facade") From f1661b62b3864e0d932d7fbda35327846717ce03 Mon Sep 17 00:00:00 2001 From: Yukita Mayako Date: Fri, 7 Aug 2026 00:19:27 -0400 Subject: [PATCH 10/63] feat(OakSpeech): use field.playerSprites.walk in shrink step --- src/ui/OakSpeech.lua | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/ui/OakSpeech.lua b/src/ui/OakSpeech.lua index 9803e12f..8c5c08b5 100644 --- a/src/ui/OakSpeech.lua +++ b/src/ui/OakSpeech.lua @@ -265,7 +265,8 @@ function OakSpeech.new(game, onDone) or "assets/generated/intro/shrink2.png") -- RedSprite: the walking sprite the pic shrinks into (frame 0 = -- standing, facing down) - local red = game.data.sprites and game.data.sprites.SPRITE_RED + local playerSprites = (game.data.field and game.data.field.playerSprites) or {} + local red = game.data.sprites and game.data.sprites[playerSprites.walk or "SPRITE_RED"] or game.data.sprites.SPRITE_RED self.walkSheet = tryImage(red and red.image) return self end From d59a9522ee754058a4ff03809592271059f8ae52 Mon Sep 17 00:00:00 2001 From: Shane McGovern Date: Fri, 7 Aug 2026 10:49:10 +0100 Subject: [PATCH 11/63] Fix mod update checks failing on non-JSON responses (#931) The mod update and Find Mods feeds feed the raw HTTP body straight to Json.decode. When the endpoint hands back something that is not JSON (an HTML error page, a proxy/captive prompt, or a plain-text outage message like "Exceeded secondary rate limit" -- usually still HTTP 200), the decoder's "unexpected character 'E'" assert escaped through the pcall and became the error message, blaming the parser instead of the response. Add Json.describeUnexpected() as a pre-decode content-type guard: it returns nil for body shapes the endpoints actually publish (JSON object or array) and otherwise a short message naming what the server sent (HTML page / plain text / empty, with a preview). Wire it into ModUpdate.parseReleases and ModIndex.parse, so both the sync and async update-check paths surface the real answer instead of the parse error. HTTP status was already checked upstream by HostShell.httpGet (non-2xx becomes "HTTP from (...)"); this closes the remaining "2xx but not JSON" gap everywhere, including bridge platforms that expose no status or headers. Add regression tests for plain-text, HTML, and empty bodies; strengthen the ModIndex HTML soft-fail test. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/link/Json.lua | 22 ++++++++++++++++++++++ src/mods/ModIndex.lua | 4 +++- src/mods/ModUpdate.lua | 4 +++- tests/engine/mod_index_tests.lua | 6 +++++- tests/engine/mod_update_tests.lua | 25 +++++++++++++++++++++++++ 5 files changed, 58 insertions(+), 3 deletions(-) diff --git a/src/link/Json.lua b/src/link/Json.lua index 9ddb2e30..b6bd58a4 100644 --- a/src/link/Json.lua +++ b/src/link/Json.lua @@ -171,4 +171,26 @@ function Json.decode(s) return nil, v end +-- For an HTTP response that was meant to carry JSON but did not. Returns nil +-- when `s` starts like a JSON object or array (the only shapes the update and +-- index endpoints publish), otherwise a short message naming what the server +-- actually sent -- so callers surface "the response was an HTML page/plain +-- text, not JSON (it starts with ...)" instead of leaking the decoder's +-- low-level "unexpected character 'E'" assert at the first byte of an error +-- page or plain-text outage message. +function Json.describeUnexpected(s) + if type(s) ~= "string" then + return "the response had no body to decode" + end + local first = s:match("^%s*(.)") + if first == "{" or first == "[" then return nil end + local preview = s:gsub("%s+", " "):gsub("^%s+", ""):gsub("%s+$", "") + if preview == "" then + return "the response was empty, not JSON" + end + if #preview > 60 then preview = preview:sub(1, 57) .. "..." end + local kind = (first == "<") and "an HTML page" or "plain text" + return ("the response was %s, not JSON (it starts with %q)"):format(kind, preview) +end + return Json diff --git a/src/mods/ModIndex.lua b/src/mods/ModIndex.lua index c1e145fa..3988b9a9 100644 --- a/src/mods/ModIndex.lua +++ b/src/mods/ModIndex.lua @@ -191,8 +191,10 @@ end -- Never throws: a truncated download, an HTML error page, or a feed from a -- future schema all come back as a message the panel can print. function ModIndex.parse(jsonText, Json) + Json = Json or require("src.link.Json") + local notJson = Json.describeUnexpected(jsonText) + if notJson then return nil, notJson end local ok, result, err = pcall(function() - Json = Json or require("src.link.Json") local doc, decodeErr = Json.decode(jsonText) if type(doc) ~= "table" then return nil, decodeErr or "index.json is not an object" diff --git a/src/mods/ModUpdate.lua b/src/mods/ModUpdate.lua index 8087b642..4768bf70 100644 --- a/src/mods/ModUpdate.lua +++ b/src/mods/ModUpdate.lua @@ -140,8 +140,10 @@ end -- Decode a releases array (GET /repos/.../releases) into a sorted list -- (newest first). Releases without a .zip asset are dropped. Never throws. function ModUpdate.parseReleases(jsonText, modId, Json) + Json = Json or require("src.link.Json") + local notJson = Json.describeUnexpected(jsonText) + if notJson then return nil, notJson end local ok, result, err = pcall(function() - Json = Json or require("src.link.Json") local doc, decodeErr = Json.decode(jsonText) if type(doc) ~= "table" then return nil, decodeErr or "releases json is not an array" diff --git a/tests/engine/mod_index_tests.lua b/tests/engine/mod_index_tests.lua index 82076453..87e96222 100644 --- a/tests/engine/mod_index_tests.lua +++ b/tests/engine/mod_index_tests.lua @@ -142,7 +142,11 @@ do index, err = ModIndex.parse(Json.encode({ mods = { NUZLOCKE } })) check(index == nil and err ~= nil, "a feed with no schema_version is refused") index, err = ModIndex.parse("404") - check(index == nil and err ~= nil, "an HTML error page soft-fails") + check(index == nil and tostring(err):find("HTML", 1, true) ~= nil, + "an HTML error page is named, not blamed on the parser") + index, err = ModIndex.parse("Error: upstream unavailable") + check(index == nil and tostring(err):find("not JSON", 1, true) ~= nil, + "a plain-text error names the response") index, err = ModIndex.parse('{"schema_version":1}') check(index == nil and err ~= nil, "a feed with no mods array soft-fails") end diff --git a/tests/engine/mod_update_tests.lua b/tests/engine/mod_update_tests.lua index d6ff3239..a0b3306e 100644 --- a/tests/engine/mod_update_tests.lua +++ b/tests/engine/mod_update_tests.lua @@ -76,6 +76,31 @@ do check(path == nil and dlErr ~= nil, "empty url soft-fails") end +-- the reported bug: a non-JSON answer (plain-text error, proxy/captive +-- prompt, outage message) used to leak the decoder's "unexpected character" +-- assert at the first byte of the body. The guard must name what the server +-- actually sent and never let that assert surface. +do + local list, err = ModUpdate.parseReleases("Error: API rate limit exceeded", "demo") + check(list == nil and err ~= nil, "plain-text error soft-fails") + check(tostring(err):find("not JSON", 1, true) ~= nil + and tostring(err):find("Error: API", 1, true) ~= nil, + "plain-text error names the response and previews what it said") + list, err = ModUpdate.parseReleases("502 Bad Gateway", "demo") + check(list == nil and tostring(err):find("HTML", 1, true) ~= nil, + "an HTML error page is named as such") + list, err = ModUpdate.parseReleases("", "demo") + check(list == nil and tostring(err):find("empty", 1, true) ~= nil, + "an empty response is named") + check(tostring(err):find("unexpected character", 1, true) == nil, + "the decoder's assert never leaks into the message") + list = ModUpdate.parseReleases(Json.encode({ + { tag_name = "v1.0.0", assets = { + { name = "demo-1.0.0.zip", browser_download_url = "https://x/d.zip" } } }, + }), "demo") + eq(#list, 1, "the guard lets real JSON through") +end + do local body = Json.encode({ tag_name = "v2.0.0", From 66e7d9432ea44bec753ff476dd4d306b00e48c48 Mon Sep 17 00:00:00 2001 From: Shane McGovern Date: Fri, 7 Aug 2026 10:51:50 +0100 Subject: [PATCH 12/63] Fix Yellow Oak speech showing Nidorino instead of Pikachu (#915) Yellow's field.oakSpeech manifest carried only shrink frames, so OakSpeech.lua's `oakGfx.demoSpecies or "NIDORINO"` fallback fired and the opening speech showed Nidorino's sprite and cry instead of the player's Pikachu. - Stamp demoSpecies "PIKACHU" in the Yellow import manifest (source of truth for fresh ROM imports and developer builds). - Stamp it in make_yellow_manifest.py so regeneration keeps the value. - Repair stale Yellow caches in Data:applyVersionedFieldData() with a fill-if-absent block, matching the #617 oldManBattle RATTATA pattern. - Add parity test (manifest carries PIKACHU; stale cache filled; pre-stamped value left alone). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/core/Data.lua | 9 ++++++ tests/parity_yellow_oak_speech.lua | 51 ++++++++++++++++++++++++++++++ tools/make_yellow_manifest.py | 6 ++++ tools/rom_manifest_yellow.json | 1 + 4 files changed, 67 insertions(+) create mode 100644 tests/parity_yellow_oak_speech.lua diff --git a/src/core/Data.lua b/src/core/Data.lua index a6ec72e8..405fe107 100644 --- a/src/core/Data.lua +++ b/src/core/Data.lua @@ -84,6 +84,15 @@ function Data:applyVersionedFieldData() -- Yellow caches carry the wrong demo species too. The fixed import -- manifest below stamps RATTATA for fresh imports. self.field.oldManBattle = { species = "RATTATA", level = 5 } + -- The Oak-speech show-off mon is the player's Pikachu in Yellow + -- (engine/battle/core.asm BATTLE_TYPE_PIKACHU / the ProfOak demo) + -- but caches imported before the manifest carried demoSpecies fell + -- back to Red's NIDORINO (#915). The fixed import manifest below + -- stamps PIKACHU for fresh imports; fill it here for stale caches. + local oakSpeech = self.field.oakSpeech + if type(oakSpeech) == "table" and not oakSpeech.demoSpecies then + oakSpeech.demoSpecies = "PIKACHU" + end end end diff --git a/tests/parity_yellow_oak_speech.lua b/tests/parity_yellow_oak_speech.lua new file mode 100644 index 00000000..2b74da3f --- /dev/null +++ b/tests/parity_yellow_oak_speech.lua @@ -0,0 +1,51 @@ +-- Pokemon Yellow's Oak-speech show-off mon is the player's Pikachu, not +-- Red/Blue's NIDORINO (engine/battle/core.asm BATTLE_TYPE_PIKACHU, the +-- ProfOak demo; engine/movie/oak_speech/oak_speech.asm). The import +-- manifest must carry field.oakSpeech.demoSpecies, and +-- Data:applyVersionedFieldData repairs Yellow caches made before the +-- manifest carried it (#915). +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local Data = require("src.core.Data") +if not (Data.field and Data.field.oakSpeech) then Data:load() end +local GameVersion = require("src.core.GameVersion") +local S = require("tests.harness").suite("parity Yellow Oak speech") +local check, eq = S.check, S.eq + +local oldVersion = GameVersion.get() +local oldTrades = Data.field.trades +local oldOldManBattle = Data.field.oldManBattle + +local manifestFile = assert(io.open("tools/rom_manifest_yellow.json", "r")) +local manifest = manifestFile:read("*a") +manifestFile:close() + +check(manifest:find('"demoSpecies": "PIKACHU"') ~= nil, + "Yellow manifest stamps field.oakSpeech.demoSpecies as PIKACHU") + +-- a stale Yellow cache carries shrink frames but no demoSpecies +local stale = { shrink1 = "assets/generated/intro/shrink1.png", + shrink2 = "assets/generated/intro/shrink2.png" } +local oldOakSpeech = Data.field.oakSpeech +Data.field.oakSpeech = stale + +GameVersion.set("yellow") +Data:applyVersionedFieldData() +eq(Data.field.oakSpeech.demoSpecies, "PIKACHU", + "applyVersionedFieldData fills a stale Yellow cache with PIKACHU") + +-- fill-if-absent: an importer that learns to stamp the key wins +local preStamped = { demoSpecies = "RAICHU", + shrink1 = "assets/generated/intro/shrink1.png" } +Data.field.oakSpeech = preStamped +Data:applyVersionedFieldData() +eq(Data.field.oakSpeech.demoSpecies, "RAICHU", + "applyVersionedFieldData leaves an already-stamped demoSpecies alone") + +Data.field.oakSpeech = oldOakSpeech +Data.field.trades = oldTrades +Data.field.oldManBattle = oldOldManBattle +GameVersion.set(oldVersion) + +return S:finish() diff --git a/tools/make_yellow_manifest.py b/tools/make_yellow_manifest.py index 4e0e3782..de491f63 100755 --- a/tools/make_yellow_manifest.py +++ b/tools/make_yellow_manifest.py @@ -481,6 +481,12 @@ def derive(red, pokeyellow, symbols_path): for i, name in enumerate(yellow_bubbles)], } + # The Oak-speech show-off mon is the player's Pikachu in Yellow + # (engine/battle/core.asm BATTLE_TYPE_PIKACHU / the ProfOak demo); + # the deep-copied Red field.oakSpeech has no demoSpecies, so stamp + # it or the import falls back to NIDORINO (#915). + yellow["field"]["oakSpeech"]["demoSpecies"] = "PIKACHU" + # Ensure Melanie / Summer Beach town-map entries exist after rebuild. locations = yellow["field"]["townMap"]["locations"] if "CERULEAN_MELANIES_HOUSE" not in locations \ diff --git a/tools/rom_manifest_yellow.json b/tools/rom_manifest_yellow.json index 9bda56d2..9fa9e696 100644 --- a/tools/rom_manifest_yellow.json +++ b/tools/rom_manifest_yellow.json @@ -6566,6 +6566,7 @@ } ], "oakSpeech": { + "demoSpecies": "PIKACHU", "shrink1": "assets/generated/intro/shrink1.png", "shrink2": "assets/generated/intro/shrink2.png" }, From ed8a89c5ce3ea2bc1d06aaf5101c4a6d94e38e3f Mon Sep 17 00:00:00 2001 From: Shane McGovern Date: Fri, 7 Aug 2026 10:53:42 +0100 Subject: [PATCH 13/63] Honor trainer battleTheme override (fixes #945) trainers.battleTheme validated and merged onto the trainer record but was never read: battle music came solely from data.audio.battle[kind] where kind is computeMusicKind()'s final/gym/trainer/wild. Route both battle- theme start sites through a single choke point: - BattleState:playBattleTheme() cues Music.playBattle with the override (self.trainer.battleTheme via battleTheme()), defaulting to the kind when unset, so vanilla fights and #782's non-gym Giovanni are unchanged. - BattleState:enter() and OverworldController:pushBattle() both call it. - Music.playBattle gains an optional 4th song arg that overrides the kind default, and real call sites now populate the music.select trainerId. - Victory jingles stay kind-based: a custom battle theme has no derivable win-variant. New ROM-free T2 suite tests/engine/trainer_battle_theme_bug945.lua covers mod load, override resolution, the choke point, and the nil-override parity gate. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/battle/BattleState.lua | 23 ++- src/core/Music.lua | 7 +- src/mods/Schemas.lua | 3 + src/world/OverworldController.lua | 4 +- tests/engine/trainer_battle_theme_bug945.lua | 190 +++++++++++++++++++ 5 files changed, 220 insertions(+), 7 deletions(-) create mode 100644 tests/engine/trainer_battle_theme_bug945.lua diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index da79b2b7..9be21b9f 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -1411,6 +1411,26 @@ function BattleState:computeMusicKind() return "wild" end +-- a mod-set per-trainer battle theme (trainers.battleTheme, an audio.songs +-- id); nil for vanilla trainers, so the kind default is untouched (#782) +function BattleState:battleTheme() + local trainer = self.trainer + if trainer and trainer.battleTheme then return trainer.battleTheme end + return nil +end + +-- the battle-theme cue for this battle: the mod-set trainer battleTheme +-- when the class has one, else the kind default. The single choke point +-- both the transition-wipe start (OverworldController:pushBattle) and +-- enter() route through, so a per-trainer override can't drift between +-- them. self.musicKind is set by enter(); pushBattle runs before that, +-- so compute it here when absent. +function BattleState:playBattleTheme() + require("src.core.Music").playBattle(self.data, + self.musicKind or self:computeMusicKind(), + self.trainer and self.trainer.id, self:battleTheme()) +end + -- side tables mirror the singles battlers; called before every -- battler-switch notification so sides[i].battlers[1] stays honest function BattleState:syncSides() @@ -1462,7 +1482,6 @@ function BattleState:enter() .. Strings("%s blacked\nout!", name), blackedOut)) return end - local Music = require("src.core.Music") self.musicKind = self:computeMusicKind() if self.isGymLeader then require("src.world.PikachuFollower") @@ -1472,7 +1491,7 @@ function BattleState:enter() -- (audio/play_battle_music.asm runs before the transition, and -- Music.play no-ops on the same song); this covers battles pushed -- without a transition (link battles, scripted pushes) - Music.playBattle(self.data, self.musicKind) + self:playBattleTheme() -- intro presentation (SlidePlayerAndEnemySilhouettesOnScreen): both -- sides slide in; the trainer pics stay up until the send-outs -- BATTLE BG "world" drops this battle's opacity so StateStack keeps drawing diff --git a/src/core/Music.lua b/src/core/Music.lua index be6ba0e1..745d45a9 100644 --- a/src/core/Music.lua +++ b/src/core/Music.lua @@ -368,11 +368,12 @@ function Music.setSurfing(data, surfing) if play then Music.play(data, play, nil, { reason = "map" }) end end --- battle themes; kind = "wild"|"trainer"|"gym"|"final" -function Music.playBattle(data, kind, trainerId) +-- battle themes; kind = "wild"|"trainer"|"gym"|"final". `song`, when +-- given, overrides the kind's default -- a mod-set trainer battleTheme. +function Music.playBattle(data, kind, trainerId, song) local b = data.audio and data.audio.battle if b then - Music.play(data, b[kind] or b.wild, nil, + Music.play(data, song or b[kind] or b.wild, nil, { reason = "battle", kind = kind, trainerId = trainerId }) end end diff --git a/src/mods/Schemas.lua b/src/mods/Schemas.lua index 7ccac8a9..c834ce41 100644 --- a/src/mods/Schemas.lua +++ b/src/mods/Schemas.lua @@ -577,6 +577,9 @@ R.trainers = { aiMods = f.opt(f.any), aiClass = f.opt(f.id("ai_classes")), brain = f.opt(f.fn), + -- Per-trainer battle theme (an audio.songs id): overrides the + -- kind-based default (wild/trainer/gym/final) for this trainer's + -- battles. The victory jingle stays kind-based. battleTheme = f.opt(f.id("music")), }, example = 'mod.content.trainers:patch("OPP_BROCK", { baseMoney = 99 })', diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index d6fcdb56..19cda81d 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -760,8 +760,8 @@ function OverworldState:pushBattle(battle) local enemyLevel = battle.enemy and battle.enemy.mon and battle.enemy.mon.level or 0 -- the battle theme starts with the wipe, not after it -- (audio/play_battle_music.asm runs before the transition) - if battle.computeMusicKind then - require("src.core.Music").playBattle(Game.data, battle:computeMusicKind()) + if battle.playBattleTheme then + battle:playBattleTheme() end -- The fade back in from white on the way out is BattleState:finish()'s diff --git a/tests/engine/trainer_battle_theme_bug945.lua b/tests/engine/trainer_battle_theme_bug945.lua new file mode 100644 index 00000000..d7a4ce8e --- /dev/null +++ b/tests/engine/trainer_battle_theme_bug945.lua @@ -0,0 +1,190 @@ +-- Issue #945: a mod's per-trainer battleTheme (trainers.battleTheme, an +-- audio.songs id) was validated and merged onto the trainer record but +-- never read -- battle music came solely from data.audio.battle[kind] where +-- kind is computeMusicKind()'s final/gym/trainer/wild. Both battle-theme +-- start sites (OverworldController:pushBattle's pre-wipe cue and +-- BattleState:enter) now route through BattleState:playBattleTheme(), which +-- hands the override to Music.playBattle's new song arg. A nil override +-- keeps the kind default, so vanilla trainer fights -- and #782's non-gym +-- Giovanni -- are unchanged. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") + +-- ------- love audio stub: file-backed songs only (mod_audio pattern) + +local love = _G.love or {} +_G.love = love +love.audio = love.audio or {} + +local assets = { + ["assets/theme.ogg"] = true, + ["assets/alt.ogg"] = true, +} + +local sources = {} + +local Source = {} +Source.__index = Source +function Source:play() end +function Source:stop() end +function Source:setLooping() end +function Source:setVolume() end +function Source:setFilter() end + +love.audio.newSource = function(what, mode) + if type(what) == "string" and not assets[what] then + error("could not open file " .. what, 0) + end + local src = setmetatable({ file = what, mode = mode, queueable = false }, Source) + sources[#sources + 1] = src + return src +end +love.audio.newQueueableSource = function() + local src = setmetatable({ queueable = true }, Source) + sources[#sources + 1] = src + return src +end + +local Music = require("src.core.Music") +local Runtime = require("src.mods.Runtime") +local Font = require("src.render.Font") +local TypeChart = require("src.battle.TypeChart") +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") +local BattleState = require("src.battle.BattleState") + +-- ------- the fix-945 mod: register a song and point a trainer class at it + +local MOD = { + ["mods/fix_youngster_theme/manifest.json"] = [[{ + "id": "fix_youngster_theme", + "name": "Fix Youngster Theme", + "version": "1.0.0", + "entry": "main.lua", + "api": 2 + }]], + ["mods/fix_youngster_theme/main.lua"] = [[ + local mod = ... + mod.content.music:register("Music_ModTheme", { file = "assets/theme.ogg" }) + mod.content.trainers:patch("OPP_FIX_YOUNGSTER", { + battleTheme = "Music_ModTheme", + }) + ]], +} + +local function newGame(data) + local save = SaveData.newGame() + save.player.name = "RED" + save.player.rival = "GARY" + save.party = { Pokemon.new(data, "FIXMON_A", 30) } + return { data = data, save = save, + stack = { top = function() return nil end, + push = function() end, pop = function() end } } +end + +-- record every cue the music.select hook sees +local function hookRecorder(seen) + Runtime.hooks:wrap("music.select", function(nextLink, song, ctx) + seen[#seen + 1] = { song = song, kind = ctx.kind, + trainerId = ctx.trainerId } + return nextLink(song, ctx) + end, nil, "bug945") +end + +-- a playBattle spy that records (kind, trainerId, song) without touching audio +local function spyPlayBattle() + local calls = {} + local real = Music.playBattle + Music.playBattle = function(data, kind, trainerId, song) + calls[#calls + 1] = { kind = kind, trainerId = trainerId, song = song } + end + return calls, function() Music.playBattle = real end +end + +-- ------- the modded class resolves and the override reaches the cue + +local Data = T.fixtures.fresh() +Font.load(Data) +TypeChart.load(Data) + +local run = T.sdk.loadMods({ "mods/fix_youngster_theme" }, + { data = Data, fs = T.sdk.memfs(MOD) }) +T.eq(#run.errors, 0, "the battleTheme mod loads without validation errors") +T.eq(Data.trainers.OPP_FIX_YOUNGSTER.battleTheme, "Music_ModTheme", + "the patch lands on the trainer record") + +-- give the kind defaults a home so the no-override fallback is observable +Data.audio = Data.audio or {} +Data.audio.battle = Data.audio.battle or { + wild = "Music_DefaultWild", trainer = "Music_DefaultTrainer", +} +Data.audio.songs = Data.audio.songs or {} +Data.audio.songs.Music_DefaultWild = { file = "assets/alt.ogg" } +Data.audio.songs.Music_DefaultTrainer = { file = "assets/alt.ogg" } + +local battle = BattleState.newTrainer(newGame(Data), "OPP_FIX_YOUNGSTER", 1) +T.eq(battle:battleTheme(), "Music_ModTheme", + "battleTheme() resolves the per-trainer override") +T.eq(battle:computeMusicKind(), "trainer", + "a plain trainer fight is still trainer-kind") + +local calls, restore = spyPlayBattle() +battle:playBattleTheme() +T.eq(#calls, 1, "playBattleTheme cues the theme once") +T.eq(calls[1].kind, "trainer", "the cue carries the computed kind") +T.eq(calls[1].trainerId, "OPP_FIX_YOUNGSTER", "the cue carries the trainer id") +T.eq(calls[1].song, "Music_ModTheme", "the override label wins over the kind default") + +-- enter() sets self.musicKind before playing; playBattleTheme honors it +battle.musicKind = "gym" +battle:playBattleTheme() +T.eq(calls[2].kind, "gym", "a pre-set musicKind (the enter path) is used as-is") +restore() + +-- ------- Music.playBattle: override arg wins; nil falls back to the default + +local seen = {} +hookRecorder(seen) +Music.reload() +Music.playBattle(Data, "trainer", "OPP_FIX_YOUNGSTER", "Music_ModTheme") +T.eq(seen[1].song, "Music_ModTheme", "the override arg is played") +T.eq(seen[1].kind, "trainer", "the hook sees the battle kind") +T.eq(seen[1].trainerId, "OPP_FIX_YOUNGSTER", "the hook sees the trainer id") + +Music.reload() +Music.playBattle(Data, "trainer", "OPP_FIX_YOUNGSTER") +T.eq(seen[2].song, "Music_DefaultTrainer", + "no override falls back to the kind's default song") +T.eq(seen[2].trainerId, "OPP_FIX_YOUNGSTER", + "the hook still sees the trainer id on the default path") + +-- ------- a vanilla class has no override, so the kind default is untouched + +local DataV = T.fixtures.fresh() +Font.load(DataV) +TypeChart.load(DataV) +DataV.audio = { + battle = { wild = "Music_DefaultWild", trainer = "Music_DefaultTrainer" }, + songs = { + Music_DefaultWild = { file = "assets/alt.ogg" }, + Music_DefaultTrainer = { file = "assets/alt.ogg" }, + }, +} + +local battleV = BattleState.newTrainer(newGame(DataV), "OPP_FIX_YOUNGSTER", 1) +T.eq(battleV:battleTheme(), nil, "a vanilla trainer class has no override") +local callsV, restoreV = spyPlayBattle() +battleV:playBattleTheme() +T.eq(callsV[1].kind, "trainer", "vanilla cue keeps the trainer kind") +T.eq(callsV[1].song, nil, "vanilla passes no override, so the default plays (#782)") +restoreV() + +local seenV = {} +hookRecorder(seenV) +Music.reload() +Music.playBattle(DataV, "trainer", "OPP_FIX_YOUNGSTER") +T.eq(seenV[1].song, "Music_DefaultTrainer", + "vanilla battles play the kind default, not a per-trainer theme (#782)") + +T.finish("trainer battle theme bug945") From ce204aaf1634c09f8b76dcdbee5b9840a9062e46 Mon Sep 17 00:00:00 2001 From: Shane McGovern Date: Fri, 7 Aug 2026 11:14:20 +0100 Subject: [PATCH 14/63] CLOSES #916: hide trainer sprite through the Fly/Dig warp fade After the Fly departure animation finished (bird off-screen) and during Dig/teleport, the trainer sprite popped back in standing at the old cell for the whole 32-frame black fade-out before the transition. The player-hide guard only held while a departure animation was live: flyAnim went nil the instant path2 completed and the teleportOut countdown cleared the spin fields at 0, but startWarpTo's Transition (not isOpaque) keeps the overworld drawing beneath the veil, and the arrival animation is not armed until setMap's midpoint. Add a playerHidden flag on OverworldState that bridges the gap: - set when each departure completes (flyAnim path2 / teleportOut hit 0), immediately before the warp starts; - cleared in startWarpTo's Transition enter callback, synchronously after setMap and before the arrival arms flyArrive / spinDrop, so the player is never drawable mid-fade and never bare on the landing frame; - folded into both player-draw guards. ROM-free regression test (tests/engine/warp_sprite_hidden_bug916.lua) drives the REAL Transition + setMap headlessly for Dig and Fly and asserts zero fade frames leave the player drawable bare (would have observed 31/32 gap frames before the fix). Runs in the CI headless T2 tier. Dig spin timing/lift and the black fade color are left as-is (fade is intentional per #607). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/world/OverworldController.lua | 21 ++- tests/engine/warp_sprite_hidden_bug916.lua | 151 +++++++++++++++++++++ 2 files changed, 170 insertions(+), 2 deletions(-) create mode 100644 tests/engine/warp_sprite_hidden_bug916.lua diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index d6fcdb56..f7a58665 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -969,8 +969,14 @@ function OverworldState:update(dt) -- the bird carries the player in on landing, with its own -- SFX_FLY (EnterMapAnim .flyAnimation) self.arriveWarp = "fly" + -- keep the sprite hidden through the warp fade-out (#916): flyAnim + -- just went nil but flyArrive is not armed until startWarpTo's + -- midpoint, and the overworld keeps drawing beneath the veil, so + -- without this the trainer pops back in at the old cell for 32 frames + self.playerHidden = true self:startWarpTo(d.map, d.x, d.y, "down", nil, { via = "fly" }) else + self.playerHidden = false self.player.inputLocked = false end return @@ -1002,6 +1008,10 @@ function OverworldState:update(dt) self.player.spinFrames = nil self.player.spinRise = nil self.player.inputLocked = false + -- keep the sprite hidden through the warp fade-out (#916): the spin is + -- over but the arrival spin-drop is not armed until startWarpTo's + -- midpoint, so without this the standing trainer shows under the veil + self.playerHidden = true self:warpToHealPoint(onDone, { arrive = "teleport" }) return end @@ -4136,6 +4146,11 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts) self.arriveWarp = nil Game.stack:push(Transition.new(Game, function() self:setMap(mapId, x, y, facing or "down", opts) + -- the departure-side hide from flyAnim/teleportOut ends here, on the new + -- map; the arrival arms its own cover (flyArrive / spinDrop) a few lines + -- down, so the player is never drawable mid-fade nor standing bare on the + -- landing frame (#916) + self.playerHidden = false -- The warp we land ON stays inert for the completed-step check until we -- physically step off it, so a warp whose destination cell is itself a -- warp cannot bounce us straight back (elevator cars, stacked stair/door @@ -4860,7 +4875,8 @@ function OverworldState:drawWorld() g.npc:draw(cam.x - g.ox, cam.y - g.oy) end for _, e in ipairs(self.entities) do - if not ((self.flyAnim or self.flyArrive) and e == self.player) then + if not ((self.flyAnim or self.flyArrive or self.playerHidden) + and e == self.player) then e:draw(cam.x, cam.y) -- tall grass overdraws the sprite's feet (GB sprite priority); -- the overdraw is BG tiles, so it rides the shake offset too @@ -4911,7 +4927,8 @@ function OverworldState:drawWorld() items[#items + 1] = { y = g.npc.py + g.oy + 16, kind = "ghost", g = g } end for _, e in ipairs(self.entities) do - if not ((self.flyAnim or self.flyArrive) and e == self.player) then + if not ((self.flyAnim or self.flyArrive or self.playerHidden) + and e == self.player) then items[#items + 1] = { y = e.py + 16, kind = "entity", e = e } end end diff --git a/tests/engine/warp_sprite_hidden_bug916.lua b/tests/engine/warp_sprite_hidden_bug916.lua new file mode 100644 index 00000000..5e6cd885 --- /dev/null +++ b/tests/engine/warp_sprite_hidden_bug916.lua @@ -0,0 +1,151 @@ +-- Engine invariant (#916): after the Fly / Dig departure animation ends, the +-- trainer sprite must stay hidden through the warp fade-out and only become +-- visible again when the arrival animation (flyArrive / teleport spin-down) +-- plays on the new map. +-- +-- Root cause: the player-hide guard only held while a departure animation +-- was live. flyAnim was nil'd the instant the bird finished path2, and the +-- teleportOut countdown cleared the spin fields at 0, but startWarpTo's +-- 32-frame fade keeps the overworld drawing beneath the veil (the Transition +-- is not isOpaque), so with the departure guard gone and the arrival not yet +-- armed, the standing sprite popped back in at the old cell for the whole +-- fade. +-- +-- The fix is a playerHidden flag on OverworldState: set when the departure +-- completes (flyAnim path2 / teleportOut countdown), cleared in startWarpTo's +-- midpoint the same tick the arrival arms, and folded into both player-draw +-- guards. This suite runs the REAL Transition + setMap headlessly and +-- asserts there is no fade frame where the player would draw bare. +-- +-- ROM-free (fixture dataset, no ROM boot): lives in tests/engine so the CI +-- headless tier runs it; also runnable standalone via +-- `luajit tests/engine/warp_sprite_hidden_bug916.lua`. + +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local T = require("tests.modkit") +local check, eq = T.check, T.eq + +local Data = T.fixtures.fresh() +-- fixture patches that let the overworld boot and run headlessly +Data.tilesets.FIX_OUT.tilesPerRow = 16 +Data.field.flyWarps = Data.field.flyWarps or {} +Data.field.playerSprites = { walk = "SPRITE_FIX_PLAYER" } +Data.field.waterTilesets = {} +Data.field.forcedMovement = { tiles = {} } + +local Game = require("src.core.Game") +local Input = require("src.core.Input") +local StateStack = require("src.core.StateStack") +local Renderer = require("src.render.Renderer") +local SaveData = require("src.core.SaveData") +local Pokemon = require("src.pokemon.Pokemon") +local OW = require("src.world.OverworldController") + +Game.data = Data +Game.input = Input; Input:init() +Game.renderer = Renderer; Renderer:init() +Game.stack = StateStack; StateStack:init() +Game.save = SaveData.newGame() +Game.save.party = { Pokemon.new(Data, "FIXMON_A", 20) } +local stack = Game.stack + +-- The draw guard both entity passes use: the player sprite is skipped while +-- any of flyAnim / flyArrive / playerHidden is set. +local function playerHidden(ow) + return ow.flyAnim ~= nil or ow.flyArrive ~= nil or ow.playerHidden == true +end + +local function newOW() + stack:push(OW, "FIX_TOWN", 5, 6, "down") + local ow = stack:top() + Game.overworld = ow + return ow +end + +-- Drive `ow` until its departure + warp + arrival all complete, tracking the +-- fade window. Returns counters: fadeFrames / fadeFramesHidden (frames the +-- Transition was on top; of those, frames the player was hidden), gapFrames +-- (fade frames where NO arrival was active AND the player was NOT hidden -- +-- the regression this suite guards), arrivalFrame (first frame an arrival +-- animation armed), warpFrame (first frame a fade is up). +-- +-- Breaks once an arrival armed and then fully finished (no stale departure +-- or arrival animation, OW back on top); `maxFrames` is the safety net. +local function drive(ow, maxFrames) + local st = { fadeFrames = 0, fadeFramesHidden = 0, gapFrames = 0, + arrivalFrame = nil, warpFrame = nil } + for i = 1, maxFrames or 260 do + local fading = stack:top() ~= ow + stack:update() + if fading then + st.fadeFrames = st.fadeFrames + 1 + if playerHidden(ow) then st.fadeFramesHidden = st.fadeFramesHidden + 1 end + local arrivalActive = ow.flyArrive ~= nil or ow.player.spinDrop == true + if not arrivalActive and not playerHidden(ow) then + st.gapFrames = st.gapFrames + 1 + end + if st.warpFrame == nil then st.warpFrame = i end + end + if st.arrivalFrame == nil + and (ow.flyArrive ~= nil or ow.player.spinDrop == true) then + st.arrivalFrame = i + end + if st.arrivalFrame and stack:top() == ow + and ow.flyArrive == nil and ow.player.spinDrop ~= true + and not ow.player.inputLocked then + break -- departure + fade + arrival all finished + end + end + return st +end + +-- ------------------------------------------------------------------ dig/teleport +-- Departure spin (48) -> warp fade -> arrival spin-down. From the moment +-- the spin ends until the arrival arms, the sprite must never draw bare. +local ow = newOW() +local doneFired = false +ow:beginTeleportOut(function() + doneFired = true + ow.player.inputLocked = false -- the party-menu caller unlocks after the warp +end) +local st = drive(ow, 260) +check(st.warpFrame ~= nil, "dig departure ends and the warp fade begins") +check(st.fadeFrames > 0, "dig warp fade ran (" .. st.fadeFrames .. " frames)") +eq(st.gapFrames, 0, + "no dig fade frame leaves the player standing bare (#916)") +check(st.fadeFramesHidden >= st.fadeFrames - 1, + "dig fade hidden on every frame but the arrival-arming midpoint (" + .. st.fadeFramesHidden .. "/" .. st.fadeFrames .. ")") +check(st.arrivalFrame ~= nil, "dig arrival spin-down arms") +check(ow.playerHidden == false, "dig hide cleared on the new map") +check(doneFired, "dig onDone fires after the warp") +check(ow.player.spinDrop ~= true and ow.player.spinning == false, + "dig arrival spin-down completes") +check(not playerHidden(ow), "player drawable again after the dig landing") + +-- ------------------------------------------------------------------ fly +-- flap (24) + path1 (36) + hold (40) + path2 (33) = 133 frames of flyAnim, +-- then the fade, then the bird swoops in (flyArrive). Same invariant. +Data.field.flyWarps.FIX_ROUTE = { x = 4, y = 6 } +ow = newOW() +ow:flyTo("FIX_ROUTE") +st = drive(ow, 260) +check(st.warpFrame ~= nil, "fly departure ends and the warp fade begins") +-- flap (8*3) + path1 (12*3) + hold (40) + path2 (11*3) = 133 frames; the +-- warp fires on frame 133's update, so the fade is on top from loop frame 134 +eq(st.warpFrame, 134, "fly fade begins right after the bird''s exit path") +check(st.fadeFrames > 0, "fly warp fade ran (" .. st.fadeFrames .. " frames)") +eq(st.gapFrames, 0, + "no fly fade frame leaves the player standing bare (#916)") +check(st.fadeFramesHidden >= st.fadeFrames - 1, + "fly fade hidden on every frame but the arrival-arming midpoint (" + .. st.fadeFramesHidden .. "/" .. st.fadeFrames .. ")") +check(st.arrivalFrame ~= nil, "fly arrival swoop arms") +check(ow.playerHidden == false, "fly hide cleared on the new map") +check(ow.flyArrive == nil, "fly arrival swoop completes") +check(not ow.player.inputLocked, "fly landing releases player input") +check(not playerHidden(ow), "player drawable again after the fly landing") + +T.finish("warp_sprite_hidden_bug916") From 5bc7d203a50df6e39a2595c224eb9d051a428eb0 Mon Sep 17 00:00:00 2001 From: Juan Heredia Date: Fri, 7 Aug 2026 13:27:59 +0200 Subject: [PATCH 15/63] Route the last hardcoded UI labels through the Strings catalog Wrap the battle stat box, the PC quantity footer and the dex metric labels in Strings() (SummaryMenu -- and MoveEffects since #811 -- already do this); give the battle menu a 'battle' lookup context so a translation can shorten FIGHT/ITEM/RUN independently of the pause menu; align the metric dex rows with the imperial ones and make the No. prefix translatable; make the title menu's recolor zone follow Menu's auto-grown width; honor the declared-but-unread boot.title seam, drawing an explicit versionRibbon as one centered piece. With an empty catalog every path is pixel-identical to vanilla. --- src/battle/BattleState.lua | 12 +++++------ src/ui/DexEntryMenu.lua | 6 +++--- src/ui/PlayerPC.lua | 2 +- src/ui/TitleState.lua | 42 +++++++++++++++++++++++++++++++------- 4 files changed, 45 insertions(+), 17 deletions(-) diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index da79b2b7..52e98a91 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -424,7 +424,7 @@ function StatBox:draw() { Strings("SPEED"), s.speed }, { Strings("SPECIAL"), s.special } } for i, r in ipairs(rows) do - Font.draw(r[1], 88, 24 + (i - 1) * 16) + Font.draw(Strings(r[1]), 88, 24 + (i - 1) * 16) Font.draw(("%3d"):format(r[2]), 128, 32 + (i - 1) * 16) end love.graphics.setColor(1, 1, 1, 1) @@ -5587,9 +5587,9 @@ function BattleState:drawTextArea() -- -- next to FIGHT (9,14) for the first 80 frames, then ITEM (9,16) Font.drawBox(8, 12, 12, 6) love.graphics.setColor(0, 0, 0, 1) - Font.draw(Strings("FIGHT"), 80, 112) + Font.draw(Strings("FIGHT", "battle"), 80, 112) Font.drawCode(0xE1, 128, 112); Font.drawCode(0xE2, 136, 112) - Font.draw(Strings("ITEM"), 80, 128); Font.draw(Strings("RUN"), 128, 128) + Font.draw(Strings("ITEM", "battle"), 80, 128); Font.draw(Strings("RUN", "battle"), 128, 128) Font.drawCode(0xED, 72, (self.demoTimer or 0) <= 80 and 112 or 128) elseif self.phase == "menu" then local col = (self.menuIndex - 1) % 2 @@ -5599,7 +5599,7 @@ function BattleState:drawTextArea() -- THROW ROCK RUN" from (2,14) Font.drawBox(0, 12, 20, 6) Font.draw(Strings("BALLx"), 16, 112); Font.draw(Strings("BAIT"), 112, 112) - Font.draw(Strings("THROW ROCK"), 16, 128); Font.draw(Strings("RUN"), 112, 128) + Font.draw(Strings("THROW ROCK"), 16, 128); Font.draw(Strings("RUN", "battle"), 112, 128) -- DisplayBattleMenu .safariLeftColumn / .safariRightColumn print -- wNumSafariBalls at hlcoord 7,14 with `lb bc, 1, 2` -- one byte, two -- digits, space padded -- right after the "BALLx" label at columns @@ -5610,9 +5610,9 @@ function BattleState:drawTextArea() -- BATTLE_MENU_TEMPLATE: box (8,12)-(19,17), "FIGHT / -- ITEM RUN" from (10,14); cursor columns 9 / 15 Font.drawBox(8, 12, 12, 6) - Font.draw(Strings("FIGHT"), 80, 112) + Font.draw(Strings("FIGHT", "battle"), 80, 112) Font.drawCode(0xE1, 128, 112); Font.drawCode(0xE2, 136, 112) - Font.draw(Strings("ITEM"), 80, 128); Font.draw(Strings("RUN"), 128, 128) + Font.draw(Strings("ITEM", "battle"), 80, 128); Font.draw(Strings("RUN", "battle"), 128, 128) Font.drawCode(0xED, (col == 0 and 72 or 120), 112 + row * 16) end elseif self.phase == "moveSelect" then diff --git a/src/ui/DexEntryMenu.lua b/src/ui/DexEntryMenu.lua index b59e6ee1..f5f1a09d 100644 --- a/src/ui/DexEntryMenu.lua +++ b/src/ui/DexEntryMenu.lua @@ -94,7 +94,7 @@ function DexEntryMenu.render(game, def, sprite, forceOwned, trueColor) -- same number width as the list (constants.dexDigits), so a dex past 999 -- prints the extra digit everywhere at once local digits = (game.data.constants or {}).dexDigits or 3 - Font.draw(("No.%0" .. digits .. "d"):format(def.dex or 0), 72, 32) + Font.draw(Strings("No.") .. ("%0" .. digits .. "d"):format(def.dex or 0), 72, 32) local owned = forceOwned or (game.save.pokedex and game.save.pokedex.owned[def.id]) -- height/weight print only once owned, like the description @@ -105,8 +105,8 @@ function DexEntryMenu.render(game, def, sprite, forceOwned, trueColor) -- pokedex.asm; the tiles come from gfx/pokedex/pokedex.png via -- engine/gfx/load_pokedex_tiles.asm) if e.heightM then - Font.draw((("GR. %.1fm"):format(e.heightM):gsub("(%d)%.(%d)", "%1,%2")), 64, 44) - Font.draw((("GEW. %.1fkg"):format(e.weightKg or 0):gsub("(%d)%.(%d)", "%1,%2")), 64, 54) + Font.draw((Strings("GR. %.1fm", e.heightM):gsub("(%d)%.(%d)", "%1,%2")), 72, 44) + Font.draw((Strings("GEW. %.1fkg", e.weightKg or 0):gsub("(%d)%.(%d)", "%1,%2")), 72, 54) else Font.draw(Strings("HT %d′%02d″", e.heightFt, e.heightIn or 0), 72, 44) Font.draw(Strings("WT %.1flb", (e.weight or 0) / 10), 72, 54) diff --git a/src/ui/PlayerPC.lua b/src/ui/PlayerPC.lua index 5e31ca39..6fdb6b90 100644 --- a/src/ui/PlayerPC.lua +++ b/src/ui/PlayerPC.lua @@ -44,7 +44,7 @@ local function askQuantity(game, list, count, id, cb) cb(1) return end - list.footer = "How many?" + list.footer = Strings("How many?") local QuantityBox = require("src.ui.QuantityBox") game.stack:push(QuantityBox.new(game, { max = count, diff --git a/src/ui/TitleState.lua b/src/ui/TitleState.lua index f7d3ffe5..6ee89618 100644 --- a/src/ui/TitleState.lua +++ b/src/ui/TitleState.lua @@ -107,7 +107,12 @@ local CYCLE_FRAMES = 240 -- the original waits ~4s between picks local function tryImage(path) if not path then return nil end - local ok, img = pcall(love.graphics.newImage, path) + -- resolve through Assets so a mod's derived art (save/mod-derived/) + -- wins here the way it does for every other generated sheet -- but + -- load uncached, because on NX the per-version overlay redirects the + -- open itself and a cached image would leak across Yellow/Blue boots + local ok, img = pcall(love.graphics.newImage, + require("src.render.Assets").resolve(path)) return ok and img or nil end @@ -151,10 +156,24 @@ function TitleState.new(game, opts) -- branding comes from field.title with the shipped art as fallback, so -- a total conversion rebrands the title without replacing the screen local title = (game.data.field and game.data.field.title) or {} + -- field.title itself is extraction data the field schema never exposes; + -- boot.title is the mod-reachable half of the same seam, so its keys + -- override here (a localized ribbon, a rebranded logo) + local boot = game.data.field and game.data.field.boot + if boot and type(boot.title) == "table" then + local merged = {} + for key, value in pairs(title) do merged[key] = value end + for key, value in pairs(boot.title) do merged[key] = value end + title = merged + end self.title = title self.logo = tryImage(imagePath(title.logo) or "assets/logo/pokemon_logo.png") - -- versionRibbon is the file-12 key; version is the importer's + -- versionRibbon is the file-12 key; version is the importer's. The + -- vanilla sheet is two fragments the draw pass repositions, so an + -- explicit ribbon (a conversion's or a translation's continuous art) + -- draws whole instead. + self.versionFull = imagePath(title.versionRibbon) ~= nil self.version = tryImage(imagePath(title.versionRibbon or title.version) or "assets/generated/title/red_version.png") self.player = tryImage("assets/generated/title/player.png") @@ -347,8 +366,12 @@ function ContinueInfo:draw() -- box at (4,7), 8x14 content; labels double-spaced from (5,9) Font.drawBox(4, 7, 16, 10) love.graphics.setColor(0, 0, 0, 1) - Font.draw(Strings("PLAYER"), 40, 72) - Font.draw((save.player and save.player.name) or "RED", 96, 72) + -- the name follows the label's real width (one space after it), so a + -- localized label longer than PLAYER's six glyphs cannot run into it + local playerLabel = Strings("PLAYER") + Font.draw(playerLabel, 40, 72) + Font.draw((save.player and save.player.name) or "RED", + math.max(96, 40 + (#Font.split(playerLabel) + 1) * 8), 72) local badges = require("src.inventory.Badges").count(self.game.data, save) Font.draw(Strings("BADGES"), 40, 88) Font.draw(("%2d"):format(badges), 128, 88) @@ -401,8 +424,10 @@ function TitleState:openMenu() end local th = #items * 2 + 2 local menu = Menu.new(game, items, { tx = 0, ty = 0, tw = 13, th = th }) - -- full-width title LOGO zones would recolor this box; see sgbPalettes - menu.titleUiBox = { 0, 0, 12, th - 1 } + -- full-width title LOGO zones would recolor this box; see sgbPalettes. + -- Menu.new may have grown tw for longer (e.g. localized) labels, so the + -- recolor zone follows the box's real width instead of the vanilla 13. + menu.titleUiBox = { 0, 0, menu.tw - 1, th - 1 } game.stack:push(menu) end @@ -490,7 +515,10 @@ function TitleState:draw() -- the Yellow fallback layout draws no ribbon at all. if self.version and not self.yellow then local iw, ih = self.version:getDimensions() - if self.blue then + if self.versionFull then + -- a continuous ribbon (versionRibbon) centers as one piece + love.graphics.draw(self.version, math.floor((160 - iw) / 2), 64) + elseif self.blue then love.graphics.draw(self.version, love.graphics.newQuad(0, 0, 64, 8, iw, ih), 56, 64) else From 726ed1102e0aeced5534674b59bb6dd6d69fc773 Mon Sep 17 00:00:00 2001 From: MaxTomahawk Date: Fri, 7 Aug 2026 14:43:04 +0200 Subject: [PATCH 16/63] feat: add opaque playthrough identity --- src/core/Game.lua | 1 + src/core/SaveData.lua | 84 ++++++++++++++- tests/engine/playthrough_identity.lua | 141 ++++++++++++++++++++++++++ 3 files changed, 224 insertions(+), 2 deletions(-) create mode 100644 tests/engine/playthrough_identity.lua diff --git a/src/core/Game.lua b/src/core/Game.lua index 06856b91..b8b452d0 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -1079,6 +1079,7 @@ function Game:restoreSave(loaded, recovered) if ModRuntime.wants("save.loading") then ModRuntime.emit("save.loading", { raw = loaded }) end + SaveData.ensurePlaythroughId(loaded) -- mod chains replay before validation so a mod repairs its own data -- instead of watching it get quarantined; core steps already ran in -- SaveData.load and skip on the format guard diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index 882897bb..c12115fd 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -817,6 +817,74 @@ function SaveData.resetSlotState() for k in pairs(slotsChecked) do slotsChecked[k] = nil end end +-- ------- opaque playthrough identity + +-- An id must never perturb the engine's gameplay RNG: savestate tools need +-- repeatable random outcomes, and allocating persistence scope is not gameplay. +-- Combine wall/process time, a process-local sequence and a fresh table address +-- into four hex words. This is an opaque collision-resistant identifier, not a +-- secret or a player-visible value. +local playthroughSeq = 0 + +local function word(n) + return math.floor(tonumber(n) or 0) % 4294967296 +end + +function SaveData.newPlaythroughId() + playthroughSeq = playthroughSeq + 1 + local address = tostring({}):match("0x(%x+)") or "0" + local addressLo = tonumber(address:sub(-8), 16) or 0 + local clock = math.floor((os.clock() or 0) * 1000000) + return ("%08x%08x%08x%08x"):format( + word(os.time()), word(clock), word(addressLo), word(playthroughSeq)) +end + +local function playthroughScope(version) + version = version or GameVersion.get() + local fs = persistFs(nil) + ensureVersionSlots(version, fs) + return activeSlotCache[version] or "legacy" +end + +local function rememberPlaythroughId(save, opts) + local meta = type(save) == "table" and save.meta + local id = type(meta) == "table" and meta.playthroughId + if type(id) ~= "string" or id == "" then return opts, false end + local version = save.version or GameVersion.get() + local scope = playthroughScope(version) + opts = opts or SaveData.loadOptions() + opts.playthroughIds = opts.playthroughIds or {} + opts.playthroughIds[version] = opts.playthroughIds[version] or {} + local changed = opts.playthroughIds[version][scope] ~= id + opts.playthroughIds[version][scope] = id + return opts, changed +end + +-- Return an existing save identity or give a pre-identity save a stable one. +-- Legacy backfill lives in options.lua until the next normal SAVE stamps the id +-- into progress, so installing a tool mod never rewrites the player's checkpoint. +function SaveData.ensurePlaythroughId(save) + if type(save) ~= "table" then return nil end + save.meta = type(save.meta) == "table" and save.meta or {} + local id = save.meta.playthroughId + if type(id) == "string" and id ~= "" then return id end + + local version = save.version or GameVersion.get() + local scope = playthroughScope(version) + local opts = SaveData.loadOptions() + local byVersion = opts.playthroughIds and opts.playthroughIds[version] + id = byVersion and byVersion[scope] + if type(id) ~= "string" or id == "" then + id = SaveData.newPlaythroughId() + opts.playthroughIds = opts.playthroughIds or {} + opts.playthroughIds[version] = opts.playthroughIds[version] or {} + opts.playthroughIds[version][scope] = id + SaveData.saveOptions(opts) + end + save.meta.playthroughId = id + return id +end + -- ------- meta -- the version/engine/mod-set stamp every v2 save carries; mods is the @@ -838,6 +906,7 @@ function SaveData.buildMeta(mods, previous) format = Version.saveFormat, engine = Version.engine, savedAt = os.time(), + playthroughId = type(previous) == "table" and previous.playthroughId or nil, mods = list, } end @@ -1047,8 +1116,14 @@ function SaveData.save(data, mods) -- write to the file matching this save's own version, not just the active -- one, so Blue/Yellow playthroughs land in save_blue.lua / save_yellow.lua local FILENAME, BACKUP_FILENAME, TMP_FILENAME = saveNames(data.version) + SaveData.ensurePlaythroughId(data) if data.options then - SaveData.saveOptions(data.options) + local opts = rememberPlaythroughId(data, data.options) + data.options = opts + SaveData.saveOptions(opts) + else + local opts, changed = rememberPlaythroughId(data) + if changed then SaveData.saveOptions(opts) end end if mods ~= nil or data.meta == nil then data.meta = SaveData.buildMeta(mods, data.meta) @@ -1117,6 +1192,7 @@ function SaveData.load(version) return nil end SaveData.runMigrations(data) + SaveData.ensurePlaythroughId(data) data.options = SaveData.loadOptions() Logger.info("loaded save") return data, recovered @@ -1427,7 +1503,11 @@ function SaveData.newGame(boot) local x, y = boot.startX or 3, boot.startY or 6 local heal = SaveData.defaultHeal(boot) local save = { - meta = { format = Version.saveFormat, mods = {} }, + meta = { + format = Version.saveFormat, + mods = {}, + playthroughId = SaveData.newPlaythroughId(), + }, -- which game this playthrough is (Red vs Blue). Only Red ships today; -- boot carries the choice once Blue support lands. version = boot.version or "red", diff --git a/tests/engine/playthrough_identity.lua b/tests/engine/playthrough_identity.lua new file mode 100644 index 00000000..291433a1 --- /dev/null +++ b/tests/engine/playthrough_identity.lua @@ -0,0 +1,141 @@ +-- Opaque playthrough identity: New Game uniqueness, save/load persistence, +-- stable legacy backfill, and version/slot isolation. No real save directory. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +love = love or require("tests.love_stub") + +local SaveData = require("src.core.SaveData") +local SaveSerializer = require("src.core.SaveSerializer") +local GameVersion = require("src.core.GameVersion") + +local realFS = love.filesystem + +local function memfs(files) + return { + 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, + createDirectory = function() 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, + getDirectoryItems = function(path) + local prefix, seen, out = path .. "/", {}, {} + for key in pairs(files) do + if key:sub(1, #prefix) == prefix then + local child = key:sub(#prefix + 1):match("^[^/]+") + if child and not seen[child] then + seen[child] = true + out[#out + 1] = child + end + end + end + table.sort(out) + return out + end, + } +end + +local function fresh() + local files = {} + love.filesystem = memfs(files) + SaveData.resetSlotState() + GameVersion.set("red") + return files +end + +local function legacy(version, name) + return { + version = version, + meta = { format = 4, mods = {} }, + player = { name = name, map = "PALLET_TOWN", x = 5, y = 6 }, + flags = {}, inventory = {}, pcItems = {}, party = {}, box = {}, boxes = {}, + money = 3000, defeatedTrainers = {}, pokedex = { seen = {}, owned = {} }, + } +end + +-- Removing playthroughId generation from New Game must fail these assertions. +do + fresh() + local first = SaveData.newGame({ version = "red" }) + local second = SaveData.newGame({ version = "red" }) + T.check(type(first.meta.playthroughId) == "string" + and first.meta.playthroughId ~= "", + "New Game receives an opaque playthrough id") + T.neq(second.meta.playthroughId, first.meta.playthroughId, + "separate New Games receive separate playthrough ids") +end + +-- Dropping the id from buildMeta or save encoding must fail the roundtrip. +do + fresh() + local save = SaveData.newGame({ version = "red" }) + local expected = save.meta.playthroughId + T.check(SaveData.save(save), "identity fixture saves") + local loaded = SaveData.load("red") + T.eq(loaded and loaded.meta.playthroughId, expected, + "normal save/load preserves the playthrough id") +end + +-- Legacy identity is persisted independently: the legacy progress bytes remain +-- unchanged, yet two loads resolve the same id before a normal SAVE occurs. +do + local files = fresh() + local raw = legacy("red", "LEGACY") + files["save.lua"] = SaveSerializer.encode(raw) + + local first = SaveData.load("red") + local id = first and first.meta.playthroughId + T.check(type(id) == "string" and id ~= "", + "a legacy save receives a playthrough id") + + local slotBytes = files["saves/red/slot1.lua"] + local onDisk = slotBytes and SaveSerializer.decode(slotBytes) + T.eq(onDisk and onDisk.meta.playthroughId, nil, + "legacy backfill does not rewrite normal progress") + + SaveData.resetSlotState() + local second = SaveData.load("red") + T.eq(second and second.meta.playthroughId, id, + "legacy backfill is stable across reload before normal SAVE") +end + +-- Reusing names and coordinates cannot merge identities across slots or games. +do + fresh() + local redA = SaveData.createSlot("red") + local redB = SaveData.createSlot("red") + SaveData.setActiveSlot("red", redA) + T.check(SaveData.writeSlot("red", redA, legacy("red", "SAME")), + "seed red slot A") + local idA = SaveData.load("red").meta.playthroughId + + SaveData.setActiveSlot("red", redB) + T.check(SaveData.writeSlot("red", redB, legacy("red", "SAME")), + "seed red slot B") + local idB = SaveData.load("red").meta.playthroughId + + GameVersion.set("blue") + local blue = SaveData.createSlot("blue") + SaveData.setActiveSlot("blue", blue) + T.check(SaveData.writeSlot("blue", blue, legacy("blue", "SAME")), + "seed blue slot") + local idBlue = SaveData.load("blue").meta.playthroughId + + T.neq(idA, idB, "two active slots do not share legacy identity") + T.neq(idA, idBlue, "Red and Blue do not share legacy identity") + T.neq(idB, idBlue, "every version/slot scope is isolated") +end + +love.filesystem = realFS +SaveData.resetSlotState() +GameVersion.set("red") + +T.finish("playthrough_identity") From 0399ad040ff0e1a54348ac00879af56cdb43f6fa Mon Sep 17 00:00:00 2001 From: MaxTomahawk Date: Fri, 7 Aug 2026 14:43:04 +0200 Subject: [PATCH 17/63] feat: add playthrough-scoped mod storage --- src/core/SaveData.lua | 7 ++ src/mods/Loader.lua | 12 +++ src/mods/Storage.lua | 208 ++++++++++++++++++++++++++++++++++++ tests/mod_storage_tests.lua | 177 ++++++++++++++++++++++++++++++ 4 files changed, 404 insertions(+) create mode 100644 src/mods/Storage.lua create mode 100644 tests/mod_storage_tests.lua diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index c12115fd..f68a10b4 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -216,6 +216,13 @@ local function persistFs(fs) return SaveData.portableFs() or fs or (love and love.filesystem) end +-- Engine-owned persistence routing for subsystems that must follow the same +-- standard/portable root as saves without exposing raw filesystem access to a +-- mod. An explicitly injected headless filesystem still wins for tests. +function SaveData.persistenceFs(fs) + return persistFs(fs) +end + -- Port + original Options menu defaults. Missing keys on load are filled -- from this table so old options.lua files stay compatible. function SaveData.defaultOptions() diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index aa6b803f..0ce9c314 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -569,6 +569,8 @@ end function Loader:_api(mod) local loader = self local modId = mod.manifest.id + local Storage = engineRequire("src.mods.Storage") + local storage = Storage and Storage.new(modId, loader.fs) local api = { id = modId, version = mod.manifest.version, @@ -658,6 +660,16 @@ function Loader:_api(mod) bucket[key] = value end, }, + -- Data-only state independent of the vanilla progress checkpoint. The + -- engine binds version/playthrough/mod scope and portable persistence; + -- callers never receive paths or a raw filesystem handle. + storage = { + context = function(_, game) return storage:context(game) end, + write = function(_, game, key, value) return storage:write(game, key, value) end, + read = function(_, game, key) return storage:read(game, key) end, + list = function(_, game, prefix) return storage:list(game, prefix) end, + delete = function(_, game, key) return storage:delete(game, key) end, + }, options = { define = function(_, schema) assert(type(schema) == "table", "options schema must be a table of rows") diff --git a/src/mods/Storage.lua b/src/mods/Storage.lua new file mode 100644 index 00000000..9b80c360 --- /dev/null +++ b/src/mods/Storage.lua @@ -0,0 +1,208 @@ +-- Data-only per-mod persistence, scoped by game version and opaque playthrough. +-- This module is engine-private; Loader exposes only the bound facade methods. + +local SaveData = require("src.core.SaveData") +local SaveSerializer = require("src.core.SaveSerializer") + +local Storage = {} +Storage.__index = Storage + +local ROOT = "mod_storage" + +local function failure(code, message) + return nil, code, message +end + +local function validSegment(value) + return type(value) == "string" and value ~= "" + and value:match("^[%w_-]+$") ~= nil +end + +local function validKey(key, allowEmpty) + if type(key) ~= "string" or (key == "" and not allowEmpty) then return false end + if key == "" then return true end + if key:sub(1, 1) == "/" or key:sub(-1) == "/" or key:find("//", 1, true) then + return false + end + for segment in key:gmatch("[^/]+") do + if not validSegment(segment) then return false end + end + return true +end + +local function ensureParent(fs, path) + local dir = path:match("^(.*)/[^/]+$") + if dir and fs.createDirectory then fs.createDirectory(dir) end +end + +local function remove(fs, path) + if fs.remove then fs.remove(path) end +end + +local function decodeAt(fs, path) + if not (fs.getInfo and fs.getInfo(path)) then return nil end + local body = fs.read and fs.read(path) + if type(body) ~= "string" then return nil end + local data = SaveSerializer.decode(body) + if not data then return nil end + return data, body +end + +function Storage.new(modId, fs) + assert(validSegment(modId), "Storage.new needs a safe mod id") + return setmetatable({ modId = modId, injectedFs = fs }, Storage) +end + +function Storage:_scope(game) + local save = game and game.save + local meta = save and save.meta + local version = save and save.version + local playthroughId = meta and meta.playthroughId + if not (save and validSegment(version) and validSegment(playthroughId)) then + return failure("not_in_playthrough", + "Storage is available only inside an identified playthrough.") + end + local fs = SaveData.persistenceFs(self.injectedFs) + if not (fs and fs.read and fs.write and fs.getInfo) then + return failure("storage_unavailable", "The persistence backend is unavailable.") + end + local base = table.concat({ ROOT, version, playthroughId, self.modId }, "/") + return { gameVersion = version, playthroughId = playthroughId, + base = base, fs = fs } +end + +function Storage:context(game) + local scope, code, message = self:_scope(game) + if not scope then return nil, code, message end + return { gameVersion = scope.gameVersion, playthroughId = scope.playthroughId } +end + +function Storage:_names(game, key, allowEmpty) + if not validKey(key, allowEmpty) then + return failure("invalid_key", + "Storage keys use nonempty letters, numbers, underscore, dash and slash segments.") + end + local scope, code, message = self:_scope(game) + if not scope then return nil, code, message end + local path = scope.base .. (key ~= "" and ("/" .. key) or "") + return scope, path .. ".lua", path .. ".lua.bak", path .. ".lua.tmp" +end + +function Storage:write(game, key, value) + local scope, main, bak, tmp = self:_names(game, key, false) + if not scope then return false, main, bak end + if type(value) ~= "table" then + return false, "encode_failed", "Storage values must be data-only tables." + end + local encodedOk, encoded = pcall(SaveSerializer.encode, value) + if not encodedOk then + return false, "encode_failed", "Storage value is not serializable data: " + .. tostring(encoded) + end + + local fs = scope.fs + ensureParent(fs, main) + local _, previous = decodeAt(fs, main) + if not previous then _, previous = decodeAt(fs, bak) end + + local ok, err = fs.write(tmp, encoded) + if not ok then + return false, "write_failed", "Could not stage storage data: " .. tostring(err) + end + local staged = decodeAt(fs, tmp) + if not staged then + remove(fs, tmp) + return false, "verify_failed", "Staged storage data could not be verified." + end + + if previous then fs.write(bak, previous) end + ok, err = fs.write(main, encoded) + if not ok then + remove(fs, tmp) + return false, "write_failed", "Could not replace storage data: " .. tostring(err) + end + local verified = decodeAt(fs, main) + if not verified then + remove(fs, main) + remove(fs, tmp) + return false, "verify_failed", "Replacement storage data could not be verified." + end + + -- At rest both main and backup hold the newest verified record. If a later + -- write dies after rolling this copy aside, one verified generation remains. + fs.write(bak, encoded) + remove(fs, tmp) + return true +end + +function Storage:read(game, key) + local scope, main, bak, tmp = self:_names(game, key, false) + if not scope then return nil, main, bak end + local fs = scope.fs + local data, body = decodeAt(fs, main) + if data then return data end + + data, body = decodeAt(fs, tmp) + if not data then data, body = decodeAt(fs, bak) end + if not data then + return nil, "not_found", "No valid stored value exists for this key." + end + + -- Best-effort healing. The recovered copy remains in tmp/bak if promotion + -- cannot land, so returning it is still safe and the next read can retry. + ensureParent(fs, main) + if fs.write(main, body) then fs.write(bak, body) end + remove(fs, tmp) + return data +end + +function Storage:list(game, prefix) + prefix = prefix or "" + local scope, main, codeOrBak = self:_names(game, prefix, true) + if not scope then return nil, main, codeOrBak end + local fs = scope.fs + if not fs.getDirectoryItems then + return nil, "storage_unavailable", "The persistence backend cannot enumerate keys." + end + + local base = scope.base + local start = prefix == "" and base or (base .. "/" .. prefix) + local out = {} + + local function walk(path, logical) + local info = fs.getInfo(path) + if not info then return end + if info.type == "file" then + if path:sub(-4) == ".lua" then out[#out + 1] = logical:sub(1, -5) end + return + end + for _, child in ipairs(fs.getDirectoryItems(path) or {}) do + local childLogical = logical == "" and child or (logical .. "/" .. child) + walk(path .. "/" .. child, childLogical) + end + end + + -- A prefix may identify one exact key or a directory of keys. + if fs.getInfo(start .. ".lua") then + out[#out + 1] = prefix + else + walk(start, prefix) + end + table.sort(out) + return out +end + +function Storage:delete(game, key) + local scope, main, bak, tmp = self:_names(game, key, false) + if not scope then return false, main, bak end + local fs = scope.fs + if not (fs.getInfo(main) or fs.getInfo(bak) or fs.getInfo(tmp)) then + return false, "not_found", "No stored value exists for this key." + end + remove(fs, main) + remove(fs, bak) + remove(fs, tmp) + return true +end + +return Storage diff --git a/tests/mod_storage_tests.lua b/tests/mod_storage_tests.lua new file mode 100644 index 00000000..52b0f886 --- /dev/null +++ b/tests/mod_storage_tests.lua @@ -0,0 +1,177 @@ +-- Public mod.storage contract: data-only transactions, namespace isolation, +-- deterministic listing, recovery, and failure retention. + +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") + +local T = require("tests.harness").suite("mod storage") +local Loader = require("src.mods.Loader") +local Runtime = require("src.mods.Runtime") + +local savedEvents, savedHooks = Runtime.events, Runtime.hooks + +local function manifest(id) + return ('{"id":"%s","name":"%s","version":"1.0.0",') + :format(id, id) .. '"entry":"main.lua","api":2,"profile":"content"}' +end + +local function memfs(files) + local fs = { files = files, failTmp = false, failMain = false } + + function fs.read(path) return files[path] end + function fs.write(path, body) + if fs.failTmp and path:sub(-4) == ".tmp" then return false, "tmp denied" end + if fs.failMain and path:sub(-4) == ".lua" then return false, "main denied" end + files[path] = body + return true + end + function fs.remove(path) files[path] = nil return true end + function fs.createDirectory() return true end + function fs.getInfo(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 + function fs.load(path) + if not files[path] then return nil, "no file: " .. path end + return load(files[path], path) + end + function fs.getDirectoryItems(path) + local prefix, seen, out = path .. "/", {}, {} + for key in pairs(files) do + if key:sub(1, #prefix) == prefix then + local child = key:sub(#prefix + 1):match("^[^/]+") + if child and not seen[child] then + seen[child] = true + out[#out + 1] = child + end + end + end + table.sort(out) + return out + end + return fs +end + +local function game(version, playthroughId) + return { save = { + version = version, + meta = { format = 4, mods = {}, playthroughId = playthroughId }, + } } +end + +local files = { + ["mods/alpha/manifest.json"] = manifest("alpha"), + ["mods/alpha/main.lua"] = [[ +return function(mod) _G.MOD_STORAGE_ALPHA = mod.storage end +]], + ["mods/beta/manifest.json"] = manifest("beta"), + ["mods/beta/main.lua"] = [[ +return function(mod) _G.MOD_STORAGE_BETA = mod.storage end +]], +} +local fs = memfs(files) +local loader = Loader.new({ fs = fs }) +local current = game("red", "play-a") +loader.game = current +T.check(loader:load({}) == true, "storage fixture mods load") + +local alpha, beta = _G.MOD_STORAGE_ALPHA, _G.MOD_STORAGE_BETA +T.check(type(alpha) == "table" and type(beta) == "table", + "Loader exposes mod.storage through the public mod object") +if type(alpha) ~= "table" or type(beta) ~= "table" then + Runtime.events, Runtime.hooks = savedEvents, savedHooks + _G.MOD_STORAGE_ALPHA, _G.MOD_STORAGE_BETA = nil, nil + T.finish() +end + +-- Removing scope identity or exposing a mutable private slot id breaks this. +local context = alpha:context(current) +T.same(context, { gameVersion = "red", playthroughId = "play-a" }, + "context exposes stable game/playthrough identity only") + +-- Data-only write/read. The literal expected table is independent of storage. +local payload = { format = 1, nested = { money = 1234 }, flags = { a = true } } +local ok, code, message = alpha:write(current, "states/quick/q1", payload) +T.check(ok == true, "data-only payload writes: " .. tostring(code or message)) +local loaded = alpha:read(current, "states/quick/q1") +T.same(loaded, payload, "stored payload roundtrips as data") +T.check(loaded ~= payload and loaded.nested ~= payload.nested, + "read returns decoded data rather than the caller's live table") + +local bad, badCode = alpha:write(current, "states/bad", { callback = function() end }) +T.check(not bad and badCode == "encode_failed", + "functions are rejected with a stable data-only error") + +local escaped, escapedCode = alpha:write(current, "../escape", {}) +T.check(not escaped and escapedCode == "invalid_key", + "path traversal is rejected before persistence") + +-- Logical enumeration is deterministic and prefix-scoped. +T.check(alpha:write(current, "states/quick/zeta", { n = 2 }), "write zeta") +T.check(alpha:write(current, "states/quick/alpha", { n = 1 }), "write alpha") +T.check(alpha:write(current, "settings", { enabled = true }), "write settings") +local keys = alpha:list(current, "states/quick") +T.same(keys, { "states/quick/alpha", "states/quick/q1", "states/quick/zeta" }, + "list returns sorted logical keys under the requested prefix") + +-- Mod, playthrough, and game namespaces cannot observe each other. +local missing, missingCode = beta:read(current, "states/quick/q1") +T.check(missing == nil and missingCode == "not_found", + "another mod cannot read the first mod's payload") +missing, missingCode = alpha:read(game("red", "play-b"), "states/quick/q1") +T.check(missing == nil and missingCode == "not_found", + "another playthrough cannot read the payload") +missing, missingCode = alpha:read(game("blue", "play-a"), "states/quick/q1") +T.check(missing == nil and missingCode == "not_found", + "another game version cannot read the payload") + +-- Find the implementation-owned file only to inject corruption; assertions stay +-- on public read behavior, not the path shape. +local function mainFor(fragment) + for path in pairs(files) do + if path:find(fragment, 1, true) and path:sub(-4) == ".lua" then return path end + end +end + +local q1Main = mainFor("q1") +T.check(type(q1Main) == "string", "failure fixture locates the persisted q1") +files[q1Main] = "not a serialized table" +loaded, code = alpha:read(current, "states/quick/q1") +T.same(loaded, payload, "corrupt main recovers the last verified payload") +T.eq(code, nil, "successful recovery is a normal read") + +-- A failed replacement cannot destroy the prior verified value. +T.check(alpha:write(current, "replace", { version = 1 }), "seed replace value") +fs.failTmp = true +ok, code = alpha:write(current, "replace", { version = 2 }) +fs.failTmp = false +T.check(not ok and code == "write_failed", "staging failure is reported") +T.same(alpha:read(current, "replace"), { version = 1 }, + "staging failure leaves the prior value readable") + +-- Delete is exact and idempotent-not-found is explicit. +T.check(alpha:write(current, "delete/me", { yes = true }), "seed delete target") +T.check(alpha:write(current, "delete/keep", { yes = true }), "seed delete neighbor") +T.check(alpha:delete(current, "delete/me") == true, "delete removes its target") +missing, missingCode = alpha:read(current, "delete/me") +T.check(missing == nil and missingCode == "not_found", "deleted key is unavailable") +T.same(alpha:read(current, "delete/keep"), { yes = true }, + "delete leaves neighboring keys untouched") + +-- No-mod parity: constructing/loading an empty loader creates no storage bytes. +local emptyFiles, emptyFs = {}, nil +emptyFs = memfs(emptyFiles) +local emptyLoader = Loader.new({ fs = emptyFs }) +emptyLoader.game = current +T.check(emptyLoader:load({}) == true, "no-mod loader still boots") +T.eq(next(emptyFiles), nil, "no-mod boot creates no storage paths or files") + +Runtime.events, Runtime.hooks = savedEvents, savedHooks +Runtime.currentMod = nil +_G.MOD_STORAGE_ALPHA, _G.MOD_STORAGE_BETA = nil, nil + +T.finish() From 6e94625f2a79b652b35d4225b13c78bc9f6dd2ba Mon Sep 17 00:00:00 2001 From: MaxTomahawk Date: Fri, 7 Aug 2026 14:49:45 +0200 Subject: [PATCH 18/63] feat: expose stable overworld checkpoints to mods --- src/core/Checkpoint.lua | 243 +++++++++++++++ src/core/Game.lua | 13 + src/mods/Loader.lua | 10 + src/world/OverworldController.lua | 34 ++- tests/modkit/cases/checkpoints.lua | 283 ++++++++++++++++++ .../cases/storage.lua} | 0 6 files changed, 568 insertions(+), 15 deletions(-) create mode 100644 src/core/Checkpoint.lua create mode 100644 tests/modkit/cases/checkpoints.lua rename tests/{mod_storage_tests.lua => modkit/cases/storage.lua} (100%) diff --git a/src/core/Checkpoint.lua b/src/core/Checkpoint.lua new file mode 100644 index 00000000..b29caba8 --- /dev/null +++ b/src/core/Checkpoint.lua @@ -0,0 +1,243 @@ +-- Public runtime checkpoint implementation. Loader exposes bound forwarding +-- methods; mods never receive controller or state-stack internals from here. + +local SaveSerializer = require("src.core.SaveSerializer") + +local Checkpoint = {} + +Checkpoint.FORMAT = 1 + +local function refusal(kind, reason, message) + return { + canCapture = false, + canRestore = false, + kind = kind or "unknown", + reason = reason, + message = message, + } +end + +local function running(runner) + return runner and runner.isRunning and runner:isRunning() +end + +local function nonempty(value) + return type(value) == "table" and next(value) ~= nil +end + +function Checkpoint.inspect(game) + local save = game and game.save + local identity = save and save.meta and save.meta.playthroughId + if type(save) ~= "table" or type(save.version) ~= "string" + or type(identity) ~= "string" or identity == "" then + return refusal("unknown", "not_in_playthrough", + "A checkpoint requires an identified active playthrough.") + end + + local ow = game.overworld + if type(ow) ~= "table" or type(ow.map) ~= "table" + or type(ow.map.id) ~= "string" or type(ow.player) ~= "table" then + return refusal("unknown", "not_overworld", + "Only a settled overworld can be checkpointed.") + end + local top = game.stack and game.stack.top and game.stack:top() + if top ~= ow then + return refusal("overworld", "screen_busy", + "Close the active menu or screen before creating a checkpoint.") + end + if ow.transitioning then + return refusal("overworld", "transition_busy", + "Wait for the map transition to finish.") + end + if running(ow.runner) or nonempty(ow.parallelRunners) + or nonempty(ow.pendingScripts) or nonempty(ow.parallelQueue) + or nonempty(ow.scriptMoves) then + return refusal("overworld", "script_busy", + "Wait for the active or queued script to finish.") + end + + local animationFields = { + "engaging", "emote", "teleportOut", "dustAnim", "cutAnim", "fishPose", + "pikaHop", "healAnim", "flyAnim", "flyArrive", + } + for _, field in ipairs(animationFields) do + if ow[field] then + return refusal("overworld", "animation_busy", + "Wait for the overworld animation to finish.") + end + end + if ow.player.moving or ow.player.targetX ~= nil or ow.player.targetY ~= nil then + return refusal("overworld", "movement_busy", + "Wait for movement to settle on a tile.") + end + return { canCapture = true, canRestore = true, kind = "overworld" } +end + +local function dataCopy(value) + local ok, encoded = pcall(SaveSerializer.encode, value) + if not ok then return nil, tostring(encoded) end + local decoded, err = SaveSerializer.decode(encoded) + if not decoded then return nil, err end + return decoded +end + +function Checkpoint.capture(game) + local capability = Checkpoint.inspect(game) + if not capability.canCapture then + return nil, capability.reason, capability.message + end + + local progress = {} + for key, value in pairs(game.save) do + if key ~= "options" then progress[key] = value end + end + progress = dataCopy(progress) + if not progress then + return nil, "capture_failed", "Progress contains non-serializable runtime data." + end + + local ok, err = pcall(game.overworld.captureSave, game.overworld, progress) + if not ok then + return nil, "capture_failed", "Could not synchronize overworld progress: " + .. tostring(err) + end + progress, err = dataCopy(progress) + if not progress then + return nil, "capture_failed", "Synchronized progress is not data-only: " + .. tostring(err) + end + + local player = game.overworld.player + return { + format = Checkpoint.FORMAT, + kind = "overworld", + identity = { + gameVersion = game.save.version, + playthroughId = game.save.meta.playthroughId, + }, + save = progress, + runtime = { overworld = { + map = game.overworld.map.id, + x = player.cellX, + y = player.cellY, + facing = player.facing, + surfing = player.surfing and true or false, + } }, + } +end + +local FACINGS = { up = true, down = true, left = true, right = true } + +local function validate(game, checkpoint) + if type(checkpoint) ~= "table" then + return nil, "invalid_checkpoint", "Checkpoint root must be a table." + end + if checkpoint.format ~= Checkpoint.FORMAT then + return nil, "unsupported_format", "This checkpoint format is not supported." + end + if checkpoint.kind ~= "overworld" then + return nil, "unsupported_runtime_kind", "Only overworld checkpoints are supported." + end + + local copy, copyErr = dataCopy(checkpoint) + if not copy then + return nil, "invalid_checkpoint", "Checkpoint is not data-only: " + .. tostring(copyErr) + end + local identity = copy.identity + local current = game and game.save + local currentId = current and current.meta and current.meta.playthroughId + if type(identity) ~= "table" or type(identity.gameVersion) ~= "string" + or type(identity.playthroughId) ~= "string" then + return nil, "invalid_checkpoint", "Checkpoint identity is missing or corrupt." + end + if identity.gameVersion ~= current.version then + return nil, "wrong_game", "Checkpoint belongs to another game version." + end + if identity.playthroughId ~= currentId then + return nil, "wrong_playthrough", "Checkpoint belongs to another playthrough." + end + + local save = copy.save + local runtime = copy.runtime and copy.runtime.overworld + if type(save) ~= "table" or type(save.player) ~= "table" + or type(runtime) ~= "table" then + return nil, "invalid_checkpoint", "Checkpoint progress or runtime data is missing." + end + if save.version ~= identity.gameVersion + or not save.meta or save.meta.playthroughId ~= identity.playthroughId then + return nil, "invalid_checkpoint", "Checkpoint progress identity is inconsistent." + end + if type(runtime.map) ~= "string" or type(runtime.x) ~= "number" + or type(runtime.y) ~= "number" or runtime.x % 1 ~= 0 or runtime.y % 1 ~= 0 + or not FACINGS[runtime.facing] or type(runtime.surfing) ~= "boolean" then + return nil, "invalid_checkpoint", "Overworld position is missing or corrupt." + end + if save.player.map ~= runtime.map or save.player.x ~= runtime.x + or save.player.y ~= runtime.y or save.player.facing ~= runtime.facing + or (save.player.surfing and true or false) ~= runtime.surfing then + return nil, "invalid_checkpoint", "Progress and runtime position disagree." + end + + local map = game.data and game.data.maps and game.data.maps[runtime.map] + if type(map) ~= "table" then + return nil, "invalid_map", "Checkpoint references a map that is unavailable." + end + local width, height = tonumber(map.width), tonumber(map.height) + if not width or not height or runtime.x < 0 or runtime.y < 0 + or runtime.x >= width * 2 or runtime.y >= height * 2 then + return nil, "invalid_position", "Checkpoint position is outside the map." + end + return copy +end + +local function apply(game, checkpoint, options) + local save, err = dataCopy(checkpoint.save) + if not save then error("checkpoint progress decode failed: " .. tostring(err), 0) end + local runtime = checkpoint.runtime.overworld + save.options = options + save.player.map = runtime.map + save.player.x = runtime.x + save.player.y = runtime.y + save.player.facing = runtime.facing + save.player.surfing = runtime.surfing + if type(game.restoreCheckpointSave) ~= "function" then + error("game has no checkpoint reconstruction path", 0) + end + game:restoreCheckpointSave(save) +end + +local function equalData(a, b) + local okA, encodedA = pcall(SaveSerializer.encode, a) + local okB, encodedB = pcall(SaveSerializer.encode, b) + return okA and okB and encodedA == encodedB +end + +function Checkpoint.restore(game, checkpoint) + local capability = Checkpoint.inspect(game) + if not capability.canRestore then + return false, capability.reason, capability.message + end + local validated, code, message = validate(game, checkpoint) + if not validated then return false, code, message end + + local rollback, captureCode, captureMessage = Checkpoint.capture(game) + if not rollback then return false, captureCode, captureMessage end + local options = game.save.options + + local ok, err = pcall(apply, game, validated, options) + if ok then + local restored, verifyCode = Checkpoint.capture(game) + if restored and equalData(restored, validated) then return true end + err = "restored state did not match checkpoint: " .. tostring(verifyCode) + end + + local rolledBack, rollbackErr = pcall(apply, game, rollback, options) + if not rolledBack then + return false, "rollback_failed", + "Checkpoint restore and rollback both failed: " .. tostring(rollbackErr) + end + return false, "restore_failed", "Checkpoint restoration failed: " .. tostring(err) +end + +return Checkpoint diff --git a/src/core/Game.lua b/src/core/Game.lua index b8b452d0..527f4fc5 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -1131,4 +1131,17 @@ function Game:restoreSave(loaded, recovered) end end +-- Reconstruct a previously validated runtime checkpoint without replaying the +-- ordinary CONTINUE lifecycle. In particular, map onEnter scripts and +-- save.loading/save.loaded events must not run a second time. Validation, +-- identity checks and transactional rollback live in Checkpoint.lua. +function Game:restoreCheckpointSave(loaded) + self.save = loaded + self:adoptSave(loaded) + while self.stack:top() do self.stack:pop() end + self.stack:push(self.overworld, loaded.player.map, + loaded.player.x, loaded.player.y, loaded.player.facing, + { via = "checkpoint", checkpoint = true }) +end + return Game diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 0ce9c314..daca6548 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -571,6 +571,7 @@ function Loader:_api(mod) local modId = mod.manifest.id local Storage = engineRequire("src.mods.Storage") local storage = Storage and Storage.new(modId, loader.fs) + local Checkpoint = engineRequire("src.core.Checkpoint") local api = { id = modId, version = mod.manifest.version, @@ -670,6 +671,15 @@ function Loader:_api(mod) list = function(_, game, prefix) return storage:list(game, prefix) end, delete = function(_, game, key) return storage:delete(game, key) end, }, + -- Runtime safety and reconstruction stay engine-owned. Checkpoints contain + -- data only; no controller, stack, coroutine or renderer object crosses out. + checkpoints = { + inspect = function(_, game) return Checkpoint.inspect(game) end, + capture = function(_, game) return Checkpoint.capture(game) end, + restore = function(_, game, checkpoint) + return Checkpoint.restore(game, checkpoint) + end, + }, options = { define = function(_, schema) assert(type(schema) == "table", "options schema must be a table of rows") diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index d6fcdb56..5a7bd65b 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -206,7 +206,7 @@ function OverworldState.computeNeighbors(maps, rootId, hops, reachW, reachH) return out end -function OverworldState:enter(mapId, x, y, facing) +function OverworldState:enter(mapId, x, y, facing, opts) Game = require("src.core.Game") Game.overworld = self Collision.load(Game.data) -- tile-pair (elevation) collisions @@ -227,7 +227,7 @@ function OverworldState:enter(mapId, x, y, facing) -- survives save/load: a loaded game may start inside a building whose -- exit mat is a LAST_MAP warp self.lastOutdoor = Game.save.lastOutdoor - self:setMap(mapId, x, y, facing, { via = "boot" }) + self:setMap(mapId, x, y, facing, opts or { via = "boot" }) -- boot/load: derive the flag from the tile the save left us standing on, -- like MapEntryAfterBattle's IsPlayerStandingOnWarp, so a game saved on a -- door mat can still walk straight back out (issue #378) @@ -282,7 +282,7 @@ end function OverworldState:setMap(mapId, x, y, facing, opts) local fromMapId = self.map and self.map.id - if fromMapId then + if fromMapId and not (opts and opts.checkpoint) then Runtime.emit("map.exited", { mapId = fromMapId, toMapId = mapId }) end -- ambient choreography is per-map: parallel runners die here, and the @@ -460,13 +460,13 @@ function OverworldState:setMap(mapId, x, y, facing, opts) -- (home/overworld.asm) -- a warp can land directly on one (the Route -- 16/18 gate exits), and the scripted door-mat walkout that follows -- suppresses onStepComplete, so waiting for a plain step never mounts - self:checkForcedMovement() + if not (opts and opts.checkpoint) then self:checkForcedMovement() end -- Seafoam B4F's map script pushes off the B3F stair warps every frame -- while the upper plugs are out (SeafoamIslandsB4FDefaultScript); the -- B3F/B4F force-surf mouths also arm their MOVE_OBJECT current scripts -- from CheckForceBikeOrSurf. Re-check here so a warp-in does not sit -- idle on those cells waiting for a player step. - self:checkSeafoamCurrent() + if not (opts and opts.checkpoint) then self:checkSeafoamCurrent() end -- snap the camera immediately: the overworld doesn't update while a -- Transition is on top, so a stale camera would show the new map at @@ -476,27 +476,31 @@ function OverworldState:setMap(mapId, x, y, facing, opts) -- fires before the onEnter chain so a listener sees the map in the same -- state the map script does - Runtime.emit("map.entered", { - mapId = mapId, map = self.map, fromMapId = fromMapId, - via = (opts and opts.via) - or (opts and opts.seamless and "connection") - or (fromMapId and "warp" or "boot"), - }) + if not (opts and opts.checkpoint) then + Runtime.emit("map.entered", { + mapId = mapId, map = self.map, fromMapId = fromMapId, + via = (opts and opts.via) + or (opts and opts.seamless and "connection") + or (fromMapId and "warp" or "boot"), + }) + end -- map-enter hooks (hand-ported map scripts, e.g. Victory Road barriers). -- fromMapId lets elevators seed a valid walk-out floor when the ROM -- car warps still point at a missing map (Silph's UNUSED_MAP_ED) and -- the player B-cancels the floor menu without .UpdateWarp. - local hooks = mapScripts.get(mapId) - if hooks and hooks.onEnter then - hooks.onEnter(Game, self, fromMapId) + if not (opts and opts.checkpoint) then + local hooks = mapScripts.get(mapId) + if hooks and hooks.onEnter then + hooks.onEnter(Game, self, fromMapId) + end end self:rebuildNeighbors() Logger.info("map: %s at (%d,%d)", mapId, x, y) -- Route22Gate_Script rewrites wLastMap from the player's Y on entry -- too (not only on step), so a save/load mid-gate keeps exits correct - self:syncLastMapRewrite() + if not (opts and opts.checkpoint) then self:syncLastMapRewrite() end end -- Neighbor maps drawn at the composed connection offsets: at least the diff --git a/tests/modkit/cases/checkpoints.lua b/tests/modkit/cases/checkpoints.lua new file mode 100644 index 00000000..0feeed1c --- /dev/null +++ b/tests/modkit/cases/checkpoints.lua @@ -0,0 +1,283 @@ +-- Public mod.checkpoints contract over a semantic Game/StateStack fixture. +-- The mod entry chunk sees no private module; the harness builds the engine side. + +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") + +local T = require("tests.harness").suite("mod checkpoints") +local Loader = require("src.mods.Loader") +local Runtime = require("src.mods.Runtime") +local GameMethods = require("src.core.Game") +local StateStack = require("src.core.StateStack") + +local savedEvents, savedHooks = Runtime.events, Runtime.hooks + +local function memfs(files) + return { + read = function(path) return files[path] end, + write = function(path, body) files[path] = body return true end, + remove = function(path) files[path] = nil return true end, + createDirectory = function() return true end, + getInfo = function(path) + if files[path] then return { type = "file" } end + local prefix = path .. "/" + for key in pairs(files) do + if key:sub(1, #prefix) == prefix then return { type = "directory" } end + end + return nil + end, + load = function(path) + if not files[path] then return nil, "no file: " .. path end + return load(files[path], path) + end, + getDirectoryItems = function(path) + local prefix, seen, out = path .. "/", {}, {} + for key in pairs(files) do + if key:sub(1, #prefix) == prefix then + local child = key:sub(#prefix + 1):match("^[^/]+") + if child and not seen[child] then + seen[child] = true + out[#out + 1] = child + end + end + end + table.sort(out) + return out + end, + } +end + +local function baseSave() + return { + version = "red", + meta = { format = 4, mods = {}, playthroughId = "play-a" }, + player = { + map = "PALLET_TOWN", x = 5, y = 6, facing = "down", surfing = false, + name = "RED", rival = "BLUE", id = 7, + }, + money = 3000, + party = { { species = "BULBASAUR", hp = 19, moves = { "TACKLE" } } }, + flags = { GOT_STARTER = true }, + inventory = { POTION = 1 }, + pcItems = {}, box = {}, boxes = {}, defeatedTrainers = {}, + pokedex = { seen = { BULBASAUR = true }, owned = { BULBASAUR = true } }, + modData = {}, + options = { volume = 4, bindings = {} }, + } +end + +local function makeGame() + local stack = setmetatable({ states = {} }, { __index = StateStack }) + local game + local ow = { + map = { id = "PALLET_TOWN" }, + player = { cellX = 5, cellY = 6, facing = "down", surfing = false }, + scriptMoves = {}, pendingScripts = {}, parallelRunners = {}, parallelQueue = {}, + runner = { isRunning = function() return false end }, + } + function ow:captureSave(save) + save.player.map = self.map.id + save.player.x = self.player.cellX + save.player.y = self.player.cellY + save.player.facing = self.player.facing + save.player.surfing = self.player.surfing and true or false + end + function ow:enter(mapId, x, y, facing, opts) + game.lastEnterOpts = opts + if game.failNextEnter then + game.failNextEnter = false + error("injected reconstruction failure") + end + self.map = { id = mapId } + self.player = { + cellX = x, cellY = y, facing = facing, + surfing = game.save.player.surfing and true or false, + } + self.scriptMoves, self.pendingScripts = {}, {} + self.parallelRunners, self.parallelQueue = {}, {} + self.runner = { isRunning = function() return false end } + end + game = setmetatable({ + save = baseSave(), stack = stack, overworld = ow, + data = { maps = { + PALLET_TOWN = { id = "PALLET_TOWN", width = 10, height = 9 }, + ROUTE_1 = { id = "ROUTE_1", width = 10, height = 18 }, + BROKEN = { id = "BROKEN", width = 10, height = 9 }, + } }, + }, { __index = GameMethods }) + stack.states[1] = ow + return game, ow +end + +local files = { + ["mods/probe/manifest.json"] = + '{"id":"probe","name":"probe","version":"1.0.0",' + .. '"entry":"main.lua","api":2,"profile":"content"}', + ["mods/probe/main.lua"] = [[ +return function(mod) _G.MOD_CHECKPOINTS = mod.checkpoints end +]], +} +local game, ow = makeGame() +local loader = Loader.new({ fs = memfs(files) }) +loader.game = game +T.check(loader:load({}) == true, "checkpoint fixture mod loads") +local checkpoints = _G.MOD_CHECKPOINTS +T.check(type(checkpoints) == "table", + "Loader exposes mod.checkpoints through the public mod object") +if type(checkpoints) ~= "table" then + Runtime.events, Runtime.hooks = savedEvents, savedHooks + _G.MOD_CHECKPOINTS = nil + T.finish() +end + +local capability = checkpoints:inspect(game) +T.same(capability, { canCapture = true, canRestore = true, kind = "overworld" }, + "plain overworld control is a stable checkpoint boundary") + +local function refused(mutator, expectedCode, message) + local undo = mutator() + local result = checkpoints:inspect(game) + T.check(result.canCapture == false and result.reason == expectedCode, message) + undo() +end + +refused(function() + ow.transitioning = true + return function() ow.transitioning = nil end +end, "transition_busy", "transition frames are rejected") + +refused(function() + ow.runner = { isRunning = function() return true end } + return function() ow.runner = { isRunning = function() return false end } end +end, "script_busy", "foreground suspended scripts are rejected") + +refused(function() + ow.parallelRunners = { { isRunning = function() return true end } } + return function() ow.parallelRunners = {} end +end, "script_busy", "parallel suspended scripts are rejected") + +refused(function() + ow.pendingScripts = { { rows = {} } } + return function() ow.pendingScripts = {} end +end, "script_busy", "queued scripts are rejected") + +refused(function() + ow.scriptMoves = { { entity = ow.player } } + return function() ow.scriptMoves = {} end +end, "script_busy", "scripted movement is rejected") + +refused(function() + game.stack.states[2] = { screenId = "StartMenu" } + return function() game.stack.states[2] = nil end +end, "screen_busy", "modal screens over the overworld are rejected") + +refused(function() + ow.emote = { frames = 1 } + return function() ow.emote = nil end +end, "animation_busy", "partial overworld animations are rejected") + +refused(function() + ow.player.moving = true + return function() ow.player.moving = nil end +end, "movement_busy", "partial player movement is rejected") + +local titleGame = { save = game.save, stack = { + top = function() return { screenId = "TitleState" } end, +} } +local titleCapability = checkpoints:inspect(titleGame) +T.check(titleCapability.canCapture == false + and titleCapability.reason == "not_overworld", + "title and non-playthrough runtime is rejected") + +-- Capture synchronizes semantic position into a detached data-only record. +ow.map.id, ow.player.cellX, ow.player.cellY = "ROUTE_1", 7, 8 +ow.player.facing, ow.player.surfing = "left", true +local snapshot, code, message = checkpoints:capture(game) +T.check(snapshot ~= nil, "stable overworld captures: " .. tostring(code or message)) +T.eq(snapshot.format, 1, "checkpoint format is explicit") +T.eq(snapshot.kind, "overworld", "checkpoint runtime kind is explicit") +T.same(snapshot.identity, { gameVersion = "red", playthroughId = "play-a" }, + "checkpoint carries compatibility identity") +T.same(snapshot.runtime.overworld, + { map = "ROUTE_1", x = 7, y = 8, facing = "left", surfing = true }, + "checkpoint carries exact semantic overworld position") +T.eq(snapshot.save.player.map, "ROUTE_1", + "captured progress is synchronized from the live controller") +T.eq(snapshot.save.options, nil, "global settings are excluded from progress rewind") + +snapshot.save.money = 1 +snapshot.runtime.overworld.x = 1 +T.eq(game.save.money, 3000, "mutating a checkpoint cannot mutate live progress") +T.eq(ow.player.cellX, 7, "mutating a checkpoint cannot move the live player") + +-- Recapture the unmodified canonical A used for the differential roundtrip. +snapshot = checkpoints:capture(game) +local original = snapshot + +game.save.money = 999999 +game.save.flags.GOT_STARTER = nil +game.save.party[1].hp = 1 +game.save.options.volume = 9 +ow.map.id, ow.player.cellX, ow.player.cellY = "PALLET_TOWN", 2, 3 +ow.player.facing, ow.player.surfing = "up", false + +local restored, restoreCode, restoreMessage = checkpoints:restore(game, original) +T.check(restored == true, + "valid checkpoint restores: " .. tostring(restoreCode or restoreMessage)) +local recaptured = checkpoints:capture(game) +T.same(recaptured, original, + "capture A, mutate B, restore A, capture A2 yields normalized A == A2") +T.eq(game.save.options.volume, 9, + "checkpoint restoration preserves current global settings") +T.check(game.lastEnterOpts and game.lastEnterOpts.checkpoint == true, + "engine reconstruction is marked to suppress map-entry side effects") + +-- Compatibility and schema failures occur before any mutation. +local beforeRejected = checkpoints:capture(game) +local wrongFormat = checkpoints:capture(game) +wrongFormat.format = 99 +restored, restoreCode = checkpoints:restore(game, wrongFormat) +T.check(not restored and restoreCode == "unsupported_format", + "unknown checkpoint format is rejected") + +local wrongGame = checkpoints:capture(game) +wrongGame.identity.gameVersion = "blue" +restored, restoreCode = checkpoints:restore(game, wrongGame) +T.check(not restored and restoreCode == "wrong_game", + "another game version is rejected") + +local wrongProfile = checkpoints:capture(game) +wrongProfile.identity.playthroughId = "play-b" +restored, restoreCode = checkpoints:restore(game, wrongProfile) +T.check(not restored and restoreCode == "wrong_playthrough", + "another playthrough is rejected") + +local badMap = checkpoints:capture(game) +badMap.runtime.overworld.map = "MISSING_MAP" +badMap.save.player.map = "MISSING_MAP" +restored, restoreCode = checkpoints:restore(game, badMap) +T.check(not restored and restoreCode == "invalid_map", + "unknown content reference is rejected") +T.same(checkpoints:capture(game), beforeRejected, + "validation failures leave the live state unchanged") + +-- A reconstruction exception rolls back to the exact pre-operation state. +local target = checkpoints:capture(game) +target.runtime.overworld.map = "BROKEN" +target.runtime.overworld.x, target.runtime.overworld.y = 1, 1 +target.save.player.map = "BROKEN" +target.save.player.x, target.save.player.y = 1, 1 +target.save.money = 42 +local beforeFailure = checkpoints:capture(game) +game.failNextEnter = true +restored, restoreCode = checkpoints:restore(game, target) +T.check(not restored and restoreCode == "restore_failed", + "reconstruction exception is returned as a structured failure") +T.same(checkpoints:capture(game), beforeFailure, + "failed reconstruction rolls back the complete pre-operation checkpoint") + +Runtime.events, Runtime.hooks = savedEvents, savedHooks +Runtime.currentMod = nil +_G.MOD_CHECKPOINTS = nil + +T.finish() diff --git a/tests/mod_storage_tests.lua b/tests/modkit/cases/storage.lua similarity index 100% rename from tests/mod_storage_tests.lua rename to tests/modkit/cases/storage.lua From 49954ec4adb5f4c1cec8a7b75eb8a22e9e524c28 Mon Sep 17 00:00:00 2001 From: MaxTomahawk Date: Fri, 7 Aug 2026 15:01:18 +0200 Subject: [PATCH 19/63] fix: allocate playthrough identity only on demand --- src/core/Checkpoint.lua | 13 +++++-- src/core/Game.lua | 1 - src/core/SaveData.lua | 50 ++++++++++++++++----------- src/core/SaveSerializer.lua | 7 ++++ src/mods/Storage.lua | 11 ++++-- tests/engine/playthrough_identity.lua | 37 +++++++++++++------- 6 files changed, 80 insertions(+), 39 deletions(-) diff --git a/src/core/Checkpoint.lua b/src/core/Checkpoint.lua index b29caba8..07427c94 100644 --- a/src/core/Checkpoint.lua +++ b/src/core/Checkpoint.lua @@ -2,6 +2,7 @@ -- methods; mods never receive controller or state-stack internals from here. local SaveSerializer = require("src.core.SaveSerializer") +local SaveData = require("src.core.SaveData") local Checkpoint = {} @@ -27,9 +28,7 @@ end function Checkpoint.inspect(game) local save = game and game.save - local identity = save and save.meta and save.meta.playthroughId - if type(save) ~= "table" or type(save.version) ~= "string" - or type(identity) ~= "string" or identity == "" then + if type(save) ~= "table" or type(save.version) ~= "string" then return refusal("unknown", "not_in_playthrough", "A checkpoint requires an identified active playthrough.") end @@ -45,6 +44,14 @@ function Checkpoint.inspect(game) return refusal("overworld", "screen_busy", "Close the active menu or screen before creating a checkpoint.") end + local identity = save.meta and save.meta.playthroughId + if type(identity) ~= "string" or identity == "" then + identity = SaveData.ensurePlaythroughId(save) + end + if type(identity) ~= "string" or identity == "" then + return refusal("overworld", "not_in_playthrough", + "The active playthrough could not be identified.") + end if ow.transitioning then return refusal("overworld", "transition_busy", "Wait for the map transition to finish.") diff --git a/src/core/Game.lua b/src/core/Game.lua index 527f4fc5..27d585b0 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -1079,7 +1079,6 @@ function Game:restoreSave(loaded, recovered) if ModRuntime.wants("save.loading") then ModRuntime.emit("save.loading", { raw = loaded }) end - SaveData.ensurePlaythroughId(loaded) -- mod chains replay before validation so a mod repairs its own data -- instead of watching it get quarantined; core steps already ran in -- SaveData.load and skip on the format guard diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index f68a10b4..2cba7e6f 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -486,6 +486,10 @@ end -- working unchanged. local activeSlotCache = {} -- version -> slotId in use, or false when none local slotsChecked = {} -- version -> true once resolved this process +-- At most one New Game can be the live candidate for a first public tool +-- request. A single strong reference models that runtime fact without adding +-- marker data to the save or retaining abandoned playthrough tables. +local freshPlaythrough local function slotDir(version) return "saves/" .. version end @@ -822,6 +826,7 @@ end function SaveData.resetSlotState() for k in pairs(activeSlotCache) do activeSlotCache[k] = nil end for k in pairs(slotsChecked) do slotsChecked[k] = nil end + freshPlaythrough = nil end -- ------- opaque playthrough identity @@ -846,20 +851,20 @@ function SaveData.newPlaythroughId() word(os.time()), word(clock), word(addressLo), word(playthroughSeq)) end -local function playthroughScope(version) +local function playthroughScope(version, injectedFs) version = version or GameVersion.get() - local fs = persistFs(nil) + local fs = persistFs(injectedFs) ensureVersionSlots(version, fs) return activeSlotCache[version] or "legacy" end -local function rememberPlaythroughId(save, opts) +local function rememberPlaythroughId(save, opts, injectedFs) local meta = type(save) == "table" and save.meta local id = type(meta) == "table" and meta.playthroughId if type(id) ~= "string" or id == "" then return opts, false end local version = save.version or GameVersion.get() - local scope = playthroughScope(version) - opts = opts or SaveData.loadOptions() + local scope = playthroughScope(version, injectedFs) + opts = opts or SaveData.loadOptions(injectedFs) opts.playthroughIds = opts.playthroughIds or {} opts.playthroughIds[version] = opts.playthroughIds[version] or {} local changed = opts.playthroughIds[version][scope] ~= id @@ -870,23 +875,25 @@ end -- Return an existing save identity or give a pre-identity save a stable one. -- Legacy backfill lives in options.lua until the next normal SAVE stamps the id -- into progress, so installing a tool mod never rewrites the player's checkpoint. -function SaveData.ensurePlaythroughId(save) +function SaveData.ensurePlaythroughId(save, injectedFs) if type(save) ~= "table" then return nil end save.meta = type(save.meta) == "table" and save.meta or {} local id = save.meta.playthroughId if type(id) == "string" and id ~= "" then return id end local version = save.version or GameVersion.get() - local scope = playthroughScope(version) - local opts = SaveData.loadOptions() + local scope = playthroughScope(version, injectedFs) + local opts = SaveData.loadOptions(injectedFs) + local isFresh = save == freshPlaythrough + if isFresh then freshPlaythrough = nil end local byVersion = opts.playthroughIds and opts.playthroughIds[version] - id = byVersion and byVersion[scope] + id = not isFresh and byVersion and byVersion[scope] or nil if type(id) ~= "string" or id == "" then id = SaveData.newPlaythroughId() opts.playthroughIds = opts.playthroughIds or {} opts.playthroughIds[version] = opts.playthroughIds[version] or {} opts.playthroughIds[version][scope] = id - SaveData.saveOptions(opts) + SaveData.saveOptions(opts, injectedFs) end save.meta.playthroughId = id return id @@ -1123,12 +1130,14 @@ function SaveData.save(data, mods) -- write to the file matching this save's own version, not just the active -- one, so Blue/Yellow playthroughs land in save_blue.lua / save_yellow.lua local FILENAME, BACKUP_FILENAME, TMP_FILENAME = saveNames(data.version) - SaveData.ensurePlaythroughId(data) if data.options then - local opts = rememberPlaythroughId(data, data.options) + local opts = data.options + if data.meta and data.meta.playthroughId then + opts = rememberPlaythroughId(data, data.options) + end data.options = opts SaveData.saveOptions(opts) - else + elseif data.meta and data.meta.playthroughId then local opts, changed = rememberPlaythroughId(data) if changed then SaveData.saveOptions(opts) end end @@ -1199,7 +1208,6 @@ function SaveData.load(version) return nil end SaveData.runMigrations(data) - SaveData.ensurePlaythroughId(data) data.options = SaveData.loadOptions() Logger.info("loaded save") return data, recovered @@ -1510,11 +1518,7 @@ function SaveData.newGame(boot) local x, y = boot.startX or 3, boot.startY or 6 local heal = SaveData.defaultHeal(boot) local save = { - meta = { - format = Version.saveFormat, - mods = {}, - playthroughId = SaveData.newPlaythroughId(), - }, + meta = { format = Version.saveFormat, mods = {} }, -- which game this playthrough is (Red vs Blue). Only Red ships today; -- boot carries the choice once Blue support lands. version = boot.version or "red", @@ -1557,8 +1561,12 @@ function SaveData.newGame(boot) options = SaveData.loadOptions(), } -- a total conversion reshapes the skeleton (spawn, party, money) - -- before anything reads it; unhooked this returns save unchanged - return Runtime.call("save.new_game", function(s) return s end, save) + -- before anything reads it; unhooked this returns save unchanged. Keep the + -- "fresh playthrough" marker outside the serialized table so a later tool + -- request can distinguish two unsaved New Games sharing one vanilla slot. + save = Runtime.call("save.new_game", function(s) return s end, save) + freshPlaythrough = save + return save end return SaveData diff --git a/src/core/SaveSerializer.lua b/src/core/SaveSerializer.lua index 77ba482d..9f821f67 100644 --- a/src/core/SaveSerializer.lua +++ b/src/core/SaveSerializer.lua @@ -41,6 +41,13 @@ local function serialize(v, indent) error("cannot serialize " .. t) end +-- LuaJIT 2.1 can lose a just-added nested table entry when a GC step lands +-- inside a compiled recursive serialization trace. The symptom is valid input +-- becoming `{" followed by only the trailing comma, which then cannot be read +-- back. Save encoding is infrequent and I/O-bound, so keep this correctness- +-- critical recursion in the interpreter while leaving the game JIT enabled. +if jit and jit.off then jit.off(serialize, true) end + function SaveSerializer.encode(data) return "return " .. serialize(data) .. "\n" end diff --git a/src/mods/Storage.lua b/src/mods/Storage.lua index 9b80c360..d5f3a324 100644 --- a/src/mods/Storage.lua +++ b/src/mods/Storage.lua @@ -57,11 +57,18 @@ function Storage:_scope(game) local save = game and game.save local meta = save and save.meta local version = save and save.version - local playthroughId = meta and meta.playthroughId - if not (save and validSegment(version) and validSegment(playthroughId)) then + if not (save and validSegment(version)) then return failure("not_in_playthrough", "Storage is available only inside an identified playthrough.") end + local playthroughId = meta and meta.playthroughId + if not validSegment(playthroughId) then + playthroughId = SaveData.ensurePlaythroughId(save, self.injectedFs) + end + if not validSegment(playthroughId) then + return failure("not_in_playthrough", + "Storage could not identify the active playthrough.") + end local fs = SaveData.persistenceFs(self.injectedFs) if not (fs and fs.read and fs.write and fs.getInfo) then return failure("storage_unavailable", "The persistence backend is unavailable.") diff --git a/tests/engine/playthrough_identity.lua b/tests/engine/playthrough_identity.lua index 291433a1..cb584d51 100644 --- a/tests/engine/playthrough_identity.lua +++ b/tests/engine/playthrough_identity.lua @@ -61,23 +61,31 @@ local function legacy(version, name) } end --- Removing playthroughId generation from New Game must fail these assertions. +-- No-mod parity: creating/saving a vanilla playthrough allocates no tool scope. do fresh() local first = SaveData.newGame({ version = "red" }) local second = SaveData.newGame({ version = "red" }) - T.check(type(first.meta.playthroughId) == "string" - and first.meta.playthroughId ~= "", - "New Game receives an opaque playthrough id") - T.neq(second.meta.playthroughId, first.meta.playthroughId, - "separate New Games receive separate playthrough ids") + T.eq(first.meta.playthroughId, nil, + "New Game allocates no playthrough id before a public tool requests it") + T.check(SaveData.save(first), "unused identity fixture saves") + local untouched = SaveData.load("red") + T.eq(untouched.meta.playthroughId, nil, + "normal save/load stays identity-free when no tool uses the capability") + + local firstId = SaveData.ensurePlaythroughId(first) + local secondId = SaveData.ensurePlaythroughId(second) + T.check(type(firstId) == "string" and firstId ~= "", + "the first tool request allocates an opaque playthrough id") + T.neq(secondId, firstId, + "separate New Games receive separate requested playthrough ids") end -- Dropping the id from buildMeta or save encoding must fail the roundtrip. do fresh() local save = SaveData.newGame({ version = "red" }) - local expected = save.meta.playthroughId + local expected = SaveData.ensurePlaythroughId(save) T.check(SaveData.save(save), "identity fixture saves") local loaded = SaveData.load("red") T.eq(loaded and loaded.meta.playthroughId, expected, @@ -92,9 +100,14 @@ do files["save.lua"] = SaveSerializer.encode(raw) local first = SaveData.load("red") - local id = first and first.meta.playthroughId + T.eq(first and first.meta.playthroughId, nil, + "loading a legacy save alone does not allocate tool identity") + local id = SaveData.ensurePlaythroughId(first) T.check(type(id) == "string" and id ~= "", "a legacy save receives a playthrough id") + local mappedOptions, mappedErr = SaveSerializer.decode(files["options.lua"] or "") + T.check(mappedOptions ~= nil, + "legacy identity mapping remains decodable: " .. tostring(mappedErr)) local slotBytes = files["saves/red/slot1.lua"] local onDisk = slotBytes and SaveSerializer.decode(slotBytes) @@ -103,7 +116,7 @@ do SaveData.resetSlotState() local second = SaveData.load("red") - T.eq(second and second.meta.playthroughId, id, + T.eq(SaveData.ensurePlaythroughId(second), id, "legacy backfill is stable across reload before normal SAVE") end @@ -115,19 +128,19 @@ do SaveData.setActiveSlot("red", redA) T.check(SaveData.writeSlot("red", redA, legacy("red", "SAME")), "seed red slot A") - local idA = SaveData.load("red").meta.playthroughId + local idA = SaveData.ensurePlaythroughId(SaveData.load("red")) SaveData.setActiveSlot("red", redB) T.check(SaveData.writeSlot("red", redB, legacy("red", "SAME")), "seed red slot B") - local idB = SaveData.load("red").meta.playthroughId + local idB = SaveData.ensurePlaythroughId(SaveData.load("red")) GameVersion.set("blue") local blue = SaveData.createSlot("blue") SaveData.setActiveSlot("blue", blue) T.check(SaveData.writeSlot("blue", blue, legacy("blue", "SAME")), "seed blue slot") - local idBlue = SaveData.load("blue").meta.playthroughId + local idBlue = SaveData.ensurePlaythroughId(SaveData.load("blue")) T.neq(idA, idB, "two active slots do not share legacy identity") T.neq(idA, idBlue, "Red and Blue do not share legacy identity") From bbca4e8e39aa637192fcfe962ae98c65bc06642c Mon Sep 17 00:00:00 2001 From: MaxTomahawk Date: Fri, 7 Aug 2026 15:05:43 +0200 Subject: [PATCH 20/63] docs: specify mod storage and checkpoint APIs --- docs/modding.md | 49 ++++++++++ docs/rfcs/0003-playthrough-storage.md | 123 +++++++++++++++++++++++++ docs/rfcs/0004-runtime-checkpoints.md | 124 ++++++++++++++++++++++++++ 3 files changed, 296 insertions(+) create mode 100644 docs/rfcs/0003-playthrough-storage.md create mode 100644 docs/rfcs/0004-runtime-checkpoints.md diff --git a/docs/modding.md b/docs/modding.md index 31721d9c..fbff19f9 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -140,6 +140,55 @@ default** (1x front, 2x back). ball-to-pic grow multiplies your scale through each stage, so a rescaled mon still grows into place from the ball, grounded the whole way. +## Durable tool storage and runtime checkpoints + +`mod.save` remains the right place for state that should travel with the next +normal Pokémon SAVE. Tools that need independently written, larger data-only +records can use `mod.storage`; the engine scopes every logical key by game +version, opaque playthrough identity, and mod id, and routes it through the same +standard or portable persistence backend as saves: + +```lua +local context, code, message = mod.storage:context(game) +local ok, code, message = mod.storage:write(game, "history/quick/q0001", { + format = 1, createdAt = os.time(), payload = { money = 3000 }, +}) +local value, code, message = mod.storage:read(game, "history/quick/q0001") +local keys, code, message = mod.storage:list(game, "history/quick") +local deleted, code, message = mod.storage:delete(game, "history/quick/q0001") +``` + +Values must be tables containing serializable data only. Keys are conservative +slash-separated segments (letters, digits, `_`, `-`); paths and filesystem +handles are never exposed. Writes are staged and decode-verified, reads recover +from a valid staged/backup generation, and methods return structured errors for +normal data or I/O failures. The playthrough identity is allocated lazily on the +first storage/checkpoint call, so an unused API changes no save bytes. + +`mod.checkpoints` captures and reconstructs engine-owned semantic runtime state: + +```lua +local capability = mod.checkpoints:inspect(game) +if capability.canCapture then + local checkpoint, code, message = mod.checkpoints:capture(game) + -- Store the detached data-only checkpoint through mod.storage. +end + +local ok, code, message = mod.checkpoints:restore(game, checkpoint) +``` + +Checkpoint format 1 supports settled overworld control only: the overworld must +be topmost, the player stationary on a tile, and no transition, menu, script, +queued script movement, or partial field animation may be active. Refusals carry +a stable `reason` and readable `message`. Capture excludes global options and +runtime objects. Restore validates format, game/playthrough identity, content, +and coordinates before mutation; preserves current options; suppresses normal +map-entry/save-load side effects; verifies a recapture; and rolls back in memory +if reconstruction fails. Callers that need crash recovery should durably capture +their own recovery checkpoint before restore. + +See RFC 0003 and RFC 0004 for exact contracts and error codes. + ## Developer console Boot with developer mode on to unlock the in-game console and hot-reload diff --git a/docs/rfcs/0003-playthrough-storage.md b/docs/rfcs/0003-playthrough-storage.md new file mode 100644 index 00000000..d95fddb2 --- /dev/null +++ b/docs/rfcs/0003-playthrough-storage.md @@ -0,0 +1,123 @@ +# RFC 0003 — Playthrough-scoped mod storage + +## Status + +Proposed. Engine: `SaveData.lua`, `SaveSerializer.lua`, `Storage.lua`, +`Loader.lua`. Tests: `playthrough_identity.lua`, `storage.lua`, the existing +save-slot and mod-save suites. + +## Motivation + +`mod.save` intentionally lives inside the normal progress record. That is the +right home for quest state, but not for independent tool data such as replay +captures, checkpoint histories, or recovery records: writing it would require a +normal Pokémon SAVE, and storing copies of progress beneath `save.modData` would +recursively embed the save that contains them. + +Mods also cannot safely infer which launcher slot or portable filesystem backs +the active playthrough. Direct filesystem access would expose private paths and +make isolation dependent on engine implementation details. + +## The decision it extends + +Extends the per-mod persistence contract documented in `docs/modding.md` and the +wiki's Save Model. `mod.save` and `mod.options` keep their existing behavior. + +## The exact API delta + +Backward-compatible, additive-only. `Loader:_api` binds a new `mod.storage` +facade to the calling mod id. Mods receive logical keys and decoded values, never +filesystem handles or physical paths. + +### Lazy opaque playthrough identity + +`SaveData.ensurePlaythroughId(save[, fs]) -> id | nil` allocates an opaque +32-hex-character identity without consuming gameplay RNG. It is called only when +`mod.storage` or `mod.checkpoints` first needs a scope; New Game, ordinary SAVE, +and ordinary load remain byte-compatible when no caller uses either API. + +The id is stored in `save.meta.playthroughId` after allocation. Until the next +ordinary SAVE writes it into progress, a mapping in `options.lua` keeps legacy +saves stable by game version and active launcher slot (or the legacy flat-save +scope). A newly created playthrough never adopts the previous playthrough's +mapping for that slot. + +`SaveData.persistenceFs([fs])` is engine-only routing used by the storage +implementation. It follows the same standard/portable backend as progress and +honors injected test filesystems; it is not exposed on the mod object. + +### `mod.storage:context(game)` + +Returns: + +```lua +{ gameVersion = "red", playthroughId = "..." } +``` + +or `nil, code, message`. It intentionally omits launcher slot ids and paths. + +### `mod.storage:write(game, key, value)` + +Accepts a data-only table and returns `true`, or +`false, code, message`. Keys are nonempty slash-separated segments containing +letters, digits, underscore, or dash. Empty segments, leading/trailing slash, +`.`/`..`, and other characters are rejected. + +The engine encodes deterministically, stages and decodes a `.tmp` witness, +preserves the previous valid generation, writes and decodes the main record, +then rolls the verified bytes to `.bak`. A failed stage or replacement leaves a +verified prior generation readable. + +### `mod.storage:read(game, key)` + +Returns a freshly decoded table, or `nil, code, message`. It tries main, staged, +then backup data. A valid staged/backup value is returned and promoted +best-effort; corrupt bytes are never executed. + +### `mod.storage:list(game[, prefix])` + +Returns sorted logical keys beneath a valid prefix, an exact key when the prefix +names one, or `nil, code, message`. Physical witness filenames are hidden. + +### `mod.storage:delete(game, key)` + +Deletes only that key's main, backup, and staged witnesses. Returns `true`, or +`false, code, message`. + +### Scope and errors + +Physical records are scoped as: + +`persistence root / mod_storage / game version / playthrough id / mod id` + +Stable error codes are `not_in_playthrough`, `storage_unavailable`, +`invalid_key`, `encode_failed`, `write_failed`, `verify_failed`, and +`not_found`. Ordinary data and I/O failures are return values, not callback- +terminating errors. + +The restricted serializer's recursive writer runs outside LuaJIT traces. A +1,000-process GC stress regression found compiled recursion could intermittently +drop a newly inserted nested identity entry and produce undecodable bytes; save +encoding is infrequent and I/O-bound, so interpreter execution is the safe +boundary. + +## Migration note for existing mods + +**Nothing.** No API is removed, no manifest field changes, and no storage path or +playthrough id is created unless a mod invokes `mod.storage` or +`mod.checkpoints`. Existing save bytes remain unchanged on the no-caller path. + +## Parity tests + +- **No-mod:** New Game plus ordinary save/load creates no identity or storage + file; the existing save-slot and mod-save suites remain green. +- **Engine identity:** lazy allocation, save/load preservation, stable legacy + mapping, fresh-playthrough replacement, and version/slot isolation. +- **Public Mod API:** two real API-2 entry chunks prove data-only roundtrip, + deterministic listing, key rejection, mod/game/playthrough isolation, + corrupt-main recovery, failure retention, exact delete, and no-mod no-write. + +## Deprecation etiquette + +Nothing deprecated. The additions are one bound public facade and engine-private +persistence/identity helpers. diff --git a/docs/rfcs/0004-runtime-checkpoints.md b/docs/rfcs/0004-runtime-checkpoints.md new file mode 100644 index 00000000..9d3bc175 --- /dev/null +++ b/docs/rfcs/0004-runtime-checkpoints.md @@ -0,0 +1,124 @@ +# RFC 0004 — Stable runtime checkpoints for mods + +## Status + +Proposed. Engine: `Checkpoint.lua`, `Game.lua`, `OverworldController.lua`, +`Loader.lua`. Tests: `checkpoints.lua`, existing world and engine suites. + +## Motivation + +Mods can observe world events and request semantic actions, but no supported API +can capture canonical progress at a proven-safe runtime boundary or reconstruct +the overworld without replaying map-entry scripts. Reaching into the state stack, +controller, ScriptRunner, or save restore internals would bind distributable mods +to private objects and can duplicate story side effects. + +The engine is the only component that can authoritatively decide whether the +runtime is settled and rebuild its controller objects. A generic checkpoint seam +lets tools store data-only records while keeping those responsibilities private. + +## The decision it extends + +Extends the public world/tool surfaces in `docs/modding.md`. It does not change +`mod.world`, normal CONTINUE, vanilla SAVE, or save lifecycle hooks/events. + +## The exact API delta + +Backward-compatible, additive-only. `Loader:_api` binds `mod.checkpoints`; mods +never receive `Game`, StateStack, controller, coroutine, renderer, or filesystem +internals inside a checkpoint. + +### `mod.checkpoints:inspect(game)` + +Returns a capability record. Stable overworld control returns: + +```lua +{ canCapture = true, canRestore = true, kind = "overworld" } +``` + +A refusal returns the same booleans as `false` plus `kind`, `reason`, and a +player-readable `message`. Format-1 supports only an overworld whose controller +is topmost, player movement has settled on a tile, and no transition, foreground +or parallel ScriptRunner, queued script, scripted move, engagement, emote, +teleport, field animation, or similar partial controller mutation is active. + +Refusal reasons are `not_in_playthrough`, `not_overworld`, `screen_busy`, +`transition_busy`, `script_busy`, `animation_busy`, and `movement_busy`. +Identity allocation is lazy and happens only after an active topmost overworld +has been established. + +### `mod.checkpoints:capture(game)` + +Returns a detached data-only format-1 checkpoint, or +`nil, code, message`: + +```lua +{ + format = 1, + kind = "overworld", + identity = { gameVersion = "red", playthroughId = "..." }, + save = { -- canonical dynamic progress, excluding global options }, + runtime = { overworld = { + map = "PALLET_TOWN", x = 5, y = 6, + facing = "down", surfing = false, + } }, +} +``` + +Capture deep-copies through the restricted serializer before and after +`OverworldController:captureSave` synchronizes live map, tile, facing, and surf +state. It excludes `save.options`, functions, userdata, threads, metatables as +behavior, controller instances, and static content registries. Failure code +`capture_failed` covers non-data progress and synchronization errors. + +### `mod.checkpoints:restore(game, checkpoint)` + +Returns `true`, or `false, code, message`. Before mutation it requires the current +runtime to be capturable and validates a detached copy of the complete record: +format, kind, internal identity consistency, current game/playthrough identity, +map availability, integral in-bounds tile, facing, surfing, and synchronized save +position. + +Validation codes are `invalid_checkpoint`, `unsupported_format`, +`unsupported_runtime_kind`, `wrong_game`, `wrong_playthrough`, `invalid_map`, and +`invalid_position`, in addition to the capability refusal reasons. + +The engine captures an in-memory rollback checkpoint, preserves current global +options, then reconstructs semantic overworld state through +`Game:restoreCheckpointSave`. Checkpoint entry suppresses normal map exit/entry +events, `onEnter` scripts, forced-movement/current checks, and last-map rewrites; +it does not emit normal `save.loading`/`save.loaded` lifecycle events. After +reconstruction, the engine recaptures and byte-compares normalized data. A failed +apply rolls back and returns `restore_failed`; failure of that rollback returns +`rollback_failed`. + +Durable recovery remains a caller responsibility: in-memory rollback handles a +runtime exception, not process termination. + +## Runtime boundary and future kinds + +Format 1 intentionally rejects battles, menus, transitions, animations, and +suspended/queued scripts. Future battle or explicit script-checkpoint kinds must +have separate inventories, validation, reconstruction, deterministic RNG, and +differential tests; they are not implied by this RFC. + +## Migration note for existing mods + +**Nothing.** No existing hook, event, save, controller, or world action changes +when `mod.checkpoints` is unused. The reconstruction path is called only by a +successful public restore after validation. + +## Parity tests + +- **No-mod:** the complete ROM-free engine suite and existing world behavior stay + green; ordinary New Game/save/load allocates no checkpoint identity. +- **Public Mod API:** a real API-2 entry chunk proves stable inspection and every + unsafe refusal, detached data-only capture, exact map/tile/facing/surf sync, + `A -> mutate B -> restore A -> recapture A2` equality across representative + progress, settings preservation, compatibility rejection without mutation, + map-side-effect suppression, and injected reconstruction rollback. + +## Deprecation etiquette + +Nothing deprecated. This adds one public facade and a checkpoint-only semantic +reconstruction route. From 9d6ea845d7c462ebab0f72a2ac0c951557e21260 Mon Sep 17 00:00:00 2001 From: MaxTomahawk Date: Fri, 7 Aug 2026 15:09:16 +0200 Subject: [PATCH 21/63] fix: reject invalid checkpoint content --- docs/rfcs/0004-runtime-checkpoints.md | 6 +++++- src/core/Checkpoint.lua | 13 ++++++++++++ tests/modkit/cases/checkpoints.lua | 29 +++++++++++++++++++++------ 3 files changed, 41 insertions(+), 7 deletions(-) diff --git a/docs/rfcs/0004-runtime-checkpoints.md b/docs/rfcs/0004-runtime-checkpoints.md index 9d3bc175..e9ba649c 100644 --- a/docs/rfcs/0004-runtime-checkpoints.md +++ b/docs/rfcs/0004-runtime-checkpoints.md @@ -79,10 +79,14 @@ format, kind, internal identity consistency, current game/playthrough identity, map availability, integral in-bounds tile, facing, surfing, and synchronized save position. -Validation codes are `invalid_checkpoint`, `unsupported_format`, +Validation codes are `invalid_checkpoint`, `invalid_content`, `unsupported_format`, `unsupported_runtime_kind`, `wrong_game`, `wrong_playthrough`, `invalid_map`, and `invalid_position`, in addition to the capability refusal reasons. +The canonical save validator runs against the detached record. Unlike ordinary +CONTINUE, a checkpoint never accepts a quarantine, remap, reclaim, clamp, or +repair: any such content change returns `invalid_content` before live mutation. + The engine captures an in-memory rollback checkpoint, preserves current global options, then reconstructs semantic overworld state through `Game:restoreCheckpointSave`. Checkpoint entry suppresses normal map exit/entry diff --git a/src/core/Checkpoint.lua b/src/core/Checkpoint.lua index 07427c94..d1082855 100644 --- a/src/core/Checkpoint.lua +++ b/src/core/Checkpoint.lua @@ -195,6 +195,19 @@ local function validate(game, checkpoint) or runtime.x >= width * 2 or runtime.y >= height * 2 then return nil, "invalid_position", "Checkpoint position is outside the map." end + + -- A checkpoint is a strict restoration record, not an ordinary CONTINUE + -- migration. Reuse the canonical save validator on the detached copy, but + -- reject any quarantine, remap, reclaim, clamp, or content repair it would + -- perform instead of silently changing the state the caller selected. + local beforeContent = SaveSerializer.encode(copy.save) + local validOk, report = pcall(SaveData.validate, copy.save, game.data) + local afterOk, afterContent = pcall(SaveSerializer.encode, copy.save) + if not validOk or not afterOk or not SaveData.emptyReport(report) + or afterContent ~= beforeContent then + return nil, "invalid_content", + "Checkpoint references unavailable or invalid game content." + end return copy end diff --git a/tests/modkit/cases/checkpoints.lua b/tests/modkit/cases/checkpoints.lua index 0feeed1c..3dd4aa09 100644 --- a/tests/modkit/cases/checkpoints.lua +++ b/tests/modkit/cases/checkpoints.lua @@ -56,7 +56,8 @@ local function baseSave() name = "RED", rival = "BLUE", id = 7, }, money = 3000, - party = { { species = "BULBASAUR", hp = 19, moves = { "TACKLE" } } }, + party = { { species = "BULBASAUR", level = 5, hp = 19, + moves = { "TACKLE" } } }, flags = { GOT_STARTER = true }, inventory = { POTION = 1 }, pcItems = {}, box = {}, boxes = {}, defeatedTrainers = {}, @@ -99,11 +100,18 @@ local function makeGame() end game = setmetatable({ save = baseSave(), stack = stack, overworld = ow, - data = { maps = { - PALLET_TOWN = { id = "PALLET_TOWN", width = 10, height = 9 }, - ROUTE_1 = { id = "ROUTE_1", width = 10, height = 18 }, - BROKEN = { id = "BROKEN", width = 10, height = 9 }, - } }, + data = { + pokemon = { BULBASAUR = { dex = 1 } }, + moves = { TACKLE = { pp = 35 } }, + items = { POTION = {} }, + constants = { fallbackMove = "TACKLE" }, + field = { boot = { startMap = "PALLET_TOWN", startX = 5, startY = 6 } }, + maps = { + PALLET_TOWN = { id = "PALLET_TOWN", width = 10, height = 9 }, + ROUTE_1 = { id = "ROUTE_1", width = 10, height = 18 }, + BROKEN = { id = "BROKEN", width = 10, height = 9 }, + }, + }, }, { __index = GameMethods }) stack.states[1] = ow return game, ow @@ -261,6 +269,15 @@ T.check(not restored and restoreCode == "invalid_map", T.same(checkpoints:capture(game), beforeRejected, "validation failures leave the live state unchanged") +local invalidGame = makeGame() +local badSpecies = checkpoints:capture(invalidGame) +badSpecies.save.party[1].species = "MISSING_SPECIES" +restored, restoreCode = checkpoints:restore(invalidGame, badSpecies) +T.check(not restored and restoreCode == "invalid_content", + "unknown Pokemon content is rejected before reconstruction") +T.eq(invalidGame.save.party[1].species, "BULBASAUR", + "invalid Pokemon content leaves the live party unchanged") + -- A reconstruction exception rolls back to the exact pre-operation state. local target = checkpoints:capture(game) target.runtime.overworld.map = "BROKEN" From 05b43ca258f808f7206c228e04c02bcb339a3768 Mon Sep 17 00:00:00 2001 From: MaxTomahawk Date: Fri, 7 Aug 2026 15:21:16 +0200 Subject: [PATCH 22/63] feat: include engine version in checkpoints --- docs/rfcs/0004-runtime-checkpoints.md | 7 +++++-- src/core/Checkpoint.lua | 5 ++++- tests/modkit/cases/checkpoints.lua | 7 ++++++- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/docs/rfcs/0004-runtime-checkpoints.md b/docs/rfcs/0004-runtime-checkpoints.md index e9ba649c..0df1c421 100644 --- a/docs/rfcs/0004-runtime-checkpoints.md +++ b/docs/rfcs/0004-runtime-checkpoints.md @@ -56,7 +56,9 @@ Returns a detached data-only format-1 checkpoint, or { format = 1, kind = "overworld", - identity = { gameVersion = "red", playthroughId = "..." }, + identity = { + engineVersion = "...", gameVersion = "red", playthroughId = "...", + }, save = { -- canonical dynamic progress, excluding global options }, runtime = { overworld = { map = "PALLET_TOWN", x = 5, y = 6, @@ -65,7 +67,8 @@ Returns a detached data-only format-1 checkpoint, or } ``` -Capture deep-copies through the restricted serializer before and after +`engineVersion` is metadata for caller compatibility warnings; the engine does +not reject patch/minor mismatches on restore. Capture deep-copies through the restricted serializer before and after `OverworldController:captureSave` synchronizes live map, tile, facing, and surf state. It excludes `save.options`, functions, userdata, threads, metatables as behavior, controller instances, and static content registries. Failure code diff --git a/src/core/Checkpoint.lua b/src/core/Checkpoint.lua index d1082855..f09962f6 100644 --- a/src/core/Checkpoint.lua +++ b/src/core/Checkpoint.lua @@ -3,6 +3,7 @@ local SaveSerializer = require("src.core.SaveSerializer") local SaveData = require("src.core.SaveData") +local Version = require("src.core.Version") local Checkpoint = {} @@ -119,6 +120,7 @@ function Checkpoint.capture(game) format = Checkpoint.FORMAT, kind = "overworld", identity = { + engineVersion = Version.engine, gameVersion = game.save.version, playthroughId = game.save.meta.playthroughId, }, @@ -154,7 +156,8 @@ local function validate(game, checkpoint) local identity = copy.identity local current = game and game.save local currentId = current and current.meta and current.meta.playthroughId - if type(identity) ~= "table" or type(identity.gameVersion) ~= "string" + if type(identity) ~= "table" or type(identity.engineVersion) ~= "string" + or type(identity.gameVersion) ~= "string" or type(identity.playthroughId) ~= "string" then return nil, "invalid_checkpoint", "Checkpoint identity is missing or corrupt." end diff --git a/tests/modkit/cases/checkpoints.lua b/tests/modkit/cases/checkpoints.lua index 3dd4aa09..a1277b1e 100644 --- a/tests/modkit/cases/checkpoints.lua +++ b/tests/modkit/cases/checkpoints.lua @@ -9,6 +9,7 @@ local Loader = require("src.mods.Loader") local Runtime = require("src.mods.Runtime") local GameMethods = require("src.core.Game") local StateStack = require("src.core.StateStack") +local Version = require("src.core.Version") local savedEvents, savedHooks = Runtime.events, Runtime.hooks @@ -204,7 +205,11 @@ local snapshot, code, message = checkpoints:capture(game) T.check(snapshot ~= nil, "stable overworld captures: " .. tostring(code or message)) T.eq(snapshot.format, 1, "checkpoint format is explicit") T.eq(snapshot.kind, "overworld", "checkpoint runtime kind is explicit") -T.same(snapshot.identity, { gameVersion = "red", playthroughId = "play-a" }, +T.same(snapshot.identity, { + engineVersion = Version.engine, + gameVersion = "red", + playthroughId = "play-a", + }, "checkpoint carries compatibility identity") T.same(snapshot.runtime.overworld, { map = "ROUTE_1", x = 7, y = 8, facing = "left", surfing = true }, From 0d4a518a086a63de564f8f05e97e9a32500648db Mon Sep 17 00:00:00 2001 From: Shane McGovern Date: Fri, 7 Aug 2026 14:55:55 +0100 Subject: [PATCH 23/63] saveOptions three-way merge: stop partial writes dropping launcher keys (#932) options.lua is a whole-file rewrite, so a caller handing saveOptions a partial table (only the keys it changed) silently dropped every key it did not mention: launcher-only keys like lastVersion, and keys the launcher set (battleBg, tilt) all fell back to defaults. saveOptions now reads the on-disk file first and folds caller-absent values underneath before mergeOptions backfills defaults. A table holding every defaultOptions key is a full snapshot and stays authoritative, so the fold is inert for all in-repo writers (every one passes loadOptions-ed tables) and cannot resurrect the bindings/activeProfile deletions the RESET REBINDS and mod-manager paths make on full tables. Adds a regression suite (options_partial_write_bug932.lua) pinning the merge, and updates the #828 suite's partial-write assertion, which now expects lastVersion to survive a delta write. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/core/SaveData.lua | 55 +++++++++- tests/engine/options_partial_write_bug932.lua | 100 ++++++++++++++++++ .../engine/options_write_readback_bug828.lua | 35 +++--- 3 files changed, 172 insertions(+), 18 deletions(-) create mode 100644 tests/engine/options_partial_write_bug932.lua diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index 882897bb..65e5c898 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -351,6 +351,22 @@ local function readTable(fs, name) return SaveSerializer.decode(body) end +-- Deep-copy a value folded in from the on-disk decode so the returned +-- options table never aliases the file's nested tables (SaveData must not +-- depend on src/mods/Merge.lua for this). Options data is plain tables of +-- strings/numbers/booleans/tables, so a cycle guard is belt-and-braces. +local function deepCopy(v, seen) + if type(v) ~= "table" then return v end + seen = seen or {} + if seen[v] then return seen[v] end + local copy = {} + seen[v] = copy + for k, val in pairs(v) do + copy[deepCopy(k, seen)] = deepCopy(val, seen) + end + return copy +end + -- the stub filesystem some headless harnesses inject has no remove; a -- lingering tmp/bak there is harmless local function remove(fs, name) @@ -364,13 +380,48 @@ end -- options round-trip headless (no love global). function SaveData.saveOptions(opts, fs) fs = persistFs(fs) + -- #932: options.lua is a WHOLE-FILE rewrite, so a caller that hands over a + -- PARTIAL table (just the keys it changed) would silently drop every key it + -- does not mention -- launcher-only keys like lastVersion, and keys the + -- launcher set (battleBg, tilt...) all fall back to defaults. Read the + -- on-disk file FIRST and fold caller-absent values underneath, so a delta + -- write changes only what it names. + -- + -- A table holding EVERY defaultOptions key is a full snapshot + -- (loadOptions() results, game.save.options, the RESET REBINDS / + -- activeProfile-drop paths) and stays authoritative: its absent keys are + -- deliberate deletions, so nothing folds for it. Partial tables get every + -- on-disk key they do not provide folded in (deep-copied so the caller's + -- table is never aliased). This is the reconciling rule: bindings and + -- activeProfile -- not defaultOptions members -- can be deleted by their + -- sites precisely because those sites always write full tables. + local onDisk = readTable(fs, OPTIONS_FILENAME) + local isFull = type(opts) == "table" + if isFull then + for k in pairs(SaveData.defaultOptions()) do + if opts[k] == nil then isFull = false break end + end + end + if not isFull then + local merged = {} + if type(opts) == "table" then + for k, v in pairs(opts) do merged[k] = v end + end + if type(onDisk) == "table" then + for k, v in pairs(onDisk) do + if k ~= "modOptions" and merged[k] == nil then + merged[k] = deepCopy(v) + end + end + end + opts = merged + end opts = SaveData.mergeOptions(opts) -- modOptions is per-mod nested state: fold the on-disk sub-tree -- underneath (newest value winning per key) so one caller's partial -- write cannot clobber another mod's persisted keys. Every other -- option stays on the shallow path. - local onDisk = readTable(fs, OPTIONS_FILENAME) - if onDisk and type(onDisk.modOptions) == "table" then + if type(onDisk) == "table" and type(onDisk.modOptions) == "table" then local merged = {} for modId, bucket in pairs(onDisk.modOptions) do merged[modId] = bucket diff --git a/tests/engine/options_partial_write_bug932.lua b/tests/engine/options_partial_write_bug932.lua new file mode 100644 index 00000000..9f7802a9 --- /dev/null +++ b/tests/engine/options_partial_write_bug932.lua @@ -0,0 +1,100 @@ +-- #932 "Bugs reset settings": a caller that hands saveOptions a PARTIAL +-- table (only the keys it changed) used to drop every key it did not +-- mention -- launcher-only keys like lastVersion, and keys the launcher set +-- (battleBg, tilt) all fell back to defaults. saveOptions now reads the +-- on-disk file first and folds caller-absent values underneath, so a delta +-- write changes only what it names. +-- +-- This suite pins the three-way merge against injected filesystem stubs +-- (the same { getInfo, read, write, remove } shape the other engine suites +-- use). It is ROM-free (T2 engine tier). +-- luajit tests/engine/options_partial_write_bug932.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local SaveData = require("src.core.SaveData") + +local OPTIONS = "options.lua" + +local function memfs() + local 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] ~= nil then return { type = "file" } end + return nil + end, + } +end + +-- Seed a save dir with the full snapshot a launcher would write: defaults, +-- plus the keys the issue cares about. lastVersion is launcher-only (not a +-- defaultOptions member) and must survive ANY write that does not name it. +local function seed(fs) + local seed = SaveData.defaultOptions() + seed.battleBg = "world" + seed.lastVersion = "blue" + seed.tilt = 1 + seed.mods = { foo = true } + seed.modOptions = { alpha = { keep = true, x = 1 } } + check(SaveData.saveOptions(seed, fs) ~= nil, "seeding lands") +end + +-- ---- launcher-only keys survive a delta write + +local fs = memfs() +seed(fs) + +-- loader-style partial write: only the mods bucket it manages. +SaveData.saveOptions({ mods = { foo = true } }, fs) +local opts = SaveData.loadOptions(fs) +eq(opts.battleBg, "world", "a partial write keeps battleBg the launcher set") +eq(opts.lastVersion, "blue", "a partial write keeps lastVersion (#932)") +eq(opts.tilt, 1, "a partial write keeps tilt the launcher set") + +-- ---- caller-present keys still win + +SaveData.saveOptions({ battleBg = "black" }, fs) +eq(SaveData.loadOptions(fs).battleBg, "black", + "a key the caller DOES provide wins over the on-disk value") +eq(SaveData.loadOptions(fs).lastVersion, "blue", + "...while the launcher-only key is still carried") + +-- ---- modOptions per-mod deep merge stays intact + +SaveData.saveOptions({ modOptions = { alpha = { x = 5 } } }, fs) +local after = SaveData.loadOptions(fs) +eq(after.modOptions.alpha.x, 5, "newest alpha value wins the per-mod merge") +eq(after.modOptions.alpha.keep, true, "alpha's untouched keys survive") +eq(after.modOptions.beta, nil, "no beta was invented by the merge") + +-- ---- full-table writes stay authoritative (bindings/activeProfile drops) + +-- The fold must NOT resurrect a key a full snapshot deliberately deletes: +-- the RESET REBINDS path nils bindings and the mod manager nils +-- activeProfile, always on full loadOptions tables. +fs = memfs() +seed(fs) +SaveData.saveOptions({ bindings = { a = 1 } }, fs) +eq(SaveData.loadOptions(fs).bindings.a, 1, "bindings is not a default member") + +local full = SaveData.loadOptions(fs) +full.bindings = nil +full.activeProfile = nil +SaveData.saveOptions(full, fs) +local reopened = SaveData.loadOptions(fs) +eq(reopened.bindings, nil, + "a full-snapshot deletion of bindings is NOT resurrected by the fold") +eq(reopened.activeProfile, nil, + "a full-snapshot deletion of activeProfile is NOT resurrected") +eq(reopened.battleBg, "world", + "the rest of the full snapshot is still what it was") + +T.finish("options_partial_write_bug932") diff --git a/tests/engine/options_write_readback_bug828.lua b/tests/engine/options_write_readback_bug828.lua index 59b9b0b7..b0ccbabc 100644 --- a/tests/engine/options_write_readback_bug828.lua +++ b/tests/engine/options_write_readback_bug828.lua @@ -204,23 +204,26 @@ eq(reopened.lastVersion, "blue", "launcher-only keys the game never reads are carried through its write") -- The corollary, and the reason the copy has to come from loadOptions: a --- caller that writes a partial literal instead of a loaded table drops every --- key it does not mention, because mergeOptions only fills DEFAULTS in around --- what it is handed (SaveData.mergeOptions). Nothing on the boot path does --- this today; the assertion is the guard rail if someone shortcuts it. +-- caller that writes a partial literal instead of a loaded table would drop +-- every key it does not mention. Since #932 that drop is closed by a +-- three-way merge -- saveOptions folds on-disk values the caller's table +-- does not carry (lastVersion here), defaults-filling only what neither side +-- has -- so even a delta write keeps the launcher's key alive. Nothing on +-- the boot path writes partials today; the assertion is the guard rail if +-- someone shortcuts it. SaveData.saveOptions({ battleLayout = "og" }, hop) -eq(SaveData.loadOptions(hop).lastVersion, nil, - "a partial write drops launcher-only keys, so the game must write the " - .. "table loadOptions handed it") +eq(SaveData.loadOptions(hop).lastVersion, "blue", + "a partial write no longer drops launcher-only keys (#932)") --- Known gap, deliberately not asserted: a copy taken BEFORE the launcher's --- write and flushed after it still wins, because saveOptions merges only --- modOptions from disk and every other key is last-writer-wins. Measured, --- not guessed (og beats a newer wide). No shipping path holds an options --- table across a launcher write -- HostShell.restart replaces the process on --- the way back to the launcher (#785, #575) and LauncherSettings.open notes --- its own cached table is only true while its modal covers the launcher -- --- so closing that gap needs a three-way merge (baseline vs caller vs disk), --- not a straight "disk wins", which would throw away real in-game changes. +-- Known gap, deliberately not asserted: a FULL copy taken BEFORE the +-- launcher's write and flushed after it still wins -- a table holding every +-- defaultOptions key is authoritative, so its og is never folded against a +-- newer wide on disk (#932 closes the PARTIAL-write drop, not this). +-- Measured, not guessed. No shipping path holds an options table across a +-- launcher write -- HostShell.restart replaces the process on the way back +-- to the launcher (#785, #575) and LauncherSettings.open notes its own +-- cached table is only true while its modal covers the launcher -- so +-- closing that gap needs a real three-way baseline (vs caller vs disk), not +-- a straight "disk wins", which would throw away real in-game changes. T.finish("options_write_readback_bug828") From af00d6ad1466428959d6b1bf722b7889aaf39f1c Mon Sep 17 00:00:00 2001 From: MaxTomahawk Date: Fri, 7 Aug 2026 17:14:42 +0200 Subject: [PATCH 24/63] feat: expose storage engine compatibility context --- docs/modding.md | 4 ++++ docs/rfcs/0003-playthrough-storage.md | 5 +++-- src/mods/Storage.lua | 7 ++++++- tests/modkit/cases/storage.lua | 8 ++++++-- 4 files changed, 19 insertions(+), 5 deletions(-) diff --git a/docs/modding.md b/docs/modding.md index fbff19f9..6c45ef92 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -158,6 +158,10 @@ local keys, code, message = mod.storage:list(game, "history/quick") local deleted, code, message = mod.storage:delete(game, "history/quick/q0001") ``` +`context` returns `{ engineVersion, gameVersion, playthroughId }`. The engine +version is compatibility metadata; physical launcher-slot and path identity stays +private. + Values must be tables containing serializable data only. Keys are conservative slash-separated segments (letters, digits, `_`, `-`); paths and filesystem handles are never exposed. Writes are staged and decode-verified, reads recover diff --git a/docs/rfcs/0003-playthrough-storage.md b/docs/rfcs/0003-playthrough-storage.md index d95fddb2..96c574e2 100644 --- a/docs/rfcs/0003-playthrough-storage.md +++ b/docs/rfcs/0003-playthrough-storage.md @@ -51,10 +51,11 @@ honors injected test filesystems; it is not exposed on the mod object. Returns: ```lua -{ gameVersion = "red", playthroughId = "..." } +{ engineVersion = "0.9.0", gameVersion = "red", playthroughId = "..." } ``` -or `nil, code, message`. It intentionally omits launcher slot ids and paths. +or `nil, code, message`. `engineVersion` is warning-grade compatibility metadata; +the context intentionally omits launcher slot ids and paths. ### `mod.storage:write(game, key, value)` diff --git a/src/mods/Storage.lua b/src/mods/Storage.lua index d5f3a324..e529106c 100644 --- a/src/mods/Storage.lua +++ b/src/mods/Storage.lua @@ -3,6 +3,7 @@ local SaveData = require("src.core.SaveData") local SaveSerializer = require("src.core.SaveSerializer") +local Version = require("src.core.Version") local Storage = {} Storage.__index = Storage @@ -81,7 +82,11 @@ end function Storage:context(game) local scope, code, message = self:_scope(game) if not scope then return nil, code, message end - return { gameVersion = scope.gameVersion, playthroughId = scope.playthroughId } + return { + engineVersion = Version.engine, + gameVersion = scope.gameVersion, + playthroughId = scope.playthroughId, + } end function Storage:_names(game, key, allowEmpty) diff --git a/tests/modkit/cases/storage.lua b/tests/modkit/cases/storage.lua index 52b0f886..56564c31 100644 --- a/tests/modkit/cases/storage.lua +++ b/tests/modkit/cases/storage.lua @@ -7,6 +7,7 @@ love = love or require("tests.love_stub") local T = require("tests.harness").suite("mod storage") local Loader = require("src.mods.Loader") local Runtime = require("src.mods.Runtime") +local Version = require("src.core.Version") local savedEvents, savedHooks = Runtime.events, Runtime.hooks @@ -90,8 +91,11 @@ end -- Removing scope identity or exposing a mutable private slot id breaks this. local context = alpha:context(current) -T.same(context, { gameVersion = "red", playthroughId = "play-a" }, - "context exposes stable game/playthrough identity only") +T.same(context, { + engineVersion = Version.engine, + gameVersion = "red", + playthroughId = "play-a", +}, "context exposes stable engine/game/playthrough compatibility identity") -- Data-only write/read. The literal expected table is independent of storage. local payload = { format = 1, nested = { money = 1234 }, flags = { a = true } } From 02fd21bcdb7bc46ac16565a76b3100c869ecd4ea Mon Sep 17 00:00:00 2001 From: MaxTomahawk Date: Fri, 7 Aug 2026 17:54:06 +0200 Subject: [PATCH 25/63] fix: honor source date in mod packages --- tests/modkit_tests.lua | 46 ++++++++++++++++++++++++++++++++++++++++++ tools/modkit.py | 21 +++++++++++++++++-- 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/tests/modkit_tests.lua b/tests/modkit_tests.lua index 3a688793..90b795f9 100644 --- a/tests/modkit_tests.lua +++ b/tests/modkit_tests.lua @@ -636,6 +636,52 @@ local packed = io.open(cleanPkg, "rb") check(packed ~= nil, "pack writes the package") if packed then packed:close() end +-- Reproducible-build callers pin the informational pack timestamp through the +-- standard SOURCE_DATE_EPOCH contract. Two clean invocations over the same +-- input must then produce identical archive bytes and metadata. +local epoch = "1234567890" +local envPrefix = isWindows + and ('set "SOURCE_DATE_EPOCH=%s" && '):format(epoch) + or ("SOURCE_DATE_EPOCH=%s "):format(epoch) +local deterministicA = root .. "/declared-a.modpkg" +local deterministicB = root .. "/declared-b.modpkg" +out, code = run(envPrefix .. + ("%s tools/modkit.py pack %q -o %q --base fixture") + :format(python, declared, deterministicA)) +check(code == 0, "SOURCE_DATE_EPOCH package A succeeds: " .. out) +out, code = run(envPrefix .. + ("%s tools/modkit.py pack %q -o %q --base fixture") + :format(python, declared, deterministicB)) +check(code == 0, "SOURCE_DATE_EPOCH package B succeeds: " .. out) +local archiveA = assert(io.open(deterministicA, "rb")) +local bytesA = archiveA:read("*a") +archiveA:close() +local archiveB = assert(io.open(deterministicB, "rb")) +local bytesB = archiveB:read("*a") +archiveB:close() +check(bytesA == bytesB, "SOURCE_DATE_EPOCH makes package bytes reproducible") +local inspectPack = root .. "/inspect_pack.py" +write(inspectPack, [[ +import json, sys, zipfile +with zipfile.ZipFile(sys.argv[1]) as archive: + meta = json.loads(archive.read(".modkit/pack.json")) +assert meta["packed_at"] == "2009-02-13T23:31:30Z", meta["packed_at"] +]]) +out, code = run(("%s %q %q"):format(python, inspectPack, deterministicA)) +check(code == 0, "pack metadata honors SOURCE_DATE_EPOCH: " .. out) +local invalidEpochPrefix = isWindows + and 'set "SOURCE_DATE_EPOCH=not-a-time" && ' + or "SOURCE_DATE_EPOCH=not-a-time " +local invalidEpochPkg = root .. "/declared-invalid-epoch.modpkg" +out, code = run(invalidEpochPrefix .. + ("%s tools/modkit.py pack %q -o %q --base fixture") + :format(python, declared, invalidEpochPkg)) +check(code == 2, "invalid SOURCE_DATE_EPOCH is a usage failure: " .. out) +check(out:find("SOURCE_DATE_EPOCH", 1, true) ~= nil, + "invalid source epoch names the failed contract") +check(io.open(invalidEpochPkg, "rb") == nil, + "invalid source epoch writes no package") + -- MK305 diffs shipped tables against the imported dataset; fake one under -- a scratch repo root so the check exercises the same on ROM-less machines local fake = root .. "/fakerepo" diff --git a/tools/modkit.py b/tools/modkit.py index 522b8008..4eaf7f8b 100644 --- a/tools/modkit.py +++ b/tools/modkit.py @@ -1085,6 +1085,20 @@ def cmd_lint(args, repo): # ---------------------------------------------------------------- pack +def pack_timestamp(): + raw = os.environ.get("SOURCE_DATE_EPOCH") + if raw is None: + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), None + try: + epoch = int(raw, 10) + if epoch < 0: + raise ValueError("negative epoch") + stamp = datetime.fromtimestamp(epoch, timezone.utc) + except (ValueError, OverflowError, OSError): + return None, "SOURCE_DATE_EPOCH must be a nonnegative Unix timestamp" + return stamp.strftime("%Y-%m-%dT%H:%M:%SZ"), None + + def cmd_pack(args, repo): mod_dir = resolve_mod_dir(repo, args.mod) if not mod_dir: @@ -1119,6 +1133,10 @@ def cmd_pack(args, repo): mod_id = manifest["id"] version = manifest.get("version", "0.0.0") out = args.output or f"{mod_id}-{version}.modpkg" + packed_at, timestamp_problem = pack_timestamp() + if timestamp_problem: + print(f"modkit: {timestamp_problem}") + return 2 files = mod_files(mod_dir) records = [] for rel in files: @@ -1127,8 +1145,7 @@ def cmd_pack(args, repo): "sha256": hashlib.sha256(body).hexdigest()}) pack_meta = { "modkit": MODKIT_VERSION, - "packed_at": datetime.now(timezone.utc) - .strftime("%Y-%m-%dT%H:%M:%SZ"), + "packed_at": packed_at, "id": mod_id, "version": version, "api": manifest.get("api", 1), From d9dc42895f09d8cd42ed770d8271ddb5b4231ea9 Mon Sep 17 00:00:00 2001 From: AverageConsumer <35539970+AverageConsumer@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:10:22 +0200 Subject: [PATCH 26/63] chore: retrigger CI From 3c57b28c6d164058efc9326b398969f31b703151 Mon Sep 17 00:00:00 2001 From: Bortlesboat <169967362+Bortlesboat@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:16:06 -0400 Subject: [PATCH 27/63] ci: retrigger cancelled checks From c1e0685e9753962613389db5df2f4370df2983ab Mon Sep 17 00:00:00 2001 From: AverageConsumer <35539970+AverageConsumer@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:20:57 +0200 Subject: [PATCH 28/63] feat(mods): expose read-only map overview --- src/world/WorldAPI.lua | 20 ++++++++++++++++++ tests/engine/world_map_overview_test.lua | 26 ++++++++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 tests/engine/world_map_overview_test.lua diff --git a/src/world/WorldAPI.lua b/src/world/WorldAPI.lua index 6adadc51..66aa493c 100644 --- a/src/world/WorldAPI.lua +++ b/src/world/WorldAPI.lua @@ -43,6 +43,26 @@ function WorldAPI:current() facing = p and p.facing } end +-- A compact, read-only view of the active map for minimaps and companion UIs. +-- Rows use " " for blocked terrain, "." for walkable land, "~" for water +-- and "+" for a door or warp. +function WorldAPI:mapOverview() + local ow = self:overworld() + if not ow or not ow.map then return nil, NO_OVERWORLD end + local map, rows = ow.map, {} + for y = 0, map.heightCells - 1 do + local row = {} + for x = 0, map.widthCells - 1 do + row[#row + 1] = map:isWarpTileCell(x, y) and "+" + or map:isWaterCell(x, y) and "~" + or map:isWalkableCell(x, y) and "." or " " + end + rows[#rows + 1] = table.concat(row) + end + return { mapId = map.id, width = map.widthCells, + height = map.heightCells, rows = rows } +end + -- opts.arrive = "fly" | "teleport" picks the arrival FX; anything else -- lands the player without one, like a scripted warp. function WorldAPI:warpTo(mapId, x, y, facing, opts) diff --git a/tests/engine/world_map_overview_test.lua b/tests/engine/world_map_overview_test.lua new file mode 100644 index 00000000..054fea68 --- /dev/null +++ b/tests/engine/world_map_overview_test.lua @@ -0,0 +1,26 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local WorldAPI = require("src.world.WorldAPI") + +local api = WorldAPI.new({ stack = { states = {} } }, "tester") +local overview, err = api:mapOverview() +T.eq(overview, nil, "map overview is unavailable outside the overworld") +T.eq(err, "no overworld", "map overview reports why it is unavailable") + +local map = { id = "TEST_MAP", widthCells = 2, heightCells = 2 } +function map:isWarpTileCell(x, y) return x == 1 and y == 0 end +function map:isWaterCell(x, y) return x == 0 and y == 1 end +function map:isWalkableCell(x, y) return x == 0 and y == 0 end + +api = WorldAPI.new({ stack = { states = { + { isOverworld = true, map = map }, +} } }, "tester") +overview = api:mapOverview() +T.eq(overview.mapId, "TEST_MAP", "map overview identifies the active map") +T.eq(overview.width, 2, "map overview reports its width") +T.eq(overview.height, 2, "map overview reports its height") +T.eq(overview.rows[1], ".+", "walkable land and warps are distinct") +T.eq(overview.rows[2], "~ ", "water and blocked terrain are distinct") + +T.finish("world map overview") From 5b0209a7ce54615f8cb01717679dc8f77cfc273d Mon Sep 17 00:00:00 2001 From: Yukita Mayako Date: Fri, 7 Aug 2026 02:49:30 -0400 Subject: [PATCH 29/63] feat(TitleState): add field.title.player --- src/ui/TitleState.lua | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/ui/TitleState.lua b/src/ui/TitleState.lua index 6ee89618..aec5c71a 100644 --- a/src/ui/TitleState.lua +++ b/src/ui/TitleState.lua @@ -176,7 +176,8 @@ function TitleState.new(game, opts) self.versionFull = imagePath(title.versionRibbon) ~= nil self.version = tryImage(imagePath(title.versionRibbon or title.version) or "assets/generated/title/red_version.png") - self.player = tryImage("assets/generated/title/player.png") + self.player = tryImage(imagePath(title.player) + or "assets/generated/title/player.png") self.blue = GameVersion.isBlue() self.yellow = GameVersion.isYellow() or title.layout == "yellow_pikachu" From abb4f3f9f059d48cb25c5b1bbe5658ad46bfabf3 Mon Sep 17 00:00:00 2001 From: Hanan Rodebaugh Date: Fri, 7 Aug 2026 20:24:11 -0400 Subject: [PATCH 30/63] better mod item registry --- src/inventory/ItemEffects.lua | 47 ++++++++++++++++++++++++++++++++++- src/ui/BagMenu.lua | 14 ++++++++++- 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/src/inventory/ItemEffects.lua b/src/inventory/ItemEffects.lua index 63f74228..2dd49e1d 100644 --- a/src/inventory/ItemEffects.lua +++ b/src/inventory/ItemEffects.lua @@ -25,6 +25,18 @@ local function noEffect(data) return romText(data, "_ItemUseNoEffectText", "It won't have\nany effect.") end +local function registeredEffect(data, itemDef) + if not data or not itemDef or not itemDef.effect then + return nil + end + + if not data.item_effects then + return nil + end + + return data.item_effects[itemDef.effect] +end + local HEAL_AMOUNT = { POTION = 20, SUPER_POTION = 50, HYPER_POTION = 200, FRESH_WATER = 50, SODA_POP = 60, LEMONADE = 80, @@ -72,7 +84,18 @@ function ItemEffects.healsHP(id) end -- Does this item need a party-member target? -function ItemEffects.needsTarget(id, itemDef) +-- 'data' is optional for compat purposes; targeting falls back to itemDef/vanilla detection +function ItemEffects.needsTarget(id, itemDef, data) + if itemDef and itemDef.needsTarget ~= nil then + return itemDef.needsTarget + end + + local effect = registeredEffect(data, itemDef) + + if effect and effect.needsTarget ~= nil then + return effect.needsTarget + end + return HEAL_AMOUNT[id] or STATUS_HEAL[id] or id == "MAX_POTION" or id == "FULL_RESTORE" or id == "REVIVE" or id == "MAX_REVIVE" or id == "RARE_CANDY" or STONES[id] @@ -147,6 +170,28 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) local itemDef = data.items[itemId] local name = itemDef and itemDef.name or itemId + local effectDef = registeredEffect(data, itemDef) + if effectDef then + if battle and effectDef.battle == false then + return "failed", { notTime(data, save) } + end + + if not battle and effectDef.field == false then + return "failed", { notTime(data, save) } + end + + return effectDef.use({ + data = data, + save = save, + itemId = itemId, + item = itemDef, + target = target, + battle = battle, + moveIndex = moveIndex, + overworld = ow, + }) + end + -- ItemUseVitamin / ItemUsePPUp / ItemUseEvoStone / ItemUseCoinCase / -- ItemUseTMHM / ItemUseRepelCommon all refuse mid-battle -- (jp nz, ItemUseNotTime) diff --git a/src/ui/BagMenu.lua b/src/ui/BagMenu.lua index 650b364d..1a2c59ee 100644 --- a/src/ui/BagMenu.lua +++ b/src/ui/BagMenu.lua @@ -253,6 +253,18 @@ local function useOn(game, battle, id, target, list, moveIndex, picker) return end + if result == "kept" then + if battle then + list:close() + showMessages(game, payload, function() + battle:itemUsed({}) + end) + else + showMessages(game, payload, closePicker) + end + return + end + if result == "consumed" then consume(game, id) -- refresh counts in the list @@ -404,7 +416,7 @@ local function useItem(game, battle, id, list) showMessages(game, payload) return end - if ItemEffects.needsTarget(id, def) and not ItemEffects.isBall(id) then + if ItemEffects.needsTarget(id, def, game.data) and not ItemEffects.isBall(id) then -- TMs/HMs boot up and announce their move before the target picker -- (ItemUseTMHM: BootedUpTMText / BootedUpHMText + TeachMachineMoveText) if def and def.machine then From 1e6f504a84e8b1507ffa8dff4aae03a038b187c8 Mon Sep 17 00:00:00 2001 From: hernan Date: Fri, 7 Aug 2026 22:13:50 -0400 Subject: [PATCH 31/63] iOS picker: a "stadium" kind, and a way to ask which kinds exist Two things, the second of which is the reason the first is safe. A NEW KIND. pickFile("stadium") opens the document picker for a Nintendo 64 cartridge and lands it as picked_stadium.z64. The caller I wrote it for is the Dramatic Shape voxel mod, which builds Pokemon Stadium battle models out of the player's own cartridge -- on desktop it opens a dialog for that, and on iOS it could only print a sandbox path to a screen and ask the player to put a file somewhere they cannot reach from a phone. Its own filename, not picked_rom.gb, because that is the name the Game Boy importer watches: a 32 MB N64 ROM landing there is deleted and then reported to the player as a broken cartridge. WHICH IS ALSO WHAT AN UNKNOWN KIND USED TO DO. The switch's default case treated anything it did not recognise as a Game Boy ROM, so a caller asking for a kind the build predates lost the player's file -- the worst available answer to "I have not heard of that one". Unknown kinds are refused now. That refusal is invisible on its own: pickFile returns false, which is also what "the picker would not open" returns, and a mod cannot tell them apart. So the host says what it knows -- love.system.pickFileKinds() returns "rom,mod,sav,stadium", or nil where there is no bridge. A caller asks first and keeps whatever fallback it had; the voxel mod shows its folder note again, which is what it did before any picker existed. Tested on an iPhone 17 Pro: a Stadium cartridge imports from the picker and the models build. --- mobile/ios/native/GRPickerBridge.swift | 35 +++++++++++++++++++- mobile/ios/patch_love_src.py | 44 ++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/mobile/ios/native/GRPickerBridge.swift b/mobile/ios/native/GRPickerBridge.swift index 9f3e3966..1793f7c4 100644 --- a/mobile/ios/native/GRPickerBridge.swift +++ b/mobile/ios/native/GRPickerBridge.swift @@ -85,11 +85,30 @@ public final class GRPickerBridge: NSObject { types = [.zip] case "sav": destName = "picked_save.sav" - default: + // A Nintendo 64 cartridge, for mods that build assets out of one -- + // the voxel mod's Pokemon Stadium battle models are the caller this + // was added for. Its own filename on purpose: an N64 ROM landing on + // picked_rom.gb is swept up by the Game Boy importer, deleted, and + // reported to the player as a broken cartridge. + case "stadium": + destName = "picked_stadium.z64" + for ext in ["z64", "n64", "v64"] { + if let t = UTType(filenameExtension: ext) { types.append(t) } + } + case "rom", "": destName = "picked_rom.gb" for ext in ["gb", "gbc"] { if let t = UTType(filenameExtension: ext) { types.append(t) } } + // An unknown kind is REFUSED rather than treated as a Game Boy ROM. + // + // It used to fall through to picked_rom.gb, so a caller asking for a + // kind this build had never heard of got its file deleted and + // reported as a broken cartridge -- the worst possible answer to + // "I do not know that one". Returning false lets the caller find out + // and offer its own fallback. + default: + return false } // .gb/.gbc/.sav resolve to dynamic UTTypes on most devices; offering // .data as well keeps every real file selectable. The importer @@ -107,6 +126,20 @@ public final class GRPickerBridge: NSObject { return present(picker, with: delegate) } + // Which kinds presentPicker understands, comma separated. + // + // So a CALLER can ask before it calls. A mod that wants a kind this build + // predates cannot otherwise tell "refused" from "the picker would not + // open", and guessing wrong used to cost the player their ROM (see the + // default case above). Asking first turns that into a fallback the caller + // chooses rather than a file it loses. + // + // Kept beside the switch it describes, because the two drifting apart is + // the only way this can lie. + @objc public static func supportedPickerKinds() -> NSString { + return "rom,mod,sav,stadium" as NSString + } + @objc(presentExportWithName:saveDir:) public static func presentExport(name: UnsafePointer?, saveDir: UnsafePointer?) -> Bool { diff --git a/mobile/ios/patch_love_src.py b/mobile/ios/patch_love_src.py index ef9d1b39..d640bee7 100644 --- a/mobile/ios/patch_love_src.py +++ b/mobile/ios/patch_love_src.py @@ -84,6 +84,49 @@ int w_pickFile(lua_State *L) return gr_callBridge(L, "GRPickerBridge", "presentPickerWithKind:saveDir:", kind); } +// love.system.pickFileKinds() -> "rom,mod,sav,stadium", or nil off iOS. +// +// So a caller can ask what this build's picker understands BEFORE opening it. +// An unknown kind is refused (GRPickerBridge), and a refusal looks exactly +// like a picker that would not open -- so a caller with a fallback worth +// showing needs to know which it is facing. A mod that guesses instead has +// no way back: before the refusal landed, an unrecognised kind wrote +// picked_rom.gb and the ROM importer deleted it. +// +// nil where there is no bridge at all, which reads the same as "no kinds". +int w_pickFileKinds(lua_State *L) +{ + Class cls = objc_getClass("GRPickerBridge"); + if (cls == nullptr) + { + lua_pushnil(L); + return 1; + } + // Fetched through the runtime: wrap_System.cpp is compiled as C++ rather + // than Objective-C++, so no Foundation type may be NAMED here -- writing + // `NSString` alone breaks the whole translation unit. objc_msgSend is a + // plain C entry point and `id` comes from objc/runtime.h, so the string + // is asked for its UTF8 bytes without ever being typed. + typedef id (*GRObj)(Class, SEL); + id kinds = ((GRObj)objc_msgSend)(cls, + sel_registerName("supportedPickerKinds")); + if (kinds == nullptr) + { + lua_pushnil(L); + return 1; + } + typedef const char *(*GRUTF8)(id, SEL); + const char *bytes = ((GRUTF8)objc_msgSend)(kinds, + sel_registerName("UTF8String")); + if (bytes == nullptr || bytes[0] == '\0') + { + lua_pushnil(L); + return 1; + } + lua_pushstring(L, bytes); + return 1; +} + int w_createFile(lua_State *L) { const char *name = luaL_optstring(L, 1, "export.sav"); @@ -101,6 +144,7 @@ int w_syncHealthSteps(lua_State *L) WRAP_REGISTRATION = """#ifdef LOVE_IOS { "pickFile", w_pickFile }, + { "pickFileKinds", w_pickFileKinds }, { "createFile", w_createFile }, { "syncHealthSteps", w_syncHealthSteps }, { "httpDownload", w_httpDownload }, From d756b7cd4325a4acd7d8ccd16ac5cd54484d2fb7 Mon Sep 17 00:00:00 2001 From: hernan Date: Fri, 7 Aug 2026 22:14:29 -0400 Subject: [PATCH 32/63] OPTIONS: route CANCEL through Strings so a translation mod can reach it CANCEL is appended after the `ui.options.rows` hook, deliberately -- that is what stops a mod from orphaning the exit. But it also means no translation mod can ever see it: there is no row for one to rewrite, and the hook has already run by the time it is added. The result is that a fully translated OPTIONS menu has exactly one English word left on it, and it is the way out. I hit this with a Spanish catalog where every row translated and the exit did not. One call, matching how every other label on this screen is already built. Nothing changes without a catalog loaded: Strings is an identity function until a mod supplies one. Follows the same reasoning as #791. --- src/ui/OptionsMenu.lua | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/ui/OptionsMenu.lua b/src/ui/OptionsMenu.lua index 1187dc6c..c44f5718 100644 --- a/src/ui/OptionsMenu.lua +++ b/src/ui/OptionsMenu.lua @@ -585,8 +585,14 @@ function OptionsMenu:update(dt) end function OptionsMenu:draw() + -- Through Strings, like every other label on this menu. CANCEL is + -- appended AFTER the rows hook (see the header), which is what keeps a mod + -- from orphaning the exit -- but it also means a translation mod never sees + -- this string, and cannot: there is no row for it to rewrite. So the one + -- word a Spanish player could not read on a fully translated OPTIONS menu + -- was the way out of it. OptionRows.draw(self.game, self.rows, self.index, self.scroll or 0, - "CANCEL", #self.rows + 1) + Strings("CANCEL"), #self.rows + 1) end return OptionsMenu From 83f93c27a1bd0452a633482ffd69b65fc45556f4 Mon Sep 17 00:00:00 2001 From: hernan Date: Fri, 7 Aug 2026 22:16:06 -0400 Subject: [PATCH 33/63] mods: a Spanish translation of the app's own text A LANGUAGE-profile mod filling the `strings` registry -- the seam #791 opened up, used for the thing it was opened for. 566 keys: the launcher, OPTIONS, the save-slot and profile screens, the mod manager, the naming screen, and the battle menu. WHAT IT DOES NOT TOUCH is the cartridge. Dialogue, species, items, moves and places all come out of the player's ROM and stay exactly as they are, so an English cartridge is still an English adventure with Spanish menus around it. The lang/ tables for those ship empty on purpose rather than absent: they are where a full translation would go, and an empty value falls through to English, so anyone continuing this can fill one row at a time and the game stays playable throughout. THE FONT IS THE CONSTRAINT, and it decided the wording. The 8x8 charmap has no N-tilde, no accented vowels and no inverted punctuation -- the sole exception in the whole atlas is the small e-acute of POKeMON. So every value on an 8x8 surface is plain A-Z: DISENO COMBATE, MESETA ANIL, SEGURO? OTRA VEZ. Not a spelling preference; a glyph that is missing renders as a hole, which is how the first cut of this shipped "ESPA OL" to a phone. The launcher draws with a real font and keeps proper Spanish, inverted marks and all -- the split is by surface, not by taste. Adding the glyphs to the atlas would let the 8x8 side read properly too, and lang/font.lua and lang/charmap.lua are already the place that would hook into. I have not done it here: it is a separate change with its own taste questions, and it should not ride in on a catalog. Tested end to end on iOS and macOS. --- mods/spanish_ui/README.md | 27 ++ mods/spanish_ui/TRANSLATING.md | 111 +++++ mods/spanish_ui/assets/font/README.md | 11 + mods/spanish_ui/lang/charmap.lua | 10 + mods/spanish_ui/lang/dialogue.lua | 12 + mods/spanish_ui/lang/font.lua | 13 + mods/spanish_ui/lang/item_names.lua | 11 + mods/spanish_ui/lang/move_names.lua | 10 + mods/spanish_ui/lang/naming.lua | 41 ++ mods/spanish_ui/lang/species_names.lua | 9 + mods/spanish_ui/lang/status_labels.lua | 11 + mods/spanish_ui/lang/strings.lua | 584 +++++++++++++++++++++++++ mods/spanish_ui/lang/trainer_names.lua | 7 + mods/spanish_ui/main.lua | 127 ++++++ mods/spanish_ui/manifest.json | 17 + 15 files changed, 1001 insertions(+) create mode 100644 mods/spanish_ui/README.md create mode 100644 mods/spanish_ui/TRANSLATING.md create mode 100644 mods/spanish_ui/assets/font/README.md create mode 100644 mods/spanish_ui/lang/charmap.lua create mode 100644 mods/spanish_ui/lang/dialogue.lua create mode 100644 mods/spanish_ui/lang/font.lua create mode 100644 mods/spanish_ui/lang/item_names.lua create mode 100644 mods/spanish_ui/lang/move_names.lua create mode 100644 mods/spanish_ui/lang/naming.lua create mode 100644 mods/spanish_ui/lang/species_names.lua create mode 100644 mods/spanish_ui/lang/status_labels.lua create mode 100644 mods/spanish_ui/lang/strings.lua create mode 100644 mods/spanish_ui/lang/trainer_names.lua create mode 100644 mods/spanish_ui/main.lua create mode 100644 mods/spanish_ui/manifest.json diff --git a/mods/spanish_ui/README.md b/mods/spanish_ui/README.md new file mode 100644 index 00000000..b844ac80 --- /dev/null +++ b/mods/spanish_ui/README.md @@ -0,0 +1,27 @@ +# spanish_ui + +A Espanol translation of the game. + +Generated with `python3 tools/modkit.py translation spanish_ui`. See +`TRANSLATING.md` for how to work on it. + +## Status + +Nothing is translated yet: 601 strings are waiting in `lang/`. + +| Catalog | Entries | +|---|---| +| `lang/dialogue.lua` | 6 | +| `lang/strings.lua` | 577 | +| `lang/species_names.lua` | 3 | +| `lang/move_names.lua` | 4 | +| `lang/item_names.lua` | 5 | +| `lang/trainer_names.lua` | 1 | +| `lang/status_labels.lua` | 5 | + +## Layout + +- `manifest.json` - identity and the engine version range +- `main.lua` - registers whatever is filled in and skips whatever is not +- `lang/` - the catalogs; this is the whole job +- `assets/font/` - your glyph sheet diff --git a/mods/spanish_ui/TRANSLATING.md b/mods/spanish_ui/TRANSLATING.md new file mode 100644 index 00000000..f1cca9bc --- /dev/null +++ b/mods/spanish_ui/TRANSLATING.md @@ -0,0 +1,111 @@ +# Translating into Espanol + +Everything the player can read is one of two kinds of string, and they live +in different places for a reason. + +| lang/ file | What it is | Key | +|---|---|---| +| `dialogue.lua` | Every line of extracted script text | the original label, e.g. `_PalletTownText1` | +| `strings.lua` | Text the engine itself writes: battle messages, menus, link play | the English source string | +| `species.lua` `moves.lua` `items.lua` `trainers.lua` | Names | the vanilla id | +| `statuses.lua` | `PSN`, `BRN`, ... as they appear in the HUD | the status id | +| `font.lua` `charmap.lua` | Your glyph sheet and what draws what | see below | +| `naming.lua` | The letter grid for entering names | - | + +Fill in a value and it takes effect. Leave it `""` and that string stays in +English, so the game is playable at every point along the way. + +## Where the English is + +The catalogs hold keys and *your* text, never the original English. The +English lives next door, in `spanish_ui-worksheet/`, one tab-separated file per +catalog: + +``` +"_AbandonLearningText" "Abandon learning\n{RAM:wStringBuffer}?" +``` + +That directory is deliberately outside the mod. Extracted script text and +the vanilla names are ROM content, and `modkit pack` zips everything under +the mod directory, so a worksheet kept inside would end up in your release +whatever a `.gitignore` said. Keep it beside the mod, never in it. + +`lang/strings.lua` is the exception: those sources are the engine's own Lua +rather than anything out of the ROM, so there the key *is* the English and +you can translate straight from it. + +## Start with the font, not the text + +The engine draws from **glyph pages**: an image of 8x8 cells plus a charmap +saying which byte sequence draws which cell. The vanilla pages sit at `$60` +and `$80`. Anything from `0x100` up is free, so a new alphabet is added +rather than swapped in: + +```lua +-- lang/font.lua +return { + spanish_ui = { + image = "assets/font/spanish_ui.png", + base = 0x100, -- first code this page owns + glyphsPerRow = 16, + -- advance = 8, -- set this if your glyphs are not 8px wide + }, +} +``` + +```lua +-- lang/charmap.lua: sequence -> code, in the same order as the sheet +return { + ["A"] = 0x100, + ["B"] = 0x101, +} +``` + +The sheet is a plain PNG, 16 glyphs to a row by default, each cell 8x8, +black on white like `assets/generated/font.png`. Codes run left to right, +top to bottom from `base`. + +Sequences are matched **longest first**, so a multi-byte character and a +multi-character ligature both work and neither shadows the other: + +```lua +["\u{3042}"] = 0x120, -- one 3-byte character, one glyph +["ch"] = 0x121, -- two ASCII letters, one glyph +``` + +## Line length is counted in glyphs + +The dialogue box fits 18 glyphs a line, not 18 bytes. A 3-byte character +costs one column, and the engine will never cut a character in half. Your +own `\n` line breaks are respected exactly as written, so break lines where +they read best rather than where they fit English. + +If your glyphs are not 8px wide, set `advance` on the page and the box +re-measures. + +## Format directives must survive + +Some sources carry `%s` or `%d`: + +```lua +["Wild %s\nappeared!"] = "...", +``` + +Keep every directive, in a count that matches. Word order is yours to +change; the engine substitutes in the order the directives appear, so if +your language needs the name last, write the sentence with the `%s` last. +A translation whose directive count does not match the English is refused +at runtime and the English is drawn instead, with a line in the log saying +so - it will not crash a battle. + +## Checking your work + +```sh +python3 tools/modkit.py validate spanish_ui --base imported +python3 tools/modkit.py translation spanish_ui --refresh # pick up new engine strings +POKEPORT_DEV=1 scripts/run.sh # F5 hot-reloads lang/ +``` + +`--refresh` rewrites the catalogs from the current engine, keeping every +translation you have already written and reporting what changed. Run it +after pulling a new engine version. diff --git a/mods/spanish_ui/assets/font/README.md b/mods/spanish_ui/assets/font/README.md new file mode 100644 index 00000000..8f0f5890 --- /dev/null +++ b/mods/spanish_ui/assets/font/README.md @@ -0,0 +1,11 @@ +Put your glyph sheet here. + +A page is a PNG of 8x8 cells, 16 per row by default, black on white. Codes +run left to right and top to bottom starting at the page's `base`, so the +first cell is `base`, the second `base + 1`, and so on. + +`assets/generated/font.png` in the player's cache is the vanilla sheet at +the same scale; open it alongside yours to match weight and baseline. + +Declare the sheet in `lang/font.lua` and map sequences to codes in +`lang/charmap.lua`. diff --git a/mods/spanish_ui/lang/charmap.lua b/mods/spanish_ui/lang/charmap.lua new file mode 100644 index 00000000..ca0e3140 --- /dev/null +++ b/mods/spanish_ui/lang/charmap.lua @@ -0,0 +1,10 @@ +-- Which byte sequence draws which glyph code. +-- +-- Sequences are matched longest-first, so a multi-byte character and a +-- multi-character ligature both work: "ch" can be one glyph even though +-- "c" is also mapped. Codes here must land inside a page declared in +-- lang/font.lua. +return { + -- ["A"] = 0x100, + -- ["B"] = 0x101, +} diff --git a/mods/spanish_ui/lang/dialogue.lua b/mods/spanish_ui/lang/dialogue.lua new file mode 100644 index 00000000..8d3c16f3 --- /dev/null +++ b/mods/spanish_ui/lang/dialogue.lua @@ -0,0 +1,12 @@ +-- Script text +-- +-- Keyed by the original text label. The English is in the comment. + +return { + ["_FixMartText"] = "", + ["_FixRouteTrainerAfterText"] = "", + ["_FixRouteTrainerBattleText"] = "", + ["_FixRouteTrainerEndText"] = "", + ["_FixTownGreeterText"] = "", + ["_FixTownSignText"] = "", +} diff --git a/mods/spanish_ui/lang/font.lua b/mods/spanish_ui/lang/font.lua new file mode 100644 index 00000000..4b878079 --- /dev/null +++ b/mods/spanish_ui/lang/font.lua @@ -0,0 +1,13 @@ +-- Glyph pages this translation adds. Delete the entry if the vanilla +-- alphabet already covers your language. +-- +-- base is the first glyph code the page owns. 0x100 and up is free space +-- above the vanilla $60/$80 pages, so this adds an alphabet rather than +-- replacing one. Set `advance` if your glyphs are not 8px wide. +return { + -- spanish_ui = { + -- image = "assets/font/spanish_ui.png", + -- base = 0x100, + -- glyphsPerRow = 16, + -- }, +} diff --git a/mods/spanish_ui/lang/item_names.lua b/mods/spanish_ui/lang/item_names.lua new file mode 100644 index 00000000..2798d181 --- /dev/null +++ b/mods/spanish_ui/lang/item_names.lua @@ -0,0 +1,11 @@ +-- Item names +-- +-- Item names for Espanol. + +return { + ["FIX_BADGE_1"] = "", + ["FIX_BADGE_2"] = "", + ["FIX_BALL"] = "", + ["FIX_POTION"] = "", + ["FIX_TM"] = "", +} diff --git a/mods/spanish_ui/lang/move_names.lua b/mods/spanish_ui/lang/move_names.lua new file mode 100644 index 00000000..80abf686 --- /dev/null +++ b/mods/spanish_ui/lang/move_names.lua @@ -0,0 +1,10 @@ +-- Move names +-- +-- Move names for Espanol. + +return { + ["FIX_CUT"] = "", + ["FIX_EMBERISH"] = "", + ["FIX_SCRATCH"] = "", + ["FIX_TACKLE"] = "", +} diff --git a/mods/spanish_ui/lang/naming.lua b/mods/spanish_ui/lang/naming.lua new file mode 100644 index 00000000..adb0666b --- /dev/null +++ b/mods/spanish_ui/lang/naming.lua @@ -0,0 +1,41 @@ +-- The naming screen's letter grid. Return an empty table to keep the +-- English alphabet. +-- +-- Each entry is a row of cells; a cell is whatever sequence your charmap +-- maps, so a multi-byte character is one cell. The row holding a single +-- "lower case" / "UPPER CASE" cell is the case switch, and the cell +-- spelled "ED" is the confirm. +-- +-- The screen is 160x144 and NamingScreen draws cell `c` of row `r` at +-- (c * 16, 32 + r * 16), so the grid is capped at **9 columns and 6 rows**: +-- a 10th column lands at x=160 and a 7th row at y=144, both off screen. +-- That leaves 44 usable cells, exactly what vanilla uses, so Spanish +-- letters have to displace something rather than being added. +-- +-- What gives way is vanilla's `× ( ) : ; [ ]` row. Those are legal in a +-- Gen-1 nickname but nobody reaches for them, whereas Ñ is not optional in +-- Spanish -- and here it sits in its alphabetical place after N, which is +-- where a Spanish speaker will look for it. Space, and are kept. +-- +-- These glyphs exist in the Spanish cartridge's font ($CA Ñ, $BF Á, $C7 É, +-- $C9 Í, $CC Ó, $CE Ú, $C2 Ü and their lowercase). On an English ROM they +-- do not, so main.lua checks the running game's charmap first and keeps the +-- English grid rather than drawing blank cells. +return { + upper = { + { "A", "B", "C", "D", "E", "F", "G", "H", "I" }, + { "J", "K", "L", "M", "N", "Ñ", "O", "P", "Q" }, + { "R", "S", "T", "U", "V", "W", "X", "Y", "Z" }, + { "Á", "É", "Í", "Ó", "Ú", "Ü", " ", "", "" }, + { "-", "?", "!", "♂", "♀", "/", ".", ",", "ED" }, + { "lower case" }, + }, + lower = { + { "a", "b", "c", "d", "e", "f", "g", "h", "i" }, + { "j", "k", "l", "m", "n", "ñ", "o", "p", "q" }, + { "r", "s", "t", "u", "v", "w", "x", "y", "z" }, + { "á", "é", "í", "ó", "ú", "ü", " ", "", "" }, + { "-", "?", "!", "♂", "♀", "/", ".", ",", "ED" }, + { "UPPER CASE" }, + }, +} diff --git a/mods/spanish_ui/lang/species_names.lua b/mods/spanish_ui/lang/species_names.lua new file mode 100644 index 00000000..7a4e5645 --- /dev/null +++ b/mods/spanish_ui/lang/species_names.lua @@ -0,0 +1,9 @@ +-- Species names +-- +-- Species names for Espanol. + +return { + ["FIXMON_A"] = "", + ["FIXMON_B"] = "", + ["FIXMON_C"] = "", +} diff --git a/mods/spanish_ui/lang/status_labels.lua b/mods/spanish_ui/lang/status_labels.lua new file mode 100644 index 00000000..b3cd3365 --- /dev/null +++ b/mods/spanish_ui/lang/status_labels.lua @@ -0,0 +1,11 @@ +-- Status labels +-- +-- Short enough for the battle HUD: the vanilla ones are three glyphs. + +return { + ["BRN"] = "", + ["FRZ"] = "", + ["PAR"] = "", + ["PSN"] = "", + ["SLP"] = "", +} diff --git a/mods/spanish_ui/lang/strings.lua b/mods/spanish_ui/lang/strings.lua new file mode 100644 index 00000000..c0858d3c --- /dev/null +++ b/mods/spanish_ui/lang/strings.lua @@ -0,0 +1,584 @@ +-- Engine text +-- +-- Keyed by the English source, which is also what draws if you leave +-- an entry empty. Keep any %s / %d directives. + +return { + ["%s\nflew up high!"] = "¡%s\nvoló muy alto!", + ["%s\ndug a hole!"] = "¡%s\ncavó un hoyo!", + ["%s\nmade a whirlwind!"] = "¡%s\ncreó un torbellino!", + ["%s\ntook in sunlight!"] = "¡%s\nabsorbió luz!", + ["%s\nlowered its head!"] = "¡%s\nbajó la cabeza!", + ["%s\nis glowing!"] = "¡%s\nestá brillando!", + ["The hooked\n%s\nattacked!"] = "¡El %s\nenganchado atacó!", + ["Wild %s\nappeared!"] = "¡Un %s\nsalvaje apareció!", + ["%s wants\nto fight!"] = "¡%s\nquiere luchar!", + ["The GHOST\nappeared!"] = "¡Apareció el\nFANTASMA!", + ["Go! %s!"] = "¡Ve, %s!", + ["Do it! %s!"] = "¡Hazlo, %s!", + ["Get'm! %s!"] = "¡A por él, %s!", + ["The enemy's weak!\nGet'm! %s!"] = "¡Está débil!\n¡A por él, %s!", + ["%s is out of\nuseable POKéMON!"] = "¡%s no tiene\nPOKéMON útiles!", + ["%s blacked\nout!"] = "¡%s se\ndebilitó!", + ["%s sent\nout %s!"] = "¡%s envió\na %s!", + ["PA: You're out of\nSAFARI BALLs!\nGame over!"] = "AV: ¡No te quedan\nSAFARI BALLs!\n¡Fin del juego!", + ["%s is too\nscared to move!"] = "¡%s tiene\ndemasiado miedo!", + ["%s has no\nmoves left!"] = "¡%s no tiene\nmovimientos!", + ["The move is\ndisabled!"] = "¡El movimiento\nestá anulado!", + ["No PP left for\nthis move!"] = "¡No quedan PP para\neste movimiento!", + ["But, it failed!"] = "¡Pero falló!", + ["%s\nlearned\n%s!"] = "¡%s\naprendió\n%s!", + ["POKé BALL"] = "POKé BALL", + ["%s used\nPOKé BALL!"] = "¡%s usó\nPOKé BALL!", + ["All right!\n%s was\ncaught!"] = "¡Bien!\n¡%s fue\ncapturado!", + ["GHOST: Get out...\nGet out..."] = "FANTASMA: Fuera...\nFuera...", + ["%s with-\ndrew %s!"] = "¡%s retiró\na %s!", + ["%s\nmust recharge!"] = "¡%s debe\nrecargarse!", + ["%s\nis fast asleep!"] = "¡%s está\nprofundamente dormido!", + ["%s\nis confused!"] = "¡%s está\nconfuso!", + ["%s\nwoke up!"] = "¡%s se\ndespertó!", + ["%s\nis frozen solid!"] = "¡%s está\ncongelado!", + ["%s\ncan't move!"] = "¡%s no\npuede moverse!", + ["%s\nflinched!"] = "¡%s se\namedrentó!", + ["It hurt itself in\nits confusion!"] = "¡Se hirió a sí\nmismo por confusión!", + ["%s\nused %s!"] = "¡%s usó\n%s!", + ["%s\nis charging up!"] = "¡%s está\ncargando energía!", + ["%s's\nattack missed!"] = "¡El ataque de %s\nfalló!", + ["%s's\nattack continues!"] = "¡El ataque de %s\ncontinúa!", + ["%s\nis storing energy!"] = "¡%s está\nacumulando energía!", + ["%s\nunleashed energy!"] = "¡%s liberó\nsu energía!", + ["%s's\nSUBSTITUTE broke!"] = "¡El SUSTITUTO de\n%s se rompió!", + ["The SUBSTITUTE\ntook damage for\n%s!"] = "¡El SUSTITUTO\nrecibió el daño\nde %s!", + ["%s's\nRAGE is building!"] = "¡La FURIA de %s\nva creciendo!", + ["%s\nfainted!"] = "¡%s se\ndebilitó!", + ["%s gained\n%d EXP. Points!"] = "¡%s ganó\n%d P. EXP.!", + ["%s gained\nwith EXP.ALL,\v%d EXP. Points!"] = "¡%s ganó\ncon EXP.TODOS,\v%d P. EXP.!", + ["%s gained\na boosted\v%d EXP. Points!"] = "¡%s ganó\nun extra de\v%d P. EXP.!", + ["%s grew\nto level %d!"] = "¡%s subió\nal nivel %d!", + ["%s is\nabout to use"] = "%s va a usar", + ["%s!"] = "¡%s!", + ["Will %s\nchange POKéMON?"] = "¿%s va a\ncambiar de POKéMON?", + ["%s defeated\n%s!"] = "¡%s venció\na %s!", + ["%s got ¥%d\nfor winning!"] = "¡%s ganó\n¥%d!", + ["%s learned\n%s!"] = "¡%s aprendió\n%s!", + ["{RIVAL}: Yeah! Am\nI great or what?"] = "{RIVAL}: ¡Sí! ¿Soy\ngenial o qué?", + ["Use next POKéMON?"] = "¿Sacar al siguiente?", + ["Got away safely!"] = "¡Escapaste!", + ["Can't escape!"] = "¡No puedes escapar!", + ["There's no will\nto fight!"] = "¡No hay ganas de\nluchar!", + ["%s used\nSAFARI BALL!"] = "¡%s usó\nSAFARI BALL!", + ["%s threw some\nBAIT."] = "%s echó\nCEBO.", + ["%s threw a\nROCK."] = "%s tiró una\nPIEDRA.", + ["Wild %s\nis eating!"] = "¡El %s\nsalvaje come!", + ["Wild %s\nis angry!"] = "¡El %s\nsalvaje se enfadó!", + ["Wild %s\nran!"] = "¡El %s\nsalvaje huyó!", + ["No! There's no\nrunning from a\vtrainer battle!"] = "¡No! ¡No puedes\nhuir de un combate\vcontra un entrenador!", + ["You missed the\nPOKéMON!"] = "¡Fallaste el tiro!", + ["Darn! The POKéMON\nbroke free!"] = "¡Vaya! ¡El POKéMON\nse escapó!", + ["Aww! It appeared\nto be caught!"] = "¡Oh! ¡Parecía que\nestaba capturado!", + ["Shoot! It was so\nclose too!"] = "¡Vaya! ¡Estuvo\nmuy cerca!", + ["Do you want to\ngive a nickname\nto %s?"] = "¿Quieres poner un\nmote a\n%s?", + ["NICKNAME?"] = "MOTE?", + ["New POKéDEX data\nwill be added for\n%s!"] = "¡Se añadirán datos\nnuevos a la POKéDEX\nde %s!", + ["someone's PC"] = "el PC de alguien", + ["%s was\ntransferred to\n%s!"] = "¡%s fue\ntransferido a\n%s!", + ["But every BOX\nis full!"] = "¡Pero todas las\nCAJAS están llenas!", + ["%s used\n%s!"] = "¡%s usó\n%s!", + ["The trainer\nblocked the BALL!"] = "¡El entrenador\nbloqueó la BALL!", + ["Don't be a thief!"] = "¡No seas ladrón!", + ["It dodged the\nthrown BALL!"] = "¡Esquivó la BALL!", + ["This POKéMON\ncan't be caught!"] = "¡Este POKéMON no\nse puede capturar!", + ["%s is\nalready out!"] = "¡%s ya\nestá fuera!", + ["%s picked up\n¥%d!"] = "¡%s recogió\n¥%d!", + ["FIGHT"] = "LUCHAR", + ["ITEM"] = "OBJETO", + ["RUN"] = "HUIR", + ["BALLx"] = "BALLx", + ["BAIT"] = "CEBO", + ["THROW ROCK"] = "TIRAR PIEDRA", + ["disabled!"] = "¡anulado!", + ["TYPE/"] = "TIPO/", + ["It doesn't affect\n%s!"] = "¡No afecta a\n%s!", + ["Critical hit!"] = "¡Golpe crítico!", + ["One-hit KO!"] = "¡KO en un golpe!", + ["It's super\neffective!"] = "¡Es muy eficaz!", + ["It's not very\neffective..."] = "¡No es muy\neficaz...", + ["Hit the enemy\n%d times!"] = "¡Golpeó al enemigo\n%d veces!", + ["Hit %d times!"] = "¡Golpeó %d veces!", + ["%s's\nhit with recoil!"] = "¡%s sufrió\nel retroceso!", + ["%s is\nprotected by MIST!"] = "¡%s está\nprotegido por NIEBLA!", + ["Nothing happened!"] = "¡No pasó nada!", + ["%s's\n%s\ngreatly rose!"] = "¡%s\nmejoró mucho su\n%s!", + ["%s's\n%s rose!"] = "¡%s mejoró\nsu %s!", + ["%s's\n%s fell!"] = "¡%s bajó\nsu %s!", + ["%s's\n%s\ngreatly fell!"] = "¡%s\nbajó mucho su\n%s!", + ["Fire defrosted\n%s!"] = "¡El fuego descongeló\na %s!", + ["%s\nbecame confused!"] = "¡%s se\nconfundió!", + ["%s\nwas seeded!"] = "¡%s recibió\nla DRENADORA!", + ["%s\nstarted sleeping!"] = "¡%s se\nquedó dormido!", + ["%s\nregained health!"] = "¡%s recuperó\nsalud!", + ["%s's\nprotected against\nspecial attacks!"] = "¡%s está\nprotegido de los\nataques especiales!", + ["%s\ngained armor!"] = "¡%s ganó\narmadura!", + ["%s's\nshrouded in mist!"] = "¡%s se\ncubrió de niebla!", + ["%s's\ngetting pumped!"] = "¡%s se\nestá animando!", + ["All STATUS changes\nare eliminated!"] = "¡Los cambios de\nESTADO desaparecen!", + ["%s\nhas a SUBSTITUTE!"] = "¡%s tiene\nun SUSTITUTO!", + ["Too weak to make\na SUBSTITUTE!"] = "¡Muy débil para\nhacer un SUSTITUTO!", + ["It created a\nSUBSTITUTE!"] = "¡Creó un SUSTITUTO!", + ["Converted type to\n%s's!"] = "¡Cambió su tipo al\nde %s!", + ["%s\ntransformed into\n%s!"] = "¡%s se\ntransformó en\n%s!", + ["%s's\n%s was\ndisabled!"] = "¡El %s\nde %s\nfue anulado!", + ["No effect!"] = "¡Sin efecto!", + ["Sucked health from\n%s!"] = "¡Absorbió salud de\n%s!", + ["%s's\ndream was eaten!"] = "¡Devoró el sueño\nde %s!", + ["%s\nkept going and\ncrashed!"] = "¡%s siguió\nadelante y se\nestrelló!", + ["Coins scattered\neverywhere!"] = "¡Las monedas se\ndesparramaron!", + ["%s\nran away scared!"] = "¡%s huyó\nasustado!", + ["%s\nwas blown away!"] = "¡%s salió\nvolando!", + ["%s\nran from battle!"] = "¡%s huyó\ndel combate!", + ["It didn't affect\n%s!"] = "¡No afectó a\n%s!", + ["%s\nis unaffected!"] = "¡%s no se\nvio afectado!", + ["The MIRROR MOVE\nfailed!"] = "¡El MOVIMIENTO\nESPEJO falló!", + ["%s\nfell asleep!"] = "¡%s se\nquedó dormido!", + ["%s\nwas frozen solid!"] = "¡%s se\ncongeló!", + ["%s's\nhurt by poison!"] = "¡El veneno hiere a\n%s!", + ["%s's\nbadly poisoned!"] = "¡%s está\ngravemente envenenado!", + ["%s\nwas poisoned!"] = "¡%s fue\nenvenenado!", + ["%s's\nhurt by the burn!"] = "¡La quemadura hiere\na %s!", + ["%s\nwas burned!"] = "¡%s se\nquemó!", + ["%s's\nfully paralyzed!"] = "¡%s está\ntotalmente paralizado!", + ["%s's\nparalyzed! It may\nnot attack!"] = "¡%s está\nparalizado! ¡Puede\nque no ataque!", + ["%s's\ndisabled no more!"] = "¡%s ya no\nestá anulado!", + ["%s\nsnapped out of\nconfusion!"] = "¡%s salió\nde su confusión!", + ["LEECH SEED saps\n%s!"] = "¡La DRENADORA\nabsorbe a %s!", + ["%s\nwas afflicted\nby %s!"] = "¡%s sufre\n%s!", + ["%s's\nprotected against\nstat changes!"] = "¡%s está\nprotegido de los\ncambios de estado!", + ["What will"] = "¿Qué va a hacer", + [" do?"] = "?", + ["You can't get off\nhere."] = "No puedes bajarte\naquí.", + ["%s got off\nthe BICYCLE."] = "%s se bajó\nde la BICICLETA.", + ["%s got on\nthe BICYCLE!"] = "¡%s se subió\na la BICICLETA!", + ["No cycling\nallowed here."] = "No se puede montar\naquí.", + ["No good! It's not\neven near water."] = "¡No sirve! No hay\nagua cerca.", + ["OAK: %s!\nThis isn't the\ntime to use that!"] = "OAK: ¡%s!\n¡No es momento\nde usar eso!", + ["The TOWN MAP is\nunreadable here."] = "El MAPA PUEBLO no\nse puede leer aquí.", + ["Yes! ITEMFINDER\nindicates there's\nan item nearby."] = "¡Sí! El BUSCAOBJ.\nindica que hay algo\ncerca.", + ["Nope! ITEMFINDER\nisn't responding."] = "¡No! El BUSCAOBJ.\nno responde.", + ["Booted up a TM!"] = "¡Se activó una MT!", + ["It contained\n%s!"] = "¡Contenía\n%s!", + ["USE"] = "USAR", + ["TOSS"] = "TIRAR", + ["That's too impor-\ntant to toss!"] = "¡Es demasiado\nimportante!", + ["Threw away\n%s."] = "Tiraste\n%s.", + ["PRESS A BUTTON"] = "PULSA UN BOTON", + ["ESC TO CANCEL"] = "ESC PARA CANCELAR", + ["%s :L%d"] = "%s :N%d", + ["STATS"] = "DATOS", + ["CANCEL"] = "CANCELAR", + ["What? There are\nno POKéMON here!"] = "¿Qué? ¡Aquí no hay\nningún POKéMON!", + ["You can't take\nany more POKéMON.\fDeposit POKéMON\nfirst."] = "No puedes llevar\nmás POKéMON.\fGuarda alguno\nprimero.", + ["BOX %d (WITHDRAW)"] = "CAJA %d (RETIRAR)", + ["%s is\ntaken out.\vGot %s."] = "Retirado\n%s.\vRecibes %s.", + ["You can't deposit\nthe last POKéMON!"] = "¡No puedes guardar\nel último POKéMON!", + ["Oops! This Box is\nfull of POKéMON."] = "¡Uups! Esta CAJA\nestá llena.", + ["You need at least\none POKéMON!"] = "¡Necesitas al menos\nun POKéMON!", + ["BOX %d is full!"] = "¡La CAJA %d está\nllena!", + ["%s was\nstored in Box %s."] = "%s se\nguardó en la CAJA %s.", + ["BOX %d (RELEASE)"] = "CAJA %d (SOLTAR)", + ["Once released,\n%s is\ngone forever. OK?"] = "Si lo sueltas,\n%s se\nirá para siempre. ¿OK?", + ["%s was\nreleased outside.\fBye %s!"] = "%s fue\nliberado.\f¡Adiós, %s!", + ["%sBOX %2d"] = "%sCAJA %2d", + ["When you change a\nPOKéMON BOX, data\nwill be saved. OK?"] = "Al cambiar de CAJA\nse guardarán los\ndatos. ¿OK?", + ["What?"] = "¿Qué?", + ["BOX No."] = "CAJA No.", + ["BOX No.%d"] = "CAJA No.%d", + ["Empty."] = "Vacía.", + [":L%d No.%03d"] = ":N%d No.%03d", + ["Printed BOX %d!\fSaved as\n%s\vin the save\nfolder."] = "¡CAJA %d impresa!\fGuardada como\n%s\ven la carpeta de\nguardado.", + ["Printer error!\n%s"] = "¡Error de impresión!\n%s", + ["WITHDRAW "] = "RETIRAR ", + ["DEPOSIT "] = "GUARDAR ", + ["RELEASE "] = "SOLTAR ", + ["CHANGE BOX"] = "CAMBIAR CAJA", + ["PRINT BOX"] = "IMPRIMIR CAJA", + ["SEE YA!"] = "HASTA LUEGO!", + ["YES"] = "SI", + ["NO"] = "NO", + ["GAME FREAK"] = "", + ["Nintendo"] = "", + ["Creatures inc."] = "", + ["GAME FREAK inc."] = "", + ["T H E E N D"] = "F I N", + ["HT %d′%02d″"] = "AL %d′%02d″", + ["WT %.1flb"] = "PE %.1flb", + ["Data unknown."] = "Datos desconocidos.", + [""] = "", + ["Player"] = "Jugador", + ["Huh? %s\nstopped evolving!"] = "¿Eh? ¡%s\ndejó de evolucionar!", + ["Congratulations!\nYour %s\nevolved into\n%s!"] = "¡Enhorabuena!\n¡Tu %s\nevolucionó a\n%s!", + ["evolving!"] = "evolucionando", + ["POKéDEX Seen:{NUM:wDexRatingNumMonsSeen, 1, 3}\n Owned:{NUM:wDexRatingNumMonsOwned, 1, 3}"] = "POKéDEX Vistos:{NUM:wDexRatingNumMonsSeen, 1, 3}\n Capturados:{NUM:wDexRatingNumMonsOwned, 1, 3}", + ["POKéDEX Rating{COLON}"] = "Nota POKéDEX{COLON}", + ["Keep it up!"] = "¡Sigue así!", + ["LEVEL/"] = "NIVEL/", + ["TYPE1/"] = "TIPO1/", + ["TYPE2/"] = "TIPO2/", + ["HALL OF FAME"] = "SALON DE LA FAMA", + ["PLAY TIME"] = "TIEMPO", + ["MONEY"] = "DINERO", + ["bois club games"] = "bois club games", + ["GENGAR VS NIDORINO"] = "", + ["bois club"] = "bois club", + ["Nothing here."] = "Aquí no hay nada.", + ["%s is\ntrying to learn\v%s!\fBut, %s\ncan't learn more\vthan 4 moves!\f"] = "¡%s está\nintentando aprender\v%s!\f¡Pero %s\nno puede aprender\vmás de 4!\f", + ["Delete an older\nmove to make room\vfor %s?"] = "¿Borrar un movi-\nmiento antiguo para\vaprender %s?", + ["HM techniques\ncan't be deleted!"] = "¡Los movimientos MO\nno se pueden borrar!", + ["Abandon learning\n%s?"] = "¿Dejar de aprender\n%s?", + ["1, 2 and... Poof!\f%s forgot\n%s!\fAnd...\f%s learned\n%s!"] = "¡1, 2 y... plaf!\f¡%s olvidó\n%s!\f¡Y...\f%s aprendió\n%s!", + ["%s\ndid not learn\v%s!"] = "¡%s no\naprendió\v%s!", + ["Which move should"] = "¿Qué movimiento", + ["be forgotten?"] = "hay que olvidar?", + ["YOUR NAME?"] = "TU NOMBRE?", + ["NEW NAME"] = "NUEVO NOMBRE", + ["Hello there!\nWelcome to the\vworld of POKéMON!\fMy name is OAK!\nPeople call me\vthe POKéMON PROF!"] = "¡Hola!\n¡Bienvenido al\vmundo POKéMON!\fMe llamo OAK.\nMe llaman el\vPROF. POKéMON.", + ["This world is\ninhabited by\vcreatures called\vPOKéMON!"] = "¡Este mundo está\nhabitado por unas\vcriaturas llamadas\vPOKéMON!", + ["\fFor some people,\nPOKéMON are\vpets. Others use\vthem for fights.\fMyself...\fI study POKéMON\nas a profession."] = "\fPara algunos, los\nPOKéMON son masco-\vtas. Otros luchan\vcon ellos.\fYo...\fEstudio los POKéMON\ncomo profesión.", + ["{PLAYER}!\fYour very own\nPOKéMON legend is\vabout to unfold!\fA world of dreams\nand adventures\vwith POKéMON\vawaits! Let's go!"] = "¡{PLAYER}!\f¡Tu propia leyenda\nPOKéMON está a\vpunto de comenzar!\f¡Un mundo de sueños\ny aventuras con\vPOKéMON te espera!\v¡Vamos!", + ["First, what is\nyour name?"] = "¿Cómo te llamas?", + ["This is my grand-\nson. He's been\vyour rival since\vyou were a baby.\f...Erm, what is\nhis name again?"] = "Este es mi nieto.\nHa sido tu rival\vdesde que erais\vbebés.\f...Mmm, ¿cómo se\nllamaba?", + ["HIS NAME?"] = "SU NOMBRE?", + ["_OakSpeechText2A"] = "", + ["TEXT SPEED"] = "VEL TEXTO", + ["BATTLE ANIMATION"] = "ANIMACIONES", + ["OFF"] = "NO", + ["ON"] = "SI", + ["BATTLE STYLE"] = "ESTILO COMBATE", + ["SET"] = "FIJO", + ["SHIFT"] = "CAMBIO", + ["BATTLE LAYOUT"] = "DISENO COMBATE", + ["WIDE"] = "ANCHO", + ["OG"] = "OG", + ["RULESET"] = "REGLAS", + ["MUSIC VOL"] = "VOL MUSICA", + ["SFX VOL"] = "VOL SONIDO", + ["PIKACHU VOL"] = "VOL PIKACHU", + ["MUSIC FILTER"] = "FILTRO MUSICA", + ["COLORS"] = "COLORES", + ["TILT"] = "INCLINACION", + ["GBC FX"] = "EFECTO GBC", + ["ZOOM"] = "ZOOM", + ["VOID FILL"] = "RELLENO VACIO", + ["VIDEO MODE"] = "MODO VIDEO", + ["MAX FPS"] = "FPS MAXIMO", + ["GAME SPEED"] = "VELOCIDAD JUEGO", + ["MODS"] = "MODS", + ["%d INSTALLED"] = "%d INSTALADOS", + ["CONTROLS"] = "CONTROLES", + ["TOUCH PAD"] = "CONTROL TACTIL", + ["SURE? AGAIN"] = "SEGURO? OTRA VEZ", + ["AUTO HIDE PAD"] = "OCULTAR AUTO", + ["A blinding FLASH\nlights the area!"] = "¡Un DESTELLO\nilumina la zona!", + ["No SURFing here!"] = "¡Aquí no se puede\nSURFEAR!", + ["Nothing to CUT!"] = "¡Nada que CORTAR!", + ["{RAM:wNameBuffer} used\nSTRENGTH."] = "{RAM:wNameBuffer} usó\nFUERZA.", + ["{RAM:wNameBuffer} can\nmove boulders."] = "{RAM:wNameBuffer} puede\nmover rocas.", + ["It won't have\nany effect."] = "No tendrá ningún\nefecto.", + ["%s's HP\nwas restored!"] = "¡Los PS de %s\nse recuperaron!", + ["SWITCH"] = "CAMBIAR", + ["FLY"] = "VUELO", + ["FLASH"] = "DESTELLO", + ["CUT"] = "CORTE", + ["SURF"] = "SURF", + ["STRENGTH"] = "FUERZA", + ["SOFTBOILED"] = "HUEVO SUERTE", + ["TELEPORT"] = "TELETRANSPORTE", + ["DIG"] = "EXCAVAR", + ["Use TM on which\nPOKéMON?"] = "¿Usar la MT en qué\nPOKéMON?", + ["Bring out which\nPOKéMON?"] = "¿Qué POKéMON\nquieres sacar?", + ["Choose a POKéMON."] = "Elige un POKéMON.", + ["No POKéMON!"] = "¡Ningún POKéMON!", + ["ABLE"] = "PUEDE", + ["NOT ABLE"] = "NO PUEDE", + ["FNT"] = "DEB", + ["Move to where?"] = "¿Mover a dónde?", + ["Use on which one?"] = "¿Usar en cuál?", + ["You can't carry\nany more items."] = "No puedes llevar\nmás objetos.", + ["Withdrew\n%s."] = "Retirado\n%s.", + ["No room left to\nstore items."] = "No queda sitio para\nguardar objetos.", + ["%s was\nstored via PC."] = "%s se\nguardó en el PC.", + ["Toss %s?"] = "¿Tirar %s?", + ["Threw away %s."] = "Tiraste %s.", + ["WITHDRAW ITEM"] = "RETIRAR OBJETO", + ["DEPOSIT ITEM"] = "GUARDAR OBJETO", + ["TOSS ITEM"] = "TIRAR OBJETO", + ["LOG OFF"] = "SALIR", + ["SEEN %d OWNED %d"] = "VISTOS %d CAPT. %d", + ["DATA"] = "DATOS", + ["CRY"] = "VOZ", + ["AREA"] = "ZONA", + ["PRNT"] = "IMPR", + ["Printed %s's\ndata!\fSaved as\n%s\vin the save\nfolder."] = "¡Datos de %s\nimpresos!\fGuardado como\n%s\ven la carpeta de\nguardado.", + ["QUIT"] = "SALIR", + ["%s (%s)"] = "%s (%s)", + ["%s x%d"] = "%s x%d", + ["%s to box %d"] = "%s a caja %d", + ["LOAD REPORT"] = "CARGAR PARTIDA", + ["A:CONTINUE"] = "A:CONTINUAR", + ["You don't have\nenough money."] = "No tienes dinero\nsuficiente.", + ["%s?\nThat will be\n¥%d. OK?"] = "¿%s?\nSon ¥%d.\n¿OK?", + ["Here you are!\nThank you!"] = "¡Aquí tienes!\n¡Gracias!", + ["I can't put a\nprice on that."] = "No puedo ponerle\nprecio a eso.", + ["I can pay you\n¥%d for that."] = "Te doy ¥%d\npor eso.", + ["BUY"] = "COMPRAR", + ["SELL"] = "VENDER", + ["%s lined up!\nScored %d coins!"] = "¡%s alineados!\n¡%d fichas!", + ["Darn!\nRan out of coins!"] = "¡Vaya!\n¡Sin fichas!", + ["Not enough\ncoins!"] = "¡Fichas\ninsuficientes!", + ["SLOT MACHINE"] = "MAQUINA TRAGAPERRAS", + ["COINS %4d"] = "FICHAS %4d", + ["POKéDEX"] = "POKéDEX", + ["POKéMON"] = "POKéMON", + ["SAVE"] = "GUARDAR", + ["PLAYER %s\nBADGES %d\nPOKéDEX %3d\nTIME %6d:%02d"] = "JUGADOR %s\nMEDALLAS %d\nPOKéDEX %3d\nTIEMPO %6d:%02d", + ["\fWould you like to\nSAVE the game?"] = "\f¿Quieres GUARDAR\nla partida?", + ["Now saving..."] = "Guardando...", + ["%s saved\nthe game!"] = "¡%s guardó\nla partida!", + ["OPTION"] = "OPCION", + ["LINK"] = "LINK", + ["RETURN TO MAIN\nMENU?"] = "¿VOLVER AL MENU\nPRINCIPAL?", + ["BALL"] = "BALL", + ["STATUS/"] = "ESTADO/", + ["OT/"] = "EO/", + ["EXP POINTS"] = "P. EXP.", + ["LEVEL UP"] = "SUBE NIVEL", + ["PP"] = "PP", + ["SCORE %d"] = "PUNTOS %d", + ["New record!"] = "¡Nuevo récord!", + ["HI %d"] = "MAX %d", + ["A: done"] = "A: listo", + ["PLAYER"] = "JUGADOR", + ["BADGES"] = "MEDALLAS", + ["TIME"] = "TIEMPO", + ["CONTINUE"] = "CONTINUAR", + ["NEW GAME"] = "NUEVA PARTIDA", + ["EXIT GAME"] = "SALIR DEL JUEGO", + ["POKéMON RED"] = "", + ["2026 bois club games"] = "", + ["OT/%s"] = "EO/%s", + ["NAME/%s"] = "NOMBRE/%s", + ["In battle"] = "En combate", + ["Wild battle"] = "Combate salvaje", + ["Trainer battle"] = "Combate entrenador", + ["Link battle"] = "Combate link", + ["Title screen"] = "Pantalla de título", + ["Level %d"] = "Nivel %d", + ["What?\n%s is\nevolving!\fCongratulations!\nYour %s\nevolved into\n%s!"] = "¿Qué?\n¡%s está\nevolucionando!\f¡Enhorabuena!\n¡Tu %s\nevolucionó a\n%s!", + ["Not even a nibble!"] = "¡Ni un mordisco!", + ["Oh!\nIt's a bite!"] = "¡Oh!\n¡Ha picado!", + ["It's a sculpture\nof DIGLETT."] = "Es una escultura\nde DIGLETT.", + ["Crammed full of\nPOKéMON books!"] = "¡Repleto de libros\nsobre POKéMON!", + ["There's a slew of\nPOKéMON stuff!"] = "¡Hay un montón de\ncosas POKéMON!", + ["An elevator!"] = "¡Un ascensor!", + ["INDIGO PLATEAU"] = "MESETA ANIL", + ["POKéMON LEAGUE HQ"] = "SEDE DE LA LIGA\nPOKéMON", + ["You can't carry\nany more items!"] = "¡No puedes llevar\nmás objetos!", + ["%s found\n%s!"] = "¡%s encontró\n%s!", + ["%s found\n%d coins!"] = "¡%s encontró\n%d fichas!", + ["OUT OF ORDER\nThis is broken."] = "FUERA DE SERVICIO\nEsto está roto.", + ["OUT TO LUNCH\nThis is reserved."] = "CERRADO POR COMIDA\nEsto está reservado.", + ["Someone's keys!\nThey'll be back."] = "¡Las llaves de\nalguien! Volverá.", + ["A COIN CASE is\nrequired!"] = "¡Se necesita un\nMONEDERO!", + ["You don't have\nany coins!"] = "¡No tienes fichas!", + ["{RAM}\nPOKéMON GYM\nLEADER: {RAM}"] = "{RAM}\nGIMNASIO POKéMON\nLIDER: {RAM}", + ["Nope, there's\nonly trash here."] = "No, aquí solo hay\nbasura.", + ["Darn! It needs a\nCARD KEY!"] = "¡Vaya! ¡Necesita\nuna LLAVE MAGNET.!", + ["Bingo!"] = "¡Bingo!", + ["\nThe CARD KEY\nopened the door!"] = "\n¡La LLAVE MAGNET.\nabrió la puerta!", + ["Hey! There's a\nswitch under the\ntrash!\fThe 1st electric\nlock opened!"] = "¡Hay un interruptor\nbajo la basura!\f¡Se abrió el 1er\ncierre eléctrico!", + ["The 2nd electric\nlock opened!\fThe motorized door\nopened!"] = "¡Se abrió el 2o\ncierre eléctrico!\f¡La puerta se\nabrió!", + ["Nope! There's\nonly trash here.\fHey! The electric\nlocks were reset!"] = "¡No! Aquí solo hay\nbasura.\f¡Los cierres se\nreiniciaron!", + ["TELEPORTER is\ndisplayed on the\nPC monitor."] = "El TELETRANSPORTE\naparece en el\nmonitor del PC.", + ["{PLAYER} initiated\nTELEPORTER's Cell\nSeparator!"] = "¡{PLAYER} activó el\nSeparador de Células\ndel TELETRANSPORTE!", + ["BILL's favorite\nPOKéMON list!"] = "¡La lista de POKéMON\nfavoritos de BILL!", + ["{PLAYER} got on\n{RAM:wNameBuffer}!"] = "¡{PLAYER} se subió\na {RAM:wNameBuffer}!", + ["{RAM:wNameBuffer} hacked\naway with CUT!"] = "¡{RAM:wNameBuffer} cortó\ncon CORTE!", + ["Gyaoo!"] = "¡Gyaoo!", + ["Hi there!\nMay I help you?"] = "¡Hola!\n¿Puedo ayudarte?", + ["SOMEONE'S PC"] = "EL PC DE ALGUIEN", + ["PROF.OAK's PC"] = "EL PC DEL PROF.OAK", + ["POKéDEX comp-\nletion is:\f{NUM:hDexRatingNumMonsSeen} POKéMON seen\n{NUM:hDexRatingNumMonsOwned} POKéMON owned\fPROF.OAK's\nRating:"] = "La POKéDEX está\nasí:\f{NUM:hDexRatingNumMonsSeen} POKéMON vistos\n{NUM:hDexRatingNumMonsOwned} POKéMON capturados\fNota del\nPROF.OAK:", + ["We hope to see\nyou again!"] = "¡Esperamos verte\nde nuevo!", + ["Welcome to our\nPOKéMON CENTER!"] = "¡Bienvenido a\nnuestro CENTRO\nPOKéMON!", + ["Shall we heal your\nPOKéMON?"] = "¿Curamos a tus\nPOKéMON?", + ["OK. We'll need\nyour POKéMON."] = "Bien. Necesitamos\ntus POKéMON.", + ["Your POKéMON are\nfighting fit!"] = "¡Tus POKéMON están\nen plena forma!", + ["Welcome to the\nCable Club!"] = "¡Bienvenido al Club\nde Cable!", + ["We're making\npreparations.\vPlease wait."] = "Estamos preparando\ntodo.\vEspera un momento.", + ["Please apply here.\fBefore opening\nthe link, we have\vto save the game."] = "Solicítalo aquí.\fAntes de abrir el\nlink hay que\vguardar la partida.", + ["Please come\nagain!"] = "¡Vuelve pronto!", + ["I like shorts!\nThey're comfy and\neasy to wear!"] = "¡Me gustan los\npantalones cortos!\n¡Son cómodos!", + ["%s received\nthe %s!"] = "¡%s recibió\nel %s!", + ["%s received\n%s!"] = "¡%s recibió\n%s!", + ["REPEL's effect\nwore off."] = "El efecto del REPEL\nse ha pasado.", + ["Go right ahead!"] = "¡Adelante!", + ["You don't have the\nBOULDERBADGE yet!"] = "¡Aún no tienes la\nMEDALLA ROCA!", + ["Oh! That is the\n{RAM}!"] = "¡Oh! ¡Eso es el\n{RAM}!", + ["You don't have the\n{RAM} yet!"] = "¡Aún no tienes el\n{RAM}!", + ["You need a\nBICYCLE for the\nCycling Road!"] = "¡Necesitas una\nBICICLETA para el\nCarril Bici!", + ["The boulder fell\nthrough the hole!"] = "¡La roca cayó por\nel agujero!", + ["PA: Ding-dong!\nTime's up!"] = "AV: ¡Ding-dong!\n¡Se acabó el tiempo!", + ["PA: Your SAFARI\nGAME is over!"] = "AV: ¡Tu JUEGO\nSAFARI ha terminado!", + ["PA: You're out of\nSAFARI BALLs!"] = "AV: ¡No te quedan\nSAFARI BALLs!", + ["{PLAYER} got\n%s!"] = "¡{PLAYER} consiguió\n%s!", + ["There's no more\nroom for POKéMON!\v%s was\vsent to POKéMON\vBOX %s on PC!"] = "¡No hay sitio para\nmás POKéMON!\v¡%s fue\venviado a la CAJA\vPOKéMON %s del PC!", + ["contribution is not a table"] = "", + [" [%s %s.%s]"] = " [%s %s.%s]", + ["Link battle needs\nthe same mods on\nboth games."] = "El combate link\nnecesita los mismos\nmods en los dos\njuegos.", + ["Your %s can't\nbattle on the\nother game."] = "Tu %s no puede\nluchar en el otro\njuego.", + ["Their %s isn't\nin this game.\n(%s)"] = "Su %s no está\nen este juego.\n(%s)", + ["%s wants\nto battle!"] = "¡%s quiere\nluchar!", + ["Link desync!\n%s differs.\fAre both games\nrunning the same\nmods?"] = "¡Link desincroni-\nzado!\n%s difiere.\f¿Están los dos\njuegos con los\nmismos mods?", + ["%s ran from\nthe battle!"] = "¡%s huyó del\ncombate!", + ["Items can't be\nused in a link\nbattle!"] = "¡No se pueden usar\nobjetos en un\ncombate link!", + ["%s is out of\nPOKéMON!\f%s wins!"] = "¡%s no tiene\nPOKéMON!\f¡%s gana!", + ["%s left the\nbattle."] = "%s dejó el\ncombate.", + ["%s ran out of\ntime!"] = "¡%s se quedó\nsin tiempo!", + ["Time's up! You\nforfeit the match."] = "¡Se acabó el tiempo!\nPierdes el combate.", + ["%s's %s can't\nbattle on this\ngame."] = "El %s de %s\nno puede luchar en\neste juego.", + ["%s's %s can't\nbattle on this\ngame.\n(%s)"] = "El %s de %s\nno puede luchar en\neste juego.\n(%s)", + ["%s vs %s!"] = "¡%s contra %s!", + ["Link error:\n%s"] = "Error de link:\n%s", + ["Online play runs\nvanilla for both\nplayers.\fTurn off %s\nand restart?"] = "El juego en línea\nva sin mods para\nlos dos jugadores.\f¿Desactivar %s\ny reiniciar?", + ["The link was\nbroken."] = "Se ha perdido el\nlink.", + ["Link battle\ncan't start."] = "El combate link no\npuede empezar.", + ["The trade stopped:\n%s."] = "El intercambio se\ndetuvo:\n%s.", + ["The trade was\ncancelled."] = "El intercambio se\nha cancelado.", + ["Trade completed!\f%s received\n%s!"] = "¡Intercambio hecho!\f¡%s recibió\n%s!", + ["LINK CABLE (LAN)"] = "CABLE LINK (LAN)", + ["ONLINE MATCH"] = "PARTIDA EN LINEA", + ["TOURNAMENT"] = "TORNEO", + ["HOST A GAME"] = "CREAR PARTIDA", + ["JOIN A GAME"] = "UNIRSE A PARTIDA", + ["UDP port %s"] = "Puerto UDP %s", + ["HOST ONLINE"] = "CREAR EN LINEA", + ["JOIN ONLINE"] = "UNIRSE EN LINEA", + ["Tell your friend"] = "Dile a tu amigo", + ["the code:"] = "el código:", + ["Waiting for join..."] = "Esperando...", + ["A: connect B: back"] = "A: conectar B: atrás", + ["Calling..."] = "Llamando...", + ["Friend joins at:"] = "Tu amigo entra en:", + ["Port: %s"] = "Puerto: %s", + ["TRADE"] = "INTERCAMBIO", + ["BATTLE"] = "COMBATE", + ["LEVELS:"] = "NIVELES:", + ["A: continue B: back"] = "A: seguir B: atrás", + ["Checking the"] = "Comprobando el", + ["other game..."] = "otro juego...", + ["Waiting for the"] = "Esperando a que", + ["host to choose..."] = "el anfitrión elija...", + ["A: trade anyway"] = "A: intercambiar igual", + ["YOURS"] = "TUYO", + ["THEIRS"] = "SUYO", + ["X: not on theirs"] = "X: no en el suyo", + ["A: trade B: cancel"] = "A: cambiar B: cancelar", + ["Exchanging data..."] = "Intercambiando...", + ["can't reach relay %s:%d\n(%s)"] = "", + ["That code wasn't\nfound."] = "Ese código no se\nha encontrado.", + ["That game already\nhas two players."] = "Esa partida ya\ntiene dos jugadores.", + ["That code has\nexpired."] = "Ese código ha\ncaducado.", + ["Couldn't join:\n%s"] = "No se pudo unir:\n%s", + ["no answer from\n%s"] = "", + ["That tournament\nhas already begun."] = "Ese torneo ya ha\nempezado.", + ["Can't host:\nneed %d Pokemon\nLv %s-%s."] = "No puedes crearlo:\nnecesitas %d Pokemon\nNv %s-%s.", + ["Couldn't host\nthat tournament."] = "No se pudo crear\nese torneo.", + ["Your party needs\n%d Pokemon, Lv\n%s-%s."] = "Tu equipo necesita\n%d Pokemon, Nv\n%s-%s.", + ["Couldn't join\nthat tournament."] = "No se pudo unir a\nese torneo.", + ["Link error:\nversion mismatch\nwith opponent."] = "Error de link:\nversión distinta a\nla del rival.", + ["The tournament\nconnection was\nlost."] = "Se perdió la\nconexión del torneo.", + ["Can't watch this\nmatch."] = "No se puede ver\neste combate.", + ["HOST"] = "CREAR", + ["JOIN"] = "UNIRSE", + ["START: create"] = "START: crear", + ["A: join B: back"] = "A: unirse B: atrás", + ["B: cancel"] = "B: cancelar", + ["TOURNAMENT %s"] = "TORNEO %s", + ["ROUND %d"] = "RONDA %d", + ["%s (bye)"] = "%s (pasa)", + ["%s%s vs %s%s"] = "%s%s contra %s%s", + ["(organizing --"] = "(organizando --", + ["not playing)"] = "no juega)", + ["Waiting for"] = "Esperando a que", + ["players to join:"] = "entren jugadores:", + ["A: START B: cancel"] = "A: START B: cancelar", + ["%s is the"] = "¡%s es el", + ["champion!"] = "campeón!", + ["A: continue"] = "A: continuar", + ["{PLAYER} played the\nPOKé FLUTE."] = "{PLAYER} tocó la\nFLAUTA POKé.", + ["Played the POKé\nFLUTE.\fNow, that's a\ncatchy tune!"] = "Tocaste la FLAUTA\nPOKé.\f¡Qué melodía tan\npegadiza!", + ["%s played the\nPOKé FLUTE."] = "%s tocó la\nFLAUTA POKé.", + ["All sleeping\nPOKéMON woke up!"] = "¡Todos los POKéMON\ndormidos despertaron!", + ["%s's\nhits will never\nmiss!"] = "¡Los golpes de %s\nnunca fallarán!", + ["The wild POKéMON\nran away!"] = "¡El POKéMON salvaje\nhuyó!", + ["%s's PP\nwas restored!"] = "¡Los PP de %s\nse recuperaron!", + ["%s's\nstatus returned\nto normal!"] = "¡El estado de %s\nvolvió a la\nnormalidad!", + ["%s\nis revitalized!"] = "¡%s se ha\nrevitalizado!", + ["%s\nis refusing!"] = "¡%s se\nniega!", + ["%s's %s\nrose!"] = "¡El %s de %s\nsubió!", + ["%s's PP\nincreased!"] = "¡Los PP de %s\naumentaron!", + ["%s can't\nlearn that move!"] = "¡%s no puede\naprender ese\nmovimiento!", + ["It knows that\nmove already!"] = "¡Ya conoce ese\nmovimiento!", + ["Coin count:\n%d"] = "Fichas:\n%d", + ["NO MODS INSTALLED"] = "NO HAY MODS", + ["SAVE CURRENT AS.."] = "GUARDAR ACTUAL..", + ["OPTIONS.."] = "OPCIONES..", + ["PERMISSIONS.."] = "PERMISOS..", + ["VIEW ERROR.."] = "VER ERROR..", + ["BACK"] = "ATRAS", + ["APPLY & RESTART"] = "APLICAR Y REINICIAR", + ["DISCARD CHANGES"] = "DESCARTAR CAMBIOS", + ["DATA & API ONLY"] = "SOLO DATOS Y API", + ["DISABLE BOTH?"] = "DESACTIVAR AMBOS?", + ["PROFILE NAME?"] = "NOMBRE DEL PERFIL?", + ["RENAME?"] = "RENOMBRAR?", + ["RESET DEFAULTS"] = "VALORES POR DEFECTO", + ["NO CHANGES"] = "SIN CAMBIOS", + ["A:OK"] = "A:OK", + ["B:DONE (NO RESTART)"] = "B:LISTO (SIN REINICIAR)", + ["MOD MANAGER"] = "GESTOR DE MODS", + ["Choose a mod .zip"] = "Elige un .zip de mod", + ["Choose a .sav save file"] = "Elige un archivo .sav", + ["An update is available"] = "Hay una actualización", + ["Name save slot"] = "Nombra la ranura", + ["Enter to save - Esc to cancel - empty clears"] = "Enter para guardar - Esc para cancelar - vacío la borra", + ["Add a mod index"] = "Añadir un índice de mods", + ["Paste the index URL, or its owner/repo."] = "Pega la URL del índice, o su owner/repo.", + ["Enter to add - Esc to cancel"] = "Enter para añadir - Esc para cancelar", + ["Import a ROM to play"] = "Importa una ROM para jugar", + ["RED"] = "ROJO", + ["BLUE"] = "AZUL", + ["YELLOW"] = "AMARILLO", + ["FIND MODS"] = "BUSCAR MODS", + ["%d of 3 ready"] = "%d de 3 listos", + ["Or drop the .gb/.gbc file here."] = "O arrastra aquí el archivo .gb/.gbc.", + ["ROM imported"] = "ROM importada", + ["That ROM could not be imported."] = "No se pudo importar esa ROM.", + ["Open folder"] = "Abrir carpeta", + ["%d badges - %s - %d caught"] = "%d medallas - %s - %d capturados", + ["%d of %d enabled"] = "%d de %d activados", + ["Or drop a mod .zip onto the window."] = "O arrastra un .zip de mod a la ventana.", + ["No mods installed - drop a mod .zip here to add one."] = "No hay mods - arrastra aquí un .zip para añadir uno.", + ["Refreshed - %d mods listed"] = "Actualizado - %d mods listados", + ["Added %s"] = "Añadido %s", + ["Index removed"] = "Índice eliminado", + ["Downloading %s..."] = "Descargando %s...", + ["Installed %s %s"] = "Instalado %s %s", + ["%d mods listed"] = "%d mods listados", + ["%d of %d mods"] = "%d de %d mods", + ["Mods here are listed, not reviewed - read the source and trust the author."] = "Los mods aquí se listan, no se revisan - lee el código y confía en el autor.", + ["No mod index added"] = "No hay índice de mods", + ["Add an index to browse mods. An index is a published list; paste its URL or its owner/repo."] = "Añade un índice para explorar mods. Un índice es una lista publicada; pega su URL o su owner/repo.", + ["Search mods"] = "Buscar mods", + ["This index lists no mods yet."] = "Este índice aún no lista mods.", + ["No mods match that search."] = "Ningún mod coincide con esa búsqueda.", +} diff --git a/mods/spanish_ui/lang/trainer_names.lua b/mods/spanish_ui/lang/trainer_names.lua new file mode 100644 index 00000000..c6537732 --- /dev/null +++ b/mods/spanish_ui/lang/trainer_names.lua @@ -0,0 +1,7 @@ +-- Trainer class names +-- +-- Trainer class names for Espanol. + +return { + ["OPP_FIX_YOUNGSTER"] = "", +} diff --git a/mods/spanish_ui/main.lua b/mods/spanish_ui/main.lua new file mode 100644 index 00000000..ada371d1 --- /dev/null +++ b/mods/spanish_ui/main.lua @@ -0,0 +1,127 @@ +-- spanish_ui: a translation of the game into Espanol. +-- +-- Nothing here is translated yet. Every table under lang/ starts with +-- empty strings; fill one in and it takes effect on the next boot, and +-- anything still empty keeps rendering in English. That means a +-- half-finished translation is always playable, so you can ship early and +-- fill the long tail in later. +-- +-- Read TRANSLATING.md before the first edit; the font is the part people +-- get wrong. +return function(mod) + -- mod:read is the supported way into your own directory; the catalogs are + -- plain Lua tables, so read and run them rather than require()ing them. + local function catalog(name) + local rel = "lang/" .. name .. ".lua" + local body = mod:read(rel) + if not body then return {} end + local chunk, err = loadstring(body, rel) + if not chunk then + mod.log:warn("%s has a syntax error: %s", rel, tostring(err)) + return {} + end + local ok, table_ = pcall(chunk) + if not ok or type(table_) ~= "table" then + mod.log:warn("%s did not return a table: %s", rel, tostring(table_)) + return {} + end + return table_ + end + + -- An empty value means "not translated yet", never "translate to blank". + local function each(name, apply) + local n = 0 + for key, value in pairs(catalog(name)) do + if type(value) == "string" and value ~= "" then + apply(key, value) + n = n + 1 + end + end + return n + end + + -- ---- glyphs ------------------------------------------------------- + -- Register the sheet BEFORE anything asks for a glyph on it. base is + -- the first code the page owns; 0x100 and up is free space above the + -- vanilla pages, so a new alphabet never collides with them. + for id, page in pairs(catalog("font")) do + mod.content.font:register(id, page) + end + -- charmap: which byte sequence draws which code + for seq, code in pairs(catalog("charmap")) do + mod.content.font:register("charmap:" .. seq, { seq = seq, code = code }) + end + + -- ---- text --------------------------------------------------------- + local counts = {} + counts.dialogue = each("dialogue", function(id, value) + mod.content.text:override(id, value) + end) + counts.strings = each("strings", function(source, value) + mod.content.strings:override(source, value) + end) + counts.species = each("species_names", function(id, value) + mod.content.pokemon:patch(id, { name = value }) + end) + counts.moves = each("move_names", function(id, value) + mod.content.moves:patch(id, { name = value }) + end) + counts.items = each("item_names", function(id, value) + mod.content.items:patch(id, { name = value }) + end) + counts.trainers = each("trainer_names", function(id, value) + mod.content.trainers:patch(id, { name = value }) + end) + counts.statuses = each("status_labels", function(id, value) + mod.content.statuses:patch(id, { label = value }) + end) + + -- ---- name entry --------------------------------------------------- + -- The naming screen's letter grid. Leave lang/naming.lua returning nil + -- to keep the English alphabet. + local grid = catalog("naming") + if grid.upper then + -- Only offer the accented cells when the running cartridge can actually + -- draw them. A Spanish ROM has Ñ and the accented vowels in its font + -- and the manifest maps them; an English one does not, and an + -- unmappable cell renders blank -- a naming screen with six empty keys + -- is worse than an English one. So check the charmap and fall back. + local function drawable(cells, ctx) + local font = ((ctx.game or {}).data or {}).font + local charmap = font and font.charmap + if not charmap then return false end + local have = {} + for _, entry in ipairs(charmap) do have[entry.seq] = true end + for _, row in ipairs(cells) do + for _, cell in ipairs(row) do + -- Only the non-ASCII cells are at risk; A-Z and punctuation are + -- on every page. + if cell:byte(1) and cell:byte(1) > 127 and not have[cell] then + return false + end + end + end + return true + end + local warned = false + mod.hooks:on("ui.naming.grid", function(base, ctx) + local want = ctx.lower and grid.lower or grid.upper + if not want then return base end + if not drawable(want, ctx) then + if not warned then + warned = true + mod.log:info("naming grid: this ROM has no accented glyphs, " + .. "keeping the English alphabet") + end + return base + end + return want + end) + end + + mod.events:on("game.ready", function() + local total = 0 + for _, n in pairs(counts) do total = total + n end + mod.log:info("Espanol: %d strings translated", total) + end) +end diff --git a/mods/spanish_ui/manifest.json b/mods/spanish_ui/manifest.json new file mode 100644 index 00000000..b3e45f04 --- /dev/null +++ b/mods/spanish_ui/manifest.json @@ -0,0 +1,17 @@ +{ + "id": "spanish_ui", + "name": "Espanol (interfaz)", + "version": "0.1.0", + "api": 2, + "entry": "main.lua", + "profile": "content", + "game_version": ">=0.0.0-dev <1.0.0", + "category": "LANGUAGE", + "priority": 100, + "dependencies": [], + "optional_dependencies": [], + "conflicts": [], + "incompatible": [], + "experimental": false, + "description": "Spanish for the app's own settings and menus. The game's text comes from your ROM and is untouched, so an English cartridge stays an English adventure with Spanish menus." +} \ No newline at end of file From 24d9f279ecc2200b50d07c0b15a6c19937da5585 Mon Sep 17 00:00:00 2001 From: MaxTomahawk Date: Fri, 7 Aug 2026 16:20:12 +0200 Subject: [PATCH 34/63] feat: expose settled battle checkpoint boundary --- src/core/Checkpoint.lua | 63 +++++++++++++++- tests/engine/battle_checkpoint_boundary.lua | 83 +++++++++++++++++++++ 2 files changed, 143 insertions(+), 3 deletions(-) create mode 100644 tests/engine/battle_checkpoint_boundary.lua diff --git a/src/core/Checkpoint.lua b/src/core/Checkpoint.lua index f09962f6..28b1093a 100644 --- a/src/core/Checkpoint.lua +++ b/src/core/Checkpoint.lua @@ -4,6 +4,7 @@ local SaveSerializer = require("src.core.SaveSerializer") local SaveData = require("src.core.SaveData") local Version = require("src.core.Version") +local BattleState = require("src.battle.BattleState") local Checkpoint = {} @@ -27,6 +28,61 @@ local function nonempty(value) return type(value) == "table" and next(value) ~= nil end +local function scriptsBusy(ow) + return running(ow.runner) or nonempty(ow.parallelRunners) + or nonempty(ow.pendingScripts) or nonempty(ow.parallelQueue) + or nonempty(ow.scriptMoves) +end + +local BATTLE_BUSY_FIELDS = { + "current", "afterQueue", "nextInsert", "pendingHit", "waitingUI", + "waitingSound", "waitFrames", "draining", "animPlaying", "growIn", + "introSlide", "ghostReveal", "mimicCtx", "mimicMoves", "result", +} + +local function inspectBattle(ow, battle) + if battle.kind == "link" then + return refusal("battle", "link_battle_unsupported", + "Network battles cannot be checkpointed.") + end + if battle.safari or battle.ghost or battle.scopeReveal or battle.demo + or battle.noCatch then + return refusal("battle", "battle_variant_unsupported", + "This battle variant does not have a checkpoint contract.") + end + if battle.kind ~= "wild" and battle.kind ~= "trainer" then + return refusal("battle", "battle_variant_unsupported", + "This battle kind does not have a checkpoint contract.") + end + local origin = battle.checkpointOrigin + local expectedOrigin = battle.kind == "wild" and "wild_encounter" + or "trainer_encounter" + if type(origin) ~= "table" or origin.kind ~= expectedOrigin then + return refusal("battle", "battle_origin_unsupported", + "The battle completion path cannot be reconstructed safely.") + end + if scriptsBusy(ow) then + return refusal("battle", "script_busy", + "A suspended or queued script cannot be checkpointed.") + end + if battle.phase ~= "menu" or nonempty(battle.queue) then + return refusal("battle", "battle_phase_busy", + "Wait for the player command menu before creating a checkpoint.") + end + for _, field in ipairs(BATTLE_BUSY_FIELDS) do + if battle[field] ~= nil and battle[field] ~= false then + return refusal("battle", "battle_phase_busy", + "Wait for the current battle action to finish.") + end + end + if not battle.player or not battle.enemy or battle.player.mon.hp <= 0 + or (battle.menuLockedAction and battle:menuLockedAction(battle.player)) then + return refusal("battle", "battle_phase_busy", + "Wait for an ordinary player decision before creating a checkpoint.") + end + return { canCapture = true, canRestore = true, kind = "battle" } +end + function Checkpoint.inspect(game) local save = game and game.save if type(save) ~= "table" or type(save.version) ~= "string" then @@ -41,6 +97,9 @@ function Checkpoint.inspect(game) "Only a settled overworld can be checkpointed.") end local top = game.stack and game.stack.top and game.stack:top() + if getmetatable(top) == BattleState then + return inspectBattle(ow, top) + end if top ~= ow then return refusal("overworld", "screen_busy", "Close the active menu or screen before creating a checkpoint.") @@ -57,9 +116,7 @@ function Checkpoint.inspect(game) return refusal("overworld", "transition_busy", "Wait for the map transition to finish.") end - if running(ow.runner) or nonempty(ow.parallelRunners) - or nonempty(ow.pendingScripts) or nonempty(ow.parallelQueue) - or nonempty(ow.scriptMoves) then + if scriptsBusy(ow) then return refusal("overworld", "script_busy", "Wait for the active or queued script to finish.") end diff --git a/tests/engine/battle_checkpoint_boundary.lua b/tests/engine/battle_checkpoint_boundary.lua new file mode 100644 index 00000000..580b8b67 --- /dev/null +++ b/tests/engine/battle_checkpoint_boundary.lua @@ -0,0 +1,83 @@ +-- Battle checkpoints are exposed only at a settled, reconstructable player +-- decision boundary. This suite is ROM-free and exercises the public engine +-- checkpoint capability against the fixture battle implementation. + +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") + +local T = require("tests.harness").suite("battle checkpoint boundary") +local Fixtures = require("tests.modkit").fixtures +local BattleState = require("src.battle.BattleState") +local Checkpoint = require("src.core.Checkpoint") +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") +local StateStack = require("src.core.StateStack") + +local Data = Fixtures.fresh() + +local function makeGame() + local save = SaveData.newGame() + save.meta.playthroughId = "battle-playthrough" + save.party = { Pokemon.new(Data, "FIXMON_A", 20) } + local stack = setmetatable({ states = {} }, { __index = StateStack }) + local overworld = { + map = { id = save.player.map }, + player = { + cellX = save.player.x, cellY = save.player.y, + facing = save.player.facing, surfing = false, + }, + runner = { isRunning = function() return false end }, + parallelRunners = {}, pendingScripts = {}, parallelQueue = {}, scriptMoves = {}, + } + local game = { data = Data, save = save, stack = stack, overworld = overworld } + stack.states[1] = overworld + local battle = BattleState.newWild(game, "FIXMON_B", 12) + battle.phase = "menu" + battle.queue = {} + battle.checkpointOrigin = { kind = "wild_encounter" } + battle.onFinish = function() end + stack.states[2] = battle + return game, overworld, battle +end + +local game, overworld, battle = makeGame() +T.same(Checkpoint.inspect(game), { + canCapture = true, canRestore = true, kind = "battle", +}, "settled standard wild battle is a checkpoint boundary") + +local function refused(mutator, code, label) + local game2, ow2, battle2 = makeGame() + mutator(game2, ow2, battle2) + local capability = Checkpoint.inspect(game2) + T.check(capability.canCapture == false and capability.reason == code, + label .. ": " .. tostring(capability.reason)) +end + +refused(function(_, _, b) b.phase = "messages" end, + "battle_phase_busy", "message phase is rejected") +refused(function(_, _, b) b.queue = { { text = "busy" } } end, + "battle_phase_busy", "nonempty action queue is rejected") +refused(function(_, _, b) b.waitFrames = 1 end, + "battle_phase_busy", "partial wait is rejected") +refused(function(_, _, b) b.player.mustRecharge = true end, + "battle_phase_busy", "automatic locked action is rejected") +refused(function(_, ow) ow.runner = { isRunning = function() return true end } end, + "script_busy", "suspended script beneath battle is rejected") +refused(function(_, _, b) b.checkpointOrigin = nil end, + "battle_origin_unsupported", "unknown completion closure is rejected") +refused(function(_, _, b) b.safari = { balls = 30, steps = 10 } end, + "battle_variant_unsupported", "Safari battle is rejected") +refused(function(_, _, b) b.ghost = true end, + "battle_variant_unsupported", "ghost battle is rejected") +refused(function(_, _, b) b.demo = true end, + "battle_variant_unsupported", "old-man demo is rejected") +refused(function(_, _, b) b.kind = "link" end, + "link_battle_unsupported", "link battle is rejected") + +-- Ordinary overworld behavior remains unchanged by the battle branch. +game.stack.states[2] = nil +T.same(Checkpoint.inspect(game), { + canCapture = true, canRestore = true, kind = "overworld", +}, "settled overworld remains supported") + +T.finish() From 67b6dcc29391a0b1be1c578b567ee3bf06b748e7 Mon Sep 17 00:00:00 2001 From: MaxTomahawk Date: Fri, 7 Aug 2026 16:22:43 +0200 Subject: [PATCH 35/63] feat: capture data-only battle checkpoints --- src/core/BattleCheckpoint.lua | 140 ++++++++++++++++++++ src/core/Checkpoint.lua | 20 +++ tests/engine/battle_checkpoint_capture.lua | 146 +++++++++++++++++++++ 3 files changed, 306 insertions(+) create mode 100644 src/core/BattleCheckpoint.lua create mode 100644 tests/engine/battle_checkpoint_capture.lua diff --git a/src/core/BattleCheckpoint.lua b/src/core/BattleCheckpoint.lua new file mode 100644 index 00000000..1e2605e5 --- /dev/null +++ b/src/core/BattleCheckpoint.lua @@ -0,0 +1,140 @@ +-- Semantic, data-only capture for settled single-player battle checkpoints. +-- Reconstruction lives here too; public mods only see the opaque checkpoint +-- facade in Loader. + +local BattleCheckpoint = {} + +local BATTLER_FIELDS = { + "shownHP", "shownStatus", "stages", "curStats", "curTypes", "curMoves", + "sleepTurns", "confusedTurns", "disabledSlot", "disabledTurns", + "toxicCounter", "substituteHP", "bideDamage", "bideTurns", "boundTurns", + "charging", "chargeReady", "invulnerable", "mustRecharge", "thrashMove", + "thrashTurns", "thrashAnnounced", "rageMove", "focusEnergy", "leechSeeded", + "lightScreen", "reflect", "mist", "xAccuracy", "lastMove", "flinched", + "skipMove", "hazeStatReset", "drainFloor", "drainHold", "trappingTurns", + "trapMove", "trapDamage", "fainted", +} + +local BATTLE_FIELDS = { + "oppClass", "partyIndex", "enemyIndex", "turnCount", "menuIndex", + "moveIndex", "moveSwapIndex", "aiUses", "runAttempts", "payDay", + "sideToxic", "isGymLeader", "musicKind", "lastBall", "lockedBall", + "lowHealthAlarmDisabled", "lowHealthAlarmOn", "victoryMusicPlayed", + "endBattleText", +} + +local function partyIndex(party, mon) + for index, candidate in ipairs(party or {}) do + if candidate == mon then return index end + end +end + +local function indexSet(set, party) + local out = {} + for mon, present in pairs(set or {}) do + if present then + local index = partyIndex(party, mon) + if index then out[#out + 1] = index end + end + end + table.sort(out) + return out +end + +local function captureBattler(battler, index, copy) + local out = { index = index } + for _, field in ipairs(BATTLER_FIELDS) do + if battler[field] ~= nil then out[field] = battler[field] end + end + return copy(out) +end + +local function captureExtensions(battle, copy) + local sides = {} + for i = 1, 2 do + local side = battle.sides and battle.sides[i] or {} + local encoded, err = copy({ + index = i, + screens = side.screens or {}, + hazards = side.hazards or {}, + tokens = side.tokens or {}, + }) + if not encoded then return nil, err end + sides[i] = encoded + end + local field, err = copy({ + weather = battle.field and battle.field.weather or nil, + tokens = battle.field and battle.field.tokens or {}, + }) + if not field then return nil, err end + return sides, field +end + +function BattleCheckpoint.capture(game, battle, progress, copy) + local getState = love and love.math and love.math.getRandomState + local setState = love and love.math and love.math.setRandomState + if type(getState) ~= "function" or type(setState) ~= "function" then + return nil, "rng_state_unavailable", + "This runtime cannot preserve deterministic battle randomness." + end + local ok, rngState = pcall(getState) + if not ok or type(rngState) ~= "string" or rngState == "" then + return nil, "rng_state_unavailable", + "The gameplay random-number state could not be captured." + end + + local origin, originErr = copy(battle.checkpointOrigin) + if not origin then + return nil, "battle_origin_unsupported", + "The battle completion path is not data-only: " .. tostring(originErr) + end + local sides, fieldOrErr = captureExtensions(battle, copy) + if not sides then + return nil, "battle_extension_unsafe", + "Battle extension state is not data-only: " .. tostring(fieldOrErr) + end + local field = fieldOrErr + + local liveParty = game.save.party + local playerIndex = partyIndex(liveParty, battle.player.mon) + if not playerIndex then + return nil, "battle_state_invalid", + "The active player battler is not in the current party." + end + + local model = { + kind = battle.kind, + origin = origin, + player = captureBattler(battle.player, playerIndex, copy), + participants = indexSet(battle.participants, liveParty), + leveledUp = indexSet(battle.leveledUp, liveParty), + sides = sides, + field = field, + } + if battle.kind == "trainer" then + model.enemyParty = copy(battle.enemyParty) + model.enemy = captureBattler(battle.enemy, battle.enemyIndex, copy) + else + model.enemyMon = copy(battle.enemy.mon) + model.enemy = captureBattler(battle.enemy, 1, copy) + end + for _, fieldName in ipairs(BATTLE_FIELDS) do + if battle[fieldName] ~= nil then model[fieldName] = battle[fieldName] end + end + + model = copy(model) + if not model then + return nil, "battle_state_invalid", + "Battle state contains non-serializable runtime data." + end + local player = progress.player + return { + overworld = { + map = player.map, x = player.x, y = player.y, + facing = player.facing, surfing = player.surfing and true or false, + }, + battle = model, + }, { love = rngState } +end + +return BattleCheckpoint diff --git a/src/core/Checkpoint.lua b/src/core/Checkpoint.lua index 28b1093a..a2d6c110 100644 --- a/src/core/Checkpoint.lua +++ b/src/core/Checkpoint.lua @@ -5,6 +5,7 @@ local SaveSerializer = require("src.core.SaveSerializer") local SaveData = require("src.core.SaveData") local Version = require("src.core.Version") local BattleState = require("src.battle.BattleState") +local BattleCheckpoint = require("src.core.BattleCheckpoint") local Checkpoint = {} @@ -172,6 +173,25 @@ function Checkpoint.capture(game) .. tostring(err) end + if capability.kind == "battle" then + local battle = game.stack:top() + local runtime, rngOrCode, battleMessage = + BattleCheckpoint.capture(game, battle, progress, dataCopy) + if not runtime then return nil, rngOrCode, battleMessage end + return { + format = Checkpoint.FORMAT, + kind = "battle", + identity = { + engineVersion = Version.engine, + gameVersion = game.save.version, + playthroughId = game.save.meta.playthroughId, + }, + save = progress, + runtime = runtime, + rng = rngOrCode, + } + end + local player = game.overworld.player return { format = Checkpoint.FORMAT, diff --git a/tests/engine/battle_checkpoint_capture.lua b/tests/engine/battle_checkpoint_capture.lua new file mode 100644 index 00000000..9ec2a5ae --- /dev/null +++ b/tests/engine/battle_checkpoint_capture.lua @@ -0,0 +1,146 @@ +-- Data-only capture of a settled battle checkpoint, including deterministic +-- gameplay RNG and normalized object-reference sets. + +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") + +local T = require("tests.harness").suite("battle checkpoint capture") +local Fixtures = require("tests.modkit").fixtures +local BattleState = require("src.battle.BattleState") +local Checkpoint = require("src.core.Checkpoint") +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") +local SaveSerializer = require("src.core.SaveSerializer") +local StateStack = require("src.core.StateStack") + +local Data = Fixtures.fresh() +local oldGet, oldSet = love.math.getRandomState, love.math.setRandomState +local randomState = "fixture-rng-A" +love.math.getRandomState = function() return randomState end +love.math.setRandomState = function(state) randomState = state end + +local function makeGame(kind) + local save = SaveData.newGame() + save.meta.playthroughId = "battle-playthrough" + save.player.map, save.player.x, save.player.y = "FIX_TOWN", 2, 3 + save.party = { + Pokemon.new(Data, "FIXMON_A", 20), + Pokemon.new(Data, "FIXMON_C", 15), + } + local stack = setmetatable({ states = {} }, { __index = StateStack }) + local overworld = { + map = { id = "FIX_TOWN" }, + player = { cellX = 2, cellY = 3, facing = "left", surfing = false }, + runner = { isRunning = function() return false end }, + parallelRunners = {}, pendingScripts = {}, parallelQueue = {}, scriptMoves = {}, + } + function overworld:captureSave(target) + target.player.map = self.map.id + target.player.x, target.player.y = self.player.cellX, self.player.cellY + target.player.facing = self.player.facing + target.player.surfing = self.player.surfing and true or false + end + local game = { data = Data, save = save, stack = stack, overworld = overworld } + stack.states[1] = overworld + local battle + if kind == "trainer" then + battle = BattleState.newTrainer(game, "OPP_FIX_YOUNGSTER", 1) + battle.checkpointOrigin = { + kind = "trainer_encounter", map = "FIX_TOWN", npc = "TRAINER_1", + event = "EVENT_BEAT_TRAINER_1", + } + else + battle = BattleState.newWild(game, "FIXMON_B", 12) + battle.checkpointOrigin = { kind = "wild_encounter", map = "FIX_TOWN" } + end + battle.phase, battle.queue = "menu", {} + battle.onFinish = function() end + stack.states[2] = battle + return game, battle +end + +local game, battle = makeGame("wild") +battle.turnCount = 7 +battle.runAttempts = 2 +battle.payDay = 45 +battle.player.stages.attack = 2 +battle.player.confusedTurns = 3 +battle.player.curTypes = { "FIRE", "FLYING" } +battle.enemy.mon.hp = battle.enemy.mon.hp - 4 +battle.enemy.stages.defense = -1 +battle.participants = { [game.save.party[1]] = true, + [game.save.party[2]] = true } +battle.leveledUp = { [game.save.party[2]] = true } +battle.sideToxic = { enemy = 3 } +battle.sides[1].screens.reflect = { turns = 2 } +battle.field.weather = { id = "fixture-rain", turns = 4 } + +local snapshot, code, message = Checkpoint.capture(game) +T.check(snapshot ~= nil, "settled battle captures: " .. tostring(code or message)) +T.eq(snapshot and snapshot.kind, "battle", "checkpoint kind is battle") +if snapshot and snapshot.kind == "battle" then + T.eq(snapshot.rng.love, "fixture-rng-A", "LÖVE RNG state is captured") + T.same(snapshot.runtime.overworld, + { map = "FIX_TOWN", x = 2, y = 3, facing = "left", surfing = false }, + "return overworld point is captured") + T.same(snapshot.runtime.battle.origin, + { kind = "wild_encounter", map = "FIX_TOWN" }, + "semantic continuation origin is data-only") + T.eq(snapshot.runtime.battle.turnCount, 7, "turn count is captured") + T.eq(snapshot.runtime.battle.runAttempts, 2, "escape attempts are captured") + T.eq(snapshot.runtime.battle.player.stages.attack, 2, + "player stat stages are captured") + T.eq(snapshot.runtime.battle.player.confusedTurns, 3, + "player volatile status is captured") + T.same(snapshot.runtime.battle.player.curTypes, { "FIRE", "FLYING" }, + "transformed battle types are captured") + T.eq(snapshot.runtime.battle.enemy.stages.defense, -1, + "enemy stat stages are captured") + T.same(snapshot.runtime.battle.participants, { 1, 2 }, + "Pokemon-keyed participants normalize to party indices") + T.same(snapshot.runtime.battle.leveledUp, { 2 }, + "Pokemon-keyed level-up set normalizes to party indices") + T.same(snapshot.runtime.battle.sides[1].screens.reflect, { turns = 2 }, + "data-only side extensions are captured") + T.same(snapshot.runtime.battle.field.weather, + { id = "fixture-rain", turns = 4 }, + "data-only field extensions are captured") + local encoded = SaveSerializer.encode(snapshot) + T.check(type(encoded) == "string" and #encoded > 0, + "battle checkpoint passes the canonical data-only serializer") + + snapshot.save.money = 1 + snapshot.runtime.battle.player.stages.attack = -6 + T.check(game.save.money ~= 1, "checkpoint progress is detached") + T.eq(battle.player.stages.attack, 2, "checkpoint battle state is detached") +end + +local trainerGame, trainer = makeGame("trainer") +trainer.enemyIndex = 1 +trainer.aiUses = 2 +local trainerSnapshot = Checkpoint.capture(trainerGame) +T.eq(trainerSnapshot and trainerSnapshot.kind, "battle", + "ordinary trainer battle captures") +if trainerSnapshot and trainerSnapshot.kind == "battle" then + T.eq(trainerSnapshot.runtime.battle.oppClass, "OPP_FIX_YOUNGSTER", + "trainer class is captured") + T.eq(trainerSnapshot.runtime.battle.partyIndex, 1, + "trainer roster index is captured") + T.eq(#trainerSnapshot.runtime.battle.enemyParty, #trainer.enemyParty, + "complete enemy roster is captured") +end + +local extensionGame, extensionBattle = makeGame("wild") +extensionBattle.field.tokens[1] = { id = "callback-token", onExpire = function() end } +local unsafe, unsafeCode = Checkpoint.capture(extensionGame) +T.check(unsafe == nil and unsafeCode == "battle_extension_unsafe", + "callback-bearing battle extensions are rejected, not stripped") + +love.math.getRandomState = nil +local rngGame = makeGame("wild") +local noRng, rngCode = Checkpoint.capture(rngGame) +T.check(noRng == nil and rngCode == "rng_state_unavailable", + "battle capture fails closed without serializable gameplay RNG") + +love.math.getRandomState, love.math.setRandomState = oldGet, oldSet +T.finish() From 85c6fde443739f7ec8c77353b3a68225f3b8f43b Mon Sep 17 00:00:00 2001 From: MaxTomahawk Date: Fri, 7 Aug 2026 16:27:54 +0200 Subject: [PATCH 36/63] feat: reconstruct battle checkpoints from data --- src/core/BattleCheckpoint.lua | 183 +++++++++++++++++++- src/core/Checkpoint.lua | 49 +++++- tests/engine/battle_checkpoint_boundary.lua | 2 + tests/engine/battle_checkpoint_capture.lua | 1 + tests/engine/battle_checkpoint_restore.lua | 171 ++++++++++++++++++ 5 files changed, 402 insertions(+), 4 deletions(-) create mode 100644 tests/engine/battle_checkpoint_restore.lua diff --git a/src/core/BattleCheckpoint.lua b/src/core/BattleCheckpoint.lua index 1e2605e5..bd5bda56 100644 --- a/src/core/BattleCheckpoint.lua +++ b/src/core/BattleCheckpoint.lua @@ -3,6 +3,7 @@ -- facade in Loader. local BattleCheckpoint = {} +local BattleState = require("src.battle.BattleState") local BATTLER_FIELDS = { "shownHP", "shownStatus", "stages", "curStats", "curTypes", "curMoves", @@ -42,13 +43,193 @@ local function indexSet(set, party) end local function captureBattler(battler, index, copy) - local out = { index = index } + local out = { + index = index, + curStatsFromMon = battler.curStats == battler.mon.stats, + curTypesFromDefinition = battler.curTypes == battler.def.types, + curMovesFromMon = battler.curMoves == battler.mon.moves, + } for _, field in ipairs(BATTLER_FIELDS) do if battler[field] ~= nil then out[field] = battler[field] end end return copy(out) end +local function integer(value, min, max) + return type(value) == "number" and value % 1 == 0 + and value >= (min or -math.huge) and value <= (max or math.huge) +end + +local function validateMoveList(data, moves) + if type(moves) ~= "table" then return false end + for _, move in ipairs(moves) do + if type(move) ~= "table" or type(move.id) ~= "string" + or type(data.moves[move.id]) ~= "table" or type(move.pp) ~= "number" then + return false + end + end + return true +end + +local function validateMon(data, mon) + return type(mon) == "table" and type(mon.species) == "string" + and type(data.pokemon[mon.species]) == "table" and integer(mon.level, 1, 100) + and type(mon.hp) == "number" and type(mon.stats) == "table" + and validateMoveList(data, mon.moves) +end + +local function validateBattler(data, battler, maxIndex) + if type(battler) ~= "table" or not integer(battler.index, 1, maxIndex) then + return false + end + if battler.stages ~= nil then + if type(battler.stages) ~= "table" then return false end + for _, stage in pairs(battler.stages) do + if not integer(stage, -6, 6) then return false end + end + end + return validateMoveList(data, battler.curMoves) + and type(battler.curStats) == "table" and type(battler.curTypes) == "table" +end + +local function clone(value, copy) + if type(value) ~= "table" then return value end + return assert(copy(value)) +end + +function BattleCheckpoint.validate(game, checkpoint) + local model = checkpoint.runtime and checkpoint.runtime.battle + local rngState = checkpoint.rng and checkpoint.rng.love + if type(model) ~= "table" or type(model.origin) ~= "table" + or type(rngState) ~= "string" or rngState == "" then + return nil, "invalid_checkpoint", "Battle checkpoint data or RNG is missing." + end + local expectedOrigin = model.kind == "wild" and "wild_encounter" + or model.kind == "trainer" and "trainer_encounter" or nil + if not expectedOrigin or model.origin.kind ~= expectedOrigin + or model.origin.map ~= checkpoint.runtime.overworld.map then + return nil, "battle_origin_unsupported", + "Battle continuation data is unsupported or inconsistent." + end + local party = checkpoint.save.party + if type(party) ~= "table" or not validateBattler(game.data, model.player, #party) then + return nil, "invalid_content", "Player battle state is invalid." + end + if model.kind == "wild" then + if not validateMon(game.data, model.enemyMon) + or not validateBattler(game.data, model.enemy, 1) then + return nil, "invalid_content", "Wild opponent state is invalid." + end + else + local trainer = game.data.trainers and game.data.trainers[model.oppClass] + if type(trainer) ~= "table" or not integer(model.partyIndex, 1) + or type(model.enemyParty) ~= "table" or #model.enemyParty == 0 + or not integer(model.enemyIndex, 1, #model.enemyParty) + or not validateBattler(game.data, model.enemy, #model.enemyParty) then + return nil, "invalid_content", "Trainer battle identity or roster is invalid." + end + for _, mon in ipairs(model.enemyParty) do + if not validateMon(game.data, mon) then + return nil, "invalid_content", "Trainer opponent state is invalid." + end + end + end + for _, indices in ipairs({ model.participants, model.leveledUp }) do + if type(indices) ~= "table" then + return nil, "invalid_checkpoint", "Battle party reference set is missing." + end + for _, index in ipairs(indices) do + if not integer(index, 1, #party) then + return nil, "invalid_checkpoint", "Battle party reference is invalid." + end + end + end + return true +end + +local function applyBattler(target, captured, copy) + for _, field in ipairs(BATTLER_FIELDS) do + if field ~= "curStats" and field ~= "curTypes" and field ~= "curMoves" then + target[field] = captured[field] ~= nil and clone(captured[field], copy) or nil + end + end + target.curStats = captured.curStatsFromMon and target.mon.stats + or assert(copy(captured.curStats)) + target.curTypes = captured.curTypesFromDefinition and target.def.types + or assert(copy(captured.curTypes)) + target.curMoves = captured.curMovesFromMon and target.mon.moves + or assert(copy(captured.curMoves)) + return target +end + +local function restoreIndexSet(indices, party) + local out = {} + for _, index in ipairs(indices or {}) do out[party[index]] = true end + return next(out) and out or nil +end + +function BattleCheckpoint.restore(game, checkpoint, copy) + local model = checkpoint.runtime.battle + local battle + if model.kind == "trainer" then + battle = BattleState.newTrainer(game, model.oppClass, model.partyIndex) + battle.enemyParty = assert(copy(model.enemyParty)) + battle.enemyIndex = model.enemyIndex + else + battle = BattleState.newWild(game, model.enemyMon.species, model.enemyMon.level) + end + + battle.player = BattleState.makeBattler(game.data, + game.save.party[model.player.index], true, game.save) + applyBattler(battle.player, model.player, copy) + local enemyMon + if model.kind == "trainer" then + enemyMon = battle.enemyParty[model.enemy.index] + else + enemyMon = assert(copy(model.enemyMon)) + end + battle.enemy = BattleState.makeBattler(game.data, enemyMon, false) + applyBattler(battle.enemy, model.enemy, copy) + + for _, field in ipairs(BATTLE_FIELDS) do + battle[field] = model[field] ~= nil and clone(model[field], copy) or nil + end + battle.kind = model.kind + battle.checkpointOrigin = assert(copy(model.origin)) + battle.participants = restoreIndexSet(model.participants, game.save.party) + battle.leveledUp = restoreIndexSet(model.leveledUp, game.save.party) + battle.sides = assert(copy(model.sides)) + battle.sides[1].battlers = { battle.player } + battle.sides[2].battlers = { battle.enemy } + battle.field = assert(copy(model.field)) + battle.field.sides = battle.sides + battle.phase, battle.queue = "menu", {} + battle.frame = 0 + battle.current, battle.afterQueue, battle.nextInsert = nil, nil, nil + battle.pendingHit, battle.waitingUI, battle.waitingSound = nil, nil, nil + battle.waitFrames, battle.draining, battle.animPlaying = nil, nil, nil + battle.introText, battle.introBalls, battle.introSlide = nil, nil, nil + battle.showPlayerBack, battle.showEnemyTrainer, battle.showEnemyBalls = nil, nil, nil + battle.player.shownHP, battle.player.shownStatus = + battle.player.mon.hp, battle.player.mon.status + battle.enemy.shownHP, battle.enemy.shownStatus = + battle.enemy.mon.hp, battle.enemy.mon.status + + local ow = game.overworld + if not ow or type(ow.restoreBattleContinuation) ~= "function" + or ow:restoreBattleContinuation(battle, battle.checkpointOrigin) ~= true then + error("battle continuation reconstruction is unavailable", 0) + end + if type(game.restoreCheckpointBattle) ~= "function" then + error("game has no battle checkpoint reconstruction path", 0) + end + game:restoreCheckpointBattle(battle) + local setState = love and love.math and love.math.setRandomState + if type(setState) ~= "function" then error("battle RNG restore is unavailable", 0) end + setState(checkpoint.rng.love) + return battle +end + local function captureExtensions(battle, copy) local sides = {} for i = 1, 2 do diff --git a/src/core/Checkpoint.lua b/src/core/Checkpoint.lua index a2d6c110..1f375283 100644 --- a/src/core/Checkpoint.lua +++ b/src/core/Checkpoint.lua @@ -81,6 +81,15 @@ local function inspectBattle(ow, battle) return refusal("battle", "battle_phase_busy", "Wait for an ordinary player decision before creating a checkpoint.") end + for _, battler in ipairs({ battle.player, battle.enemy }) do + if battler.shownHP ~= battler.mon.hp + or battler.shownStatus ~= battler.mon.status + or battler.drainFloor ~= nil or battler.drainHold ~= nil + or battler.faintQueued then + return refusal("battle", "battle_phase_busy", + "Wait for battle status and HP presentation to settle.") + end + end return { canCapture = true, canRestore = true, kind = "battle" } end @@ -221,8 +230,8 @@ local function validate(game, checkpoint) if checkpoint.format ~= Checkpoint.FORMAT then return nil, "unsupported_format", "This checkpoint format is not supported." end - if checkpoint.kind ~= "overworld" then - return nil, "unsupported_runtime_kind", "Only overworld checkpoints are supported." + if checkpoint.kind ~= "overworld" and checkpoint.kind ~= "battle" then + return nil, "unsupported_runtime_kind", "This checkpoint runtime kind is not supported." end local copy, copyErr = dataCopy(checkpoint) @@ -288,6 +297,13 @@ local function validate(game, checkpoint) return nil, "invalid_content", "Checkpoint references unavailable or invalid game content." end + if copy.kind == "battle" then + local battleOk, battleCode, battleMessage = BattleCheckpoint.validate(game, copy) + if not battleOk then return nil, battleCode, battleMessage end + elseif copy.runtime.battle ~= nil or copy.rng ~= nil then + return nil, "invalid_checkpoint", + "Overworld checkpoint contains unexpected battle state." + end return copy end @@ -305,6 +321,9 @@ local function apply(game, checkpoint, options) error("game has no checkpoint reconstruction path", 0) end game:restoreCheckpointSave(save) + if checkpoint.kind == "battle" then + BattleCheckpoint.restore(game, checkpoint, dataCopy) + end end local function equalData(a, b) @@ -313,6 +332,28 @@ local function equalData(a, b) return okA and okB and encodedA == encodedB end +local function firstDifference(a, b, path) + path = path or "$" + if type(a) ~= type(b) then return path .. " (type)" end + if type(a) ~= "table" then + if a ~= b then return path end + return nil + end + for key, value in pairs(a) do + if b[key] == nil and value ~= nil then + return path .. "." .. tostring(key) .. " (missing)" + end + local found = firstDifference(value, b[key], path .. "." .. tostring(key)) + if found then return found end + end + for key, value in pairs(b) do + if a[key] == nil and value ~= nil then + return path .. "." .. tostring(key) .. " (unexpected)" + end + end + return nil +end + function Checkpoint.restore(game, checkpoint) local capability = Checkpoint.inspect(game) if not capability.canRestore then @@ -329,7 +370,9 @@ function Checkpoint.restore(game, checkpoint) if ok then local restored, verifyCode = Checkpoint.capture(game) if restored and equalData(restored, validated) then return true end - err = "restored state did not match checkpoint: " .. tostring(verifyCode) + err = restored and ("restored state differed at " + .. tostring(firstDifference(validated, restored) or "canonical encoding")) + or ("restored state could not be captured: " .. tostring(verifyCode)) end local rolledBack, rollbackErr = pcall(apply, game, rollback, options) diff --git a/tests/engine/battle_checkpoint_boundary.lua b/tests/engine/battle_checkpoint_boundary.lua index 580b8b67..e8d1bf5f 100644 --- a/tests/engine/battle_checkpoint_boundary.lua +++ b/tests/engine/battle_checkpoint_boundary.lua @@ -59,6 +59,8 @@ refused(function(_, _, b) b.queue = { { text = "busy" } } end, "battle_phase_busy", "nonempty action queue is rejected") refused(function(_, _, b) b.waitFrames = 1 end, "battle_phase_busy", "partial wait is rejected") +refused(function(_, _, b) b.enemy.mon.hp = b.enemy.mon.hp - 1 end, + "battle_phase_busy", "unfinished HP display synchronization is rejected") refused(function(_, _, b) b.player.mustRecharge = true end, "battle_phase_busy", "automatic locked action is rejected") refused(function(_, ow) ow.runner = { isRunning = function() return true end } end, diff --git a/tests/engine/battle_checkpoint_capture.lua b/tests/engine/battle_checkpoint_capture.lua index 9ec2a5ae..98c3512b 100644 --- a/tests/engine/battle_checkpoint_capture.lua +++ b/tests/engine/battle_checkpoint_capture.lua @@ -67,6 +67,7 @@ battle.player.stages.attack = 2 battle.player.confusedTurns = 3 battle.player.curTypes = { "FIRE", "FLYING" } battle.enemy.mon.hp = battle.enemy.mon.hp - 4 +battle.enemy.shownHP = battle.enemy.mon.hp battle.enemy.stages.defense = -1 battle.participants = { [game.save.party[1]] = true, [game.save.party[2]] = true } diff --git a/tests/engine/battle_checkpoint_restore.lua b/tests/engine/battle_checkpoint_restore.lua new file mode 100644 index 00000000..46ccc934 --- /dev/null +++ b/tests/engine/battle_checkpoint_restore.lua @@ -0,0 +1,171 @@ +-- A battle checkpoint reconstructs a new controller from data, rather than +-- retaining the original table/closure, and restores gameplay RNG exactly. + +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") + +local T = require("tests.harness").suite("battle checkpoint restore") +local Fixtures = require("tests.modkit").fixtures +local BattleState = require("src.battle.BattleState") +local Checkpoint = require("src.core.Checkpoint") +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") +local StateStack = require("src.core.StateStack") + +local Data = Fixtures.fresh() +local oldRandom = love.math.random +local oldGet, oldSet = love.math.getRandomState, love.math.setRandomState +local rng = 12345 +love.math.getRandomState = function() return tostring(rng) end +love.math.setRandomState = function(state) rng = assert(tonumber(state)) end +love.math.random = function(a, b) + rng = (rng * 1103515245 + 12345) % 2147483648 + local unit = rng / 2147483648 + if a == nil then return unit end + if b == nil then return math.floor(unit * a) + 1 end + return a + math.floor(unit * (b - a + 1)) +end + +local function makeGame(kind) + local save = SaveData.newGame() + save.meta.playthroughId = "battle-playthrough" + save.player.map, save.player.x, save.player.y = "FIX_TOWN", 2, 3 + save.player.facing, save.player.surfing = "left", false + save.party = { + Pokemon.new(Data, "FIXMON_A", 20), + Pokemon.new(Data, "FIXMON_C", 15), + } + -- Strip new-game defaults that are intentionally absent from the tiny + -- fixture registry, then place the sanitized save on a fixture map. + SaveData.validate(save, Data) + save.player.map, save.player.x, save.player.y = "FIX_TOWN", 2, 3 + save.player.facing, save.player.surfing = "left", false + local stack = setmetatable({ states = {} }, { __index = StateStack }) + local overworld = { + map = { id = "FIX_TOWN" }, + player = { cellX = 2, cellY = 3, facing = "left", surfing = false }, + runner = { isRunning = function() return false end }, + parallelRunners = {}, pendingScripts = {}, parallelQueue = {}, scriptMoves = {}, + } + function overworld:captureSave(target) + target.player.map = self.map.id + target.player.x, target.player.y = self.player.cellX, self.player.cellY + target.player.facing = self.player.facing + target.player.surfing = self.player.surfing and true or false + end + function overworld:restoreBattleContinuation(battle, origin) + battle.onFinish = function(result) + self.lastRestoredFinish = { result = result, origin = origin.kind } + end + return true + end + local game = { data = Data, save = save, stack = stack, overworld = overworld } + function game:restoreCheckpointSave(loaded) + self.save = loaded + self.overworld.map = { id = loaded.player.map } + self.overworld.player = { + cellX = loaded.player.x, cellY = loaded.player.y, + facing = loaded.player.facing, + surfing = loaded.player.surfing and true or false, + } + self.overworld.runner = { isRunning = function() return false end } + self.overworld.parallelRunners, self.overworld.pendingScripts = {}, {} + self.overworld.parallelQueue, self.overworld.scriptMoves = {}, {} + self.stack.states = { self.overworld } + end + function game:restoreCheckpointBattle(battle) + self.stack.states[#self.stack.states + 1] = battle + end + stack.states[1] = overworld + local battle + if kind == "trainer" then + battle = BattleState.newTrainer(game, "OPP_FIX_YOUNGSTER", 1) + battle.checkpointOrigin = { + kind = "trainer_encounter", map = "FIX_TOWN", npc = "TRAINER_1", + event = "EVENT_BEAT_TRAINER_1", + } + else + battle = BattleState.newWild(game, "FIXMON_B", 12) + battle.checkpointOrigin = { kind = "wild_encounter", map = "FIX_TOWN" } + end + battle.phase, battle.queue = "menu", {} + battle.onFinish = function() end + stack.states[2] = battle + return game, battle +end + +local function settleOverworld(game) + game.stack.states = { game.overworld } + game.save.money = 999999 + game.save.party[1].hp = 1 + game.overworld.player.cellX = 8 + game.overworld.player.facing = "up" +end + +local game, originalBattle = makeGame("wild") +originalBattle.turnCount = 4 +originalBattle.runAttempts = 1 +originalBattle.player.stages.speed = 3 +originalBattle.enemy.mon.hp = originalBattle.enemy.mon.hp - 5 +originalBattle.enemy.shownHP = originalBattle.enemy.mon.hp +originalBattle.enemy.disabledSlot = 1 +originalBattle.enemy.disabledTurns = 2 +originalBattle.participants = { [game.save.party[1]] = true } +local checkpoint = assert(Checkpoint.capture(game)) +local expectedNext = love.math.random(1, 1000000) + +settleOverworld(game) +rng = 777 +local restored, code, message = Checkpoint.restore(game, checkpoint) +T.check(restored == true, "battle checkpoint restores: " .. tostring(message or code)) +local rebuilt = restored and game.stack:top() +if restored then + T.check(rebuilt ~= originalBattle, "restore creates a new battle controller") + T.eq(getmetatable(rebuilt), BattleState, "restored stack top is a BattleState") + T.eq(rebuilt.phase, "menu", "restored battle resumes at the decision menu") + T.eq(#rebuilt.queue, 0, "restored battle has no stale action queue") + T.eq(rebuilt.turnCount, 4, "turn count roundtrips") + T.eq(rebuilt.runAttempts, 1, "escape state roundtrips") + T.eq(rebuilt.player.stages.speed, 3, "player stages roundtrip") + T.eq(rebuilt.enemy.disabledSlot, 1, "enemy volatile state roundtrips") + T.eq(rebuilt.enemy.disabledTurns, 2, "enemy volatile duration roundtrips") + T.eq(rebuilt.enemy.mon.hp, checkpoint.runtime.battle.enemyMon.hp, + "enemy Pokemon model roundtrips") + T.eq(game.save.money, checkpoint.save.money, "persistent progress roundtrips") + T.eq(game.save.party[1].hp, checkpoint.save.party[1].hp, + "party model roundtrips") + T.same(Checkpoint.capture(game), checkpoint, + "capture A, discard, restore A, capture A2 yields normalized A == A2") + T.eq(love.math.random(1, 1000000), expectedNext, + "the exact next gameplay RNG result repeats after reload") + rebuilt.onFinish("run") + T.same(game.overworld.lastRestoredFinish, + { result = "run", origin = "wild_encounter" }, + "restored battle receives a reconstructed semantic continuation") +end + +local trainerGame, trainerOriginal = makeGame("trainer") +trainerOriginal.turnCount = 6 +trainerOriginal.enemy.mon.hp = trainerOriginal.enemy.mon.hp - 3 +trainerOriginal.enemy.shownHP = trainerOriginal.enemy.mon.hp +trainerOriginal.aiUses = 1 +trainerOriginal.participants = { [trainerGame.save.party[1]] = true, + [trainerGame.save.party[2]] = true } +local trainerCheckpoint = assert(Checkpoint.capture(trainerGame)) +settleOverworld(trainerGame) +restored, code, message = Checkpoint.restore(trainerGame, trainerCheckpoint) +T.check(restored == true, "trainer checkpoint restores: " .. tostring(message or code)) +local trainerRebuilt = trainerGame.stack:top() +if restored then + T.check(trainerRebuilt ~= trainerOriginal, + "trainer restore is independent of the original controller") + T.eq(trainerRebuilt.oppClass, "OPP_FIX_YOUNGSTER", "trainer class roundtrips") + T.eq(trainerRebuilt.enemyIndex, 1, "enemy roster index roundtrips") + T.eq(trainerRebuilt.aiUses, 1, "trainer AI item budget roundtrips") + T.same(Checkpoint.capture(trainerGame), trainerCheckpoint, + "trainer differential recapture is exact") +end + +love.math.random = oldRandom +love.math.getRandomState, love.math.setRandomState = oldGet, oldSet +T.finish() From 44a7910c6918b821da107f42d650062f8a8fa05f Mon Sep 17 00:00:00 2001 From: MaxTomahawk Date: Fri, 7 Aug 2026 16:32:42 +0200 Subject: [PATCH 37/63] feat: reconstruct standard battle continuations --- src/battle/BattleState.lua | 9 ++ src/core/BattleCheckpoint.lua | 18 +++- src/core/Game.lua | 11 ++ src/world/OverworldController.lua | 45 ++++++++ tests/engine/battle_checkpoint_capture.lua | 3 +- .../engine/battle_checkpoint_continuation.lua | 102 ++++++++++++++++++ tests/engine/battle_checkpoint_restore.lua | 16 ++- 7 files changed, 196 insertions(+), 8 deletions(-) create mode 100644 tests/engine/battle_checkpoint_continuation.lua diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index b81041e3..86b2ab18 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -98,6 +98,15 @@ function BattleState:bgMode() return "white" end +-- Resume a semantic checkpoint directly at the command menu. Unlike enter(), +-- this deliberately does not replay the battle transition, intro queues, +-- cries, happiness changes, or battle-start events. +function BattleState:resumeCheckpoint() + self.isOpaque = self:bgMode() ~= "world" + require("src.core.Music").playBattle(self.data, + self.musicKind or self:computeMusicKind()) +end + -- How far to dim the overworld behind a "world" background, 0..1. Enough -- that the battle reads as the foreground rather than competing with a fully -- lit map behind it. diff --git a/src/core/BattleCheckpoint.lua b/src/core/BattleCheckpoint.lua index bd5bda56..b424034f 100644 --- a/src/core/BattleCheckpoint.lua +++ b/src/core/BattleCheckpoint.lua @@ -111,6 +111,12 @@ function BattleCheckpoint.validate(game, checkpoint) return nil, "battle_origin_unsupported", "Battle continuation data is unsupported or inconsistent." end + if model.kind == "trainer" and (type(model.origin.npcId) ~= "string" + or model.origin.trainerClass ~= model.oppClass + or model.origin.partyIndex ~= (model.partyIndex or 1)) then + return nil, "battle_origin_unsupported", + "Trainer continuation data is incomplete or inconsistent." + end local party = checkpoint.save.party if type(party) ~= "table" or not validateBattler(game.data, model.player, #party) then return nil, "invalid_content", "Player battle state is invalid." @@ -150,7 +156,11 @@ end local function applyBattler(target, captured, copy) for _, field in ipairs(BATTLER_FIELDS) do if field ~= "curStats" and field ~= "curTypes" and field ~= "curMoves" then - target[field] = captured[field] ~= nil and clone(captured[field], copy) or nil + if captured[field] ~= nil then + target[field] = clone(captured[field], copy) + else + target[field] = nil + end end end target.curStats = captured.curStatsFromMon and target.mon.stats @@ -192,7 +202,11 @@ function BattleCheckpoint.restore(game, checkpoint, copy) applyBattler(battle.enemy, model.enemy, copy) for _, field in ipairs(BATTLE_FIELDS) do - battle[field] = model[field] ~= nil and clone(model[field], copy) or nil + if model[field] ~= nil then + battle[field] = clone(model[field], copy) + else + battle[field] = nil + end end battle.kind = model.kind battle.checkpointOrigin = assert(copy(model.origin)) diff --git a/src/core/Game.lua b/src/core/Game.lua index 27d585b0..5226dd16 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -1143,4 +1143,15 @@ function Game:restoreCheckpointSave(loaded) { via = "checkpoint", checkpoint = true }) end +-- Install a reconstructed battle without calling BattleState:enter(), whose +-- transition, intro queues and battle-start side effects already happened in +-- the checkpointed timeline. +function Game:restoreCheckpointBattle(battle) + if self.stack:top() ~= self.overworld then + error("battle checkpoint requires a reconstructed overworld base", 0) + end + self.stack.states[#self.stack.states + 1] = battle + if battle.resumeCheckpoint then battle:resumeCheckpoint() end +end + return Game diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 8f12c267..1ac89816 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -3059,6 +3059,14 @@ function OverworldState:engageTrainer(npc, onDone, endBattleText, skipBattleText if theme then require("src.core.Music").play(Game.data, theme) end end local battle = BattleState.newTrainer(Game, d.trainerClass, d.trainerParty) + battle.checkpointOrigin = { + kind = "trainer_encounter", + map = self.map.id, + npcId = npc.id, + trainerClass = d.trainerClass, + partyIndex = d.trainerParty or 1, + event = header and header.event or nil, + } -- PrintEndBattleText (home/trainers.asm:341) is called from -- TrainerBattleVictory (engine/battle/core.asm:942), i.e. ON the battle -- screen once ScrollTrainerPicAfterBattle has brought the beaten trainer @@ -3596,6 +3604,10 @@ function OverworldState:onStepComplete() end local BattleState = require("src.battle.BattleState") local battle = BattleState.newWild(Game, enc.species, enc.level) + battle.checkpointOrigin = { + kind = "wild_encounter", + map = self.map.id, + } -- map.ghostBattles: unidentifiable without the named item (the -- Pokemon Tower's Silph Scope) local ghost = Map.ghostBattles(self.map.def) @@ -4000,6 +4012,39 @@ function OverworldState:afterBattle(result, battle) end end +-- Rebind the data-only continuation attached to a supported battle checkpoint. +-- The overworld was reconstructed first, so transient input/NPC freezes from +-- the original encounter are intentionally not resumed. +function OverworldState:restoreBattleContinuation(battle, origin) + local game = battle and battle.game + if not game or type(origin) ~= "table" or not self.map + or origin.map ~= self.map.id then + return false + end + if origin.kind == "wild_encounter" and battle.kind == "wild" then + battle.onFinish = function(result) self:afterBattle(result, battle) end + return true + end + if origin.kind ~= "trainer_encounter" or battle.kind ~= "trainer" + or origin.trainerClass ~= battle.oppClass + or origin.partyIndex ~= (battle.partyIndex or 1) + or type(origin.npcId) ~= "string" then + return false + end + battle.onFinish = function(result) + if result == "win" then + game.save.defeatedTrainers[origin.npcId] = true + if origin.event then game.save.flags[origin.event] = true end + self:checkVictoryRewards(battle.oppClass, battle.partyIndex) + end + self:afterBattle(result, battle) + self.engaging = false + local npc = self.npcPool and self.npcPool[origin.npcId] + if npc then npc.frozen = false end + end + return true +end + -- ------------------------------------------------------------------------- -- warps -- ------------------------------------------------------------------------- diff --git a/tests/engine/battle_checkpoint_capture.lua b/tests/engine/battle_checkpoint_capture.lua index 98c3512b..13f7b16a 100644 --- a/tests/engine/battle_checkpoint_capture.lua +++ b/tests/engine/battle_checkpoint_capture.lua @@ -46,7 +46,8 @@ local function makeGame(kind) if kind == "trainer" then battle = BattleState.newTrainer(game, "OPP_FIX_YOUNGSTER", 1) battle.checkpointOrigin = { - kind = "trainer_encounter", map = "FIX_TOWN", npc = "TRAINER_1", + kind = "trainer_encounter", map = "FIX_TOWN", npcId = "TRAINER_1", + trainerClass = "OPP_FIX_YOUNGSTER", partyIndex = 1, event = "EVENT_BEAT_TRAINER_1", } else diff --git a/tests/engine/battle_checkpoint_continuation.lua b/tests/engine/battle_checkpoint_continuation.lua new file mode 100644 index 00000000..3219119f --- /dev/null +++ b/tests/engine/battle_checkpoint_continuation.lua @@ -0,0 +1,102 @@ +-- Engine-owned battle continuations replace unserializable onFinish closures +-- after a persistent checkpoint reconstructs the overworld and battle. + +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") + +local T = require("tests.harness").suite("battle checkpoint continuation") +local GameMethods = require("src.core.Game") +local OverworldState = require("src.world.OverworldController") + +local function fakeOverworld() + local npc = { id = "FIX_TOWN_obj_1", frozen = true } + local ow = setmetatable({ + map = { id = "FIX_TOWN" }, + npcPool = { [npc.id] = npc }, + engaging = true, + }, { __index = OverworldState }) + ow.afterBattle = function(self, result, battle) + self.after = { result = result, battle = battle } + end + ow.checkVictoryRewards = function(self, class, party) + self.reward = { class = class, party = party } + end + return ow, npc +end + +local wildOw = fakeOverworld() +local wildGame = { save = { defeatedTrainers = {}, flags = {} } } +local wild = { game = wildGame, kind = "wild" } +T.check(wildOw:restoreBattleContinuation(wild, + { kind = "wild_encounter", map = "FIX_TOWN" }) == true, + "ordinary wild continuation binds") +wild.onFinish("run") +T.same(wildOw.after, { result = "run", battle = wild }, + "wild continuation returns through canonical afterBattle") + +local trainerOw, trainerNpc = fakeOverworld() +local trainerGame = { save = { defeatedTrainers = {}, flags = {} } } +local trainer = { + game = trainerGame, kind = "trainer", + oppClass = "OPP_FIX_YOUNGSTER", partyIndex = 1, +} +local trainerOrigin = { + kind = "trainer_encounter", map = "FIX_TOWN", + npcId = trainerNpc.id, trainerClass = trainer.oppClass, partyIndex = 1, + event = "EVENT_BEAT_FIX_TRAINER", +} +T.check(trainerOw:restoreBattleContinuation(trainer, trainerOrigin) == true, + "ordinary trainer continuation binds") +trainer.onFinish("win") +T.check(trainerGame.save.defeatedTrainers[trainerNpc.id] == true, + "trainer win stamps the stable object id") +T.check(trainerGame.save.flags.EVENT_BEAT_FIX_TRAINER == true, + "trainer win stamps the header event") +T.same(trainerOw.reward, + { class = "OPP_FIX_YOUNGSTER", party = 1 }, + "trainer win runs canonical victory rewards") +T.same(trainerOw.after, { result = "win", battle = trainer }, + "trainer win returns through canonical afterBattle") +T.check(trainerOw.engaging == false and trainerNpc.frozen == false, + "reconstructed trainer completion leaves overworld input unfrozen") + +local lossOw, lossNpc = fakeOverworld() +local lossGame = { save = { defeatedTrainers = {}, flags = {} } } +local lossBattle = { + game = lossGame, kind = "trainer", + oppClass = "OPP_FIX_YOUNGSTER", partyIndex = 1, +} +T.check(lossOw:restoreBattleContinuation(lossBattle, trainerOrigin) == true, + "trainer loss continuation binds") +lossBattle.onFinish("lose") +T.eq(lossGame.save.defeatedTrainers[lossNpc.id], nil, + "trainer loss does not stamp the trainer defeated") +T.eq(lossGame.save.flags.EVENT_BEAT_FIX_TRAINER, nil, + "trainer loss does not stamp the header event") +T.eq(lossOw.reward, nil, "trainer loss does not grant victory rewards") + +local mismatchOw = fakeOverworld() +T.check(mismatchOw:restoreBattleContinuation(trainer, { + kind = "trainer_encounter", map = "OTHER_MAP", npcId = trainerNpc.id, + trainerClass = trainer.oppClass, partyIndex = 1, +}) == false, "continuation from another map is rejected") +T.check(mismatchOw:restoreBattleContinuation(trainer, { + kind = "trainer_encounter", map = "FIX_TOWN", npcId = trainerNpc.id, + trainerClass = "OPP_OTHER", partyIndex = 1, +}) == false, "mismatched trainer identity is rejected") + +local ow = {} +local stack = { states = { ow } } +function stack:top() return self.states[#self.states] end +local game = setmetatable({ overworld = ow, stack = stack }, { __index = GameMethods }) +local entered, resumed = false, false +local battle = { + enter = function() entered = true end, + resumeCheckpoint = function() resumed = true end, +} +game:restoreCheckpointBattle(battle) +T.check(game.stack:top() == battle, "reconstructed battle is installed on stack") +T.check(resumed == true, "checkpoint-specific battle resume path runs") +T.check(entered == false, "ordinary battle intro is not replayed") + +T.finish() diff --git a/tests/engine/battle_checkpoint_restore.lua b/tests/engine/battle_checkpoint_restore.lua index 46ccc934..95f23c80 100644 --- a/tests/engine/battle_checkpoint_restore.lua +++ b/tests/engine/battle_checkpoint_restore.lua @@ -8,6 +8,8 @@ local T = require("tests.harness").suite("battle checkpoint restore") local Fixtures = require("tests.modkit").fixtures local BattleState = require("src.battle.BattleState") local Checkpoint = require("src.core.Checkpoint") +local GameMethods = require("src.core.Game") +local Music = require("src.core.Music") local Pokemon = require("src.pokemon.Pokemon") local SaveData = require("src.core.SaveData") local StateStack = require("src.core.StateStack") @@ -15,6 +17,8 @@ local StateStack = require("src.core.StateStack") local Data = Fixtures.fresh() local oldRandom = love.math.random local oldGet, oldSet = love.math.getRandomState, love.math.setRandomState +local oldPlayBattle = Music.playBattle +Music.playBattle = function() end local rng = 12345 love.math.getRandomState = function() return tostring(rng) end love.math.setRandomState = function(state) rng = assert(tonumber(state)) end @@ -59,7 +63,9 @@ local function makeGame(kind) end return true end - local game = { data = Data, save = save, stack = stack, overworld = overworld } + local game = setmetatable( + { data = Data, save = save, stack = stack, overworld = overworld }, + { __index = GameMethods }) function game:restoreCheckpointSave(loaded) self.save = loaded self.overworld.map = { id = loaded.player.map } @@ -73,15 +79,13 @@ local function makeGame(kind) self.overworld.parallelQueue, self.overworld.scriptMoves = {}, {} self.stack.states = { self.overworld } end - function game:restoreCheckpointBattle(battle) - self.stack.states[#self.stack.states + 1] = battle - end stack.states[1] = overworld local battle if kind == "trainer" then battle = BattleState.newTrainer(game, "OPP_FIX_YOUNGSTER", 1) battle.checkpointOrigin = { - kind = "trainer_encounter", map = "FIX_TOWN", npc = "TRAINER_1", + kind = "trainer_encounter", map = "FIX_TOWN", npcId = "TRAINER_1", + trainerClass = "OPP_FIX_YOUNGSTER", partyIndex = 1, event = "EVENT_BEAT_TRAINER_1", } else @@ -89,6 +93,7 @@ local function makeGame(kind) battle.checkpointOrigin = { kind = "wild_encounter", map = "FIX_TOWN" } end battle.phase, battle.queue = "menu", {} + battle.musicKind = battle:computeMusicKind() battle.onFinish = function() end stack.states[2] = battle return game, battle @@ -168,4 +173,5 @@ end love.math.random = oldRandom love.math.getRandomState, love.math.setRandomState = oldGet, oldSet +Music.playBattle = oldPlayBattle T.finish() From 6999e5aecfb0ef3fbe6f29e24755d6c9cad188ce Mon Sep 17 00:00:00 2001 From: MaxTomahawk Date: Fri, 7 Aug 2026 16:32:42 +0200 Subject: [PATCH 38/63] feat: preserve overworld checkpoint RNG --- src/core/Checkpoint.lua | 31 +++++++++++++++++++++++++++++- tests/modkit/cases/checkpoints.lua | 24 +++++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/src/core/Checkpoint.lua b/src/core/Checkpoint.lua index 1f375283..f6827fa8 100644 --- a/src/core/Checkpoint.lua +++ b/src/core/Checkpoint.lua @@ -156,6 +156,27 @@ local function dataCopy(value) return decoded end +local function captureRng() + local getState = love and love.math and love.math.getRandomState + local setState = love and love.math and love.math.setRandomState + if type(getState) ~= "function" or type(setState) ~= "function" then return nil end + local ok, state = pcall(getState) + if ok and type(state) == "string" and state ~= "" then + return { love = state } + end + return nil +end + +local function restoreRng(rng) + if rng == nil then return end -- legacy format-1 overworld checkpoint + local setState = love and love.math and love.math.setRandomState + if type(rng) ~= "table" or type(rng.love) ~= "string" + or type(setState) ~= "function" then + error("checkpoint RNG restore is unavailable", 0) + end + setState(rng.love) +end + function Checkpoint.capture(game) local capability = Checkpoint.inspect(game) if not capability.canCapture then @@ -218,6 +239,7 @@ function Checkpoint.capture(game) facing = player.facing, surfing = player.surfing and true or false, } }, + rng = captureRng(), } end @@ -264,6 +286,10 @@ local function validate(game, checkpoint) or not save.meta or save.meta.playthroughId ~= identity.playthroughId then return nil, "invalid_checkpoint", "Checkpoint progress identity is inconsistent." end + if copy.rng ~= nil and (type(copy.rng) ~= "table" + or type(copy.rng.love) ~= "string" or copy.rng.love == "") then + return nil, "invalid_checkpoint", "Checkpoint RNG state is corrupt." + end if type(runtime.map) ~= "string" or type(runtime.x) ~= "number" or type(runtime.y) ~= "number" or runtime.x % 1 ~= 0 or runtime.y % 1 ~= 0 or not FACINGS[runtime.facing] or type(runtime.surfing) ~= "boolean" then @@ -300,7 +326,7 @@ local function validate(game, checkpoint) if copy.kind == "battle" then local battleOk, battleCode, battleMessage = BattleCheckpoint.validate(game, copy) if not battleOk then return nil, battleCode, battleMessage end - elseif copy.runtime.battle ~= nil or copy.rng ~= nil then + elseif copy.runtime.battle ~= nil then return nil, "invalid_checkpoint", "Overworld checkpoint contains unexpected battle state." end @@ -323,6 +349,8 @@ local function apply(game, checkpoint, options) game:restoreCheckpointSave(save) if checkpoint.kind == "battle" then BattleCheckpoint.restore(game, checkpoint, dataCopy) + else + restoreRng(checkpoint.rng) end end @@ -369,6 +397,7 @@ function Checkpoint.restore(game, checkpoint) local ok, err = pcall(apply, game, validated, options) if ok then local restored, verifyCode = Checkpoint.capture(game) + if restored and validated.rng == nil then restored.rng = nil end if restored and equalData(restored, validated) then return true end err = restored and ("restored state differed at " .. tostring(firstDifference(validated, restored) or "canonical encoding")) diff --git a/tests/modkit/cases/checkpoints.lua b/tests/modkit/cases/checkpoints.lua index a1277b1e..4c26ef87 100644 --- a/tests/modkit/cases/checkpoints.lua +++ b/tests/modkit/cases/checkpoints.lua @@ -4,6 +4,12 @@ package.path = "./?.lua;./?/init.lua;" .. package.path love = love or require("tests.love_stub") +local oldGetRandomState = love.math.getRandomState +local oldSetRandomState = love.math.setRandomState +local checkpointRngState = "overworld-rng-A" +love.math.getRandomState = function() return checkpointRngState end +love.math.setRandomState = function(state) checkpointRngState = state end + local T = require("tests.harness").suite("mod checkpoints") local Loader = require("src.mods.Loader") local Runtime = require("src.mods.Runtime") @@ -217,6 +223,19 @@ T.same(snapshot.runtime.overworld, T.eq(snapshot.save.player.map, "ROUTE_1", "captured progress is synchronized from the live controller") T.eq(snapshot.save.options, nil, "global settings are excluded from progress rewind") +T.same(snapshot.rng, { love = "overworld-rng-A" }, + "overworld checkpoint carries deterministic gameplay RNG") + +local legacy = checkpoints:capture(game) +legacy.rng = nil +checkpointRngState = "legacy-runtime-rng" +local legacyRestored, legacyCode = checkpoints:restore(game, legacy) +T.check(legacyRestored == true, + "legacy format-1 overworld checkpoint without RNG remains loadable: " + .. tostring(legacyCode)) +T.eq(checkpointRngState, "legacy-runtime-rng", + "legacy checkpoint leaves the current RNG stream untouched") +checkpointRngState = "overworld-rng-A" snapshot.save.money = 1 snapshot.runtime.overworld.x = 1 @@ -231,6 +250,7 @@ game.save.money = 999999 game.save.flags.GOT_STARTER = nil game.save.party[1].hp = 1 game.save.options.volume = 9 +checkpointRngState = "overworld-rng-B" ow.map.id, ow.player.cellX, ow.player.cellY = "PALLET_TOWN", 2, 3 ow.player.facing, ow.player.surfing = "up", false @@ -242,6 +262,8 @@ T.same(recaptured, original, "capture A, mutate B, restore A, capture A2 yields normalized A == A2") T.eq(game.save.options.volume, 9, "checkpoint restoration preserves current global settings") +T.eq(checkpointRngState, "overworld-rng-A", + "overworld checkpoint restores gameplay RNG") T.check(game.lastEnterOpts and game.lastEnterOpts.checkpoint == true, "engine reconstruction is marked to suppress map-entry side effects") @@ -301,5 +323,7 @@ T.same(checkpoints:capture(game), beforeFailure, Runtime.events, Runtime.hooks = savedEvents, savedHooks Runtime.currentMod = nil _G.MOD_CHECKPOINTS = nil +love.math.getRandomState = oldGetRandomState +love.math.setRandomState = oldSetRandomState T.finish() From 2ce612d5b14be1dbf4e542674bf6c48ad19b1e16 Mon Sep 17 00:00:00 2001 From: MaxTomahawk Date: Fri, 7 Aug 2026 16:40:14 +0200 Subject: [PATCH 39/63] test: prove deterministic battle checkpoint fidelity --- src/core/BattleCheckpoint.lua | 101 ++++++++++++++++++- tests/engine/battle_checkpoint_capture.lua | 20 ++++ tests/engine/battle_checkpoint_restore.lua | 109 ++++++++++++++++++++- 3 files changed, 225 insertions(+), 5 deletions(-) diff --git a/src/core/BattleCheckpoint.lua b/src/core/BattleCheckpoint.lua index b424034f..912ea1b2 100644 --- a/src/core/BattleCheckpoint.lua +++ b/src/core/BattleCheckpoint.lua @@ -4,16 +4,37 @@ local BattleCheckpoint = {} local BattleState = require("src.battle.BattleState") +local BUILTIN_RULESETS = { + gen1_faithful = require("src.battle.rulesets.gen1_faithful"), + modern_clean = require("src.battle.rulesets.modern_clean"), +} + +local function rulesets(game) + return game.data.rulesets or BUILTIN_RULESETS +end + +local function rulesetId(game, record) + for id, candidate in pairs(rulesets(game)) do + if candidate == record then return id end + end +end local BATTLER_FIELDS = { "shownHP", "shownStatus", "stages", "curStats", "curTypes", "curMoves", "sleepTurns", "confusedTurns", "disabledSlot", "disabledTurns", "toxicCounter", "substituteHP", "bideDamage", "bideTurns", "boundTurns", - "charging", "chargeReady", "invulnerable", "mustRecharge", "thrashMove", - "thrashTurns", "thrashAnnounced", "rageMove", "focusEnergy", "leechSeeded", + "chargeReady", "invulnerable", "mustRecharge", + "thrashTurns", "thrashAnnounced", "focusEnergy", "leechSeeded", "lightScreen", "reflect", "mist", "xAccuracy", "lastMove", "flinched", "skipMove", "hazeStatReset", "drainFloor", "drainHold", "trappingTurns", "trapMove", "trapDamage", "fainted", + "aiLayer2", +} + +local MOVE_REFERENCE_FIELDS = { + charging = "chargingSlot", + thrashMove = "thrashMoveSlot", + rageMove = "rageMoveSlot", } local BATTLE_FIELDS = { @@ -52,6 +73,15 @@ local function captureBattler(battler, index, copy) for _, field in ipairs(BATTLER_FIELDS) do if battler[field] ~= nil then out[field] = battler[field] end end + for field, slotField in pairs(MOVE_REFERENCE_FIELDS) do + local reference = battler[field] + if reference ~= nil then + for slot, move in ipairs(battler.curMoves or {}) do + if move == reference then out[slotField] = slot break end + end + if out[slotField] == nil then return nil end + end + end return copy(out) end @@ -82,12 +112,19 @@ local function validateBattler(data, battler, maxIndex) if type(battler) ~= "table" or not integer(battler.index, 1, maxIndex) then return false end + if type(battler.curMoves) ~= "table" then return false end if battler.stages ~= nil then if type(battler.stages) ~= "table" then return false end for _, stage in pairs(battler.stages) do if not integer(stage, -6, 6) then return false end end end + for _, slotField in pairs(MOVE_REFERENCE_FIELDS) do + if battler[slotField] ~= nil + and not integer(battler[slotField], 1, #battler.curMoves) then + return false + end + end return validateMoveList(data, battler.curMoves) and type(battler.curStats) == "table" and type(battler.curTypes) == "table" end @@ -97,6 +134,21 @@ local function clone(value, copy) return assert(copy(value)) end +local function captureMimicRestores(battle) + local out = {} + for _, restore in ipairs(battle.mimicRestores or {}) do + local side = restore.battler == battle.player and "player" + or restore.battler == battle.enemy and "enemy" or nil + local slot + for index, move in ipairs(restore.battler and restore.battler.curMoves or {}) do + if move == restore.entry then slot = index break end + end + if not side or not slot or type(restore.id) ~= "string" then return nil end + out[#out + 1] = { side = side, slot = slot, id = restore.id } + end + return out +end + function BattleCheckpoint.validate(game, checkpoint) local model = checkpoint.runtime and checkpoint.runtime.battle local rngState = checkpoint.rng and checkpoint.rng.love @@ -111,6 +163,10 @@ function BattleCheckpoint.validate(game, checkpoint) return nil, "battle_origin_unsupported", "Battle continuation data is unsupported or inconsistent." end + if type(model.rulesetId) ~= "string" + or type(rulesets(game)[model.rulesetId]) ~= "table" then + return nil, "invalid_content", "Battle ruleset is unavailable." + end if model.kind == "trainer" and (type(model.origin.npcId) ~= "string" or model.origin.trainerClass ~= model.oppClass or model.origin.partyIndex ~= (model.partyIndex or 1)) then @@ -150,6 +206,18 @@ function BattleCheckpoint.validate(game, checkpoint) end end end + if type(model.mimicRestores) ~= "table" then + return nil, "invalid_checkpoint", "Mimic restore state is missing." + end + for _, restore in ipairs(model.mimicRestores) do + local battler = restore.side == "player" and model.player + or restore.side == "enemy" and model.enemy or nil + if not battler or not integer(restore.slot, 1, #battler.curMoves) + or type(restore.id) ~= "string" + or type(game.data.moves[restore.id]) ~= "table" then + return nil, "invalid_content", "Mimic restore state is invalid." + end + end return true end @@ -169,6 +237,9 @@ local function applyBattler(target, captured, copy) or assert(copy(captured.curTypes)) target.curMoves = captured.curMovesFromMon and target.mon.moves or assert(copy(captured.curMoves)) + for field, slotField in pairs(MOVE_REFERENCE_FIELDS) do + target[field] = captured[slotField] and target.curMoves[captured[slotField]] or nil + end return target end @@ -201,6 +272,17 @@ function BattleCheckpoint.restore(game, checkpoint, copy) battle.enemy = BattleState.makeBattler(game.data, enemyMon, false) applyBattler(battle.enemy, model.enemy, copy) + battle.mimicRestores = {} + for _, restore in ipairs(model.mimicRestores or {}) do + local battler = restore.side == "player" and battle.player or battle.enemy + battle.mimicRestores[#battle.mimicRestores + 1] = { + battler = battler, + entry = battler.curMoves[restore.slot], + id = restore.id, + } + end + if #battle.mimicRestores == 0 then battle.mimicRestores = nil end + for _, field in ipairs(BATTLE_FIELDS) do if model[field] ~= nil then battle[field] = clone(model[field], copy) @@ -209,6 +291,7 @@ function BattleCheckpoint.restore(game, checkpoint, copy) end end battle.kind = model.kind + battle.ruleset = rulesets(game)[model.rulesetId] battle.checkpointOrigin = assert(copy(model.origin)) battle.participants = restoreIndexSet(model.participants, game.save.party) battle.leveledUp = restoreIndexSet(model.leveledUp, game.save.party) @@ -299,13 +382,24 @@ function BattleCheckpoint.capture(game, battle, progress, copy) local model = { kind = battle.kind, + rulesetId = rulesetId(game, battle.ruleset), origin = origin, player = captureBattler(battle.player, playerIndex, copy), participants = indexSet(battle.participants, liveParty), leveledUp = indexSet(battle.leveledUp, liveParty), sides = sides, field = field, + mimicRestores = captureMimicRestores(battle), } + if not model.rulesetId then + return nil, "battle_state_invalid", "Battle ruleset identity is unavailable." + end + if not model.player then + return nil, "battle_state_invalid", "Player move references are inconsistent." + end + if not model.mimicRestores then + return nil, "battle_state_invalid", "Mimic restore state is inconsistent." + end if battle.kind == "trainer" then model.enemyParty = copy(battle.enemyParty) model.enemy = captureBattler(battle.enemy, battle.enemyIndex, copy) @@ -313,6 +407,9 @@ function BattleCheckpoint.capture(game, battle, progress, copy) model.enemyMon = copy(battle.enemy.mon) model.enemy = captureBattler(battle.enemy, 1, copy) end + if not model.enemy then + return nil, "battle_state_invalid", "Enemy move references are inconsistent." + end for _, fieldName in ipairs(BATTLE_FIELDS) do if battle[fieldName] ~= nil then model[fieldName] = battle[fieldName] end end diff --git a/tests/engine/battle_checkpoint_capture.lua b/tests/engine/battle_checkpoint_capture.lua index 13f7b16a..84319a6b 100644 --- a/tests/engine/battle_checkpoint_capture.lua +++ b/tests/engine/battle_checkpoint_capture.lua @@ -67,9 +67,18 @@ battle.payDay = 45 battle.player.stages.attack = 2 battle.player.confusedTurns = 3 battle.player.curTypes = { "FIRE", "FLYING" } +local originalMoveId = battle.player.curMoves[1].id +battle.player.curMoves[1].id = "FIX_CUT" +battle.player.curMoves[1].mimic = true +battle.mimicRestores = { + { battler = battle.player, entry = battle.player.curMoves[1], id = originalMoveId }, +} battle.enemy.mon.hp = battle.enemy.mon.hp - 4 battle.enemy.shownHP = battle.enemy.mon.hp battle.enemy.stages.defense = -1 +battle.enemy.aiLayer2 = 1 +battle.enemy.thrashMove = battle.enemy.curMoves[1] +battle.enemy.thrashTurns = 2 battle.participants = { [game.save.party[1]] = true, [game.save.party[2]] = true } battle.leveledUp = { [game.save.party[2]] = true } @@ -89,6 +98,8 @@ if snapshot and snapshot.kind == "battle" then { kind = "wild_encounter", map = "FIX_TOWN" }, "semantic continuation origin is data-only") T.eq(snapshot.runtime.battle.turnCount, 7, "turn count is captured") + T.eq(snapshot.runtime.battle.rulesetId, "gen1_faithful", + "battle mechanics ruleset identity is captured") T.eq(snapshot.runtime.battle.runAttempts, 2, "escape attempts are captured") T.eq(snapshot.runtime.battle.player.stages.attack, 2, "player stat stages are captured") @@ -96,8 +107,17 @@ if snapshot and snapshot.kind == "battle" then "player volatile status is captured") T.same(snapshot.runtime.battle.player.curTypes, { "FIRE", "FLYING" }, "transformed battle types are captured") + T.same(snapshot.runtime.battle.mimicRestores, + { { side = "player", slot = 1, id = originalMoveId } }, + "Mimic restore pointers normalize to side and move slot") T.eq(snapshot.runtime.battle.enemy.stages.defense, -1, "enemy stat stages are captured") + T.eq(snapshot.runtime.battle.enemy.aiLayer2, 1, + "enemy AI selection layer is captured") + T.eq(snapshot.runtime.battle.enemy.thrashMoveSlot, 1, + "move-instance references normalize to move slots") + T.eq(snapshot.runtime.battle.enemy.thrashMove, nil, + "live move-instance references are not serialized as detached copies") T.same(snapshot.runtime.battle.participants, { 1, 2 }, "Pokemon-keyed participants normalize to party indices") T.same(snapshot.runtime.battle.leveledUp, { 2 }, diff --git a/tests/engine/battle_checkpoint_restore.lua b/tests/engine/battle_checkpoint_restore.lua index 95f23c80..d3aa32dd 100644 --- a/tests/engine/battle_checkpoint_restore.lua +++ b/tests/engine/battle_checkpoint_restore.lua @@ -8,11 +8,15 @@ local T = require("tests.harness").suite("battle checkpoint restore") local Fixtures = require("tests.modkit").fixtures local BattleState = require("src.battle.BattleState") local Checkpoint = require("src.core.Checkpoint") +local Damage = require("src.battle.Damage") +local Encounter = require("src.world.Encounter") local GameMethods = require("src.core.Game") local Music = require("src.core.Music") local Pokemon = require("src.pokemon.Pokemon") local SaveData = require("src.core.SaveData") +local SaveSerializer = require("src.core.SaveSerializer") local StateStack = require("src.core.StateStack") +local TrainerAI = require("src.battle.TrainerAI") local Data = Fixtures.fresh() local oldRandom = love.math.random @@ -111,15 +115,51 @@ local game, originalBattle = makeGame("wild") originalBattle.turnCount = 4 originalBattle.runAttempts = 1 originalBattle.player.stages.speed = 3 +local originalMoveId = originalBattle.player.curMoves[1].id +originalBattle.player.curMoves[1].id = "FIX_CUT" +originalBattle.player.curMoves[1].mimic = true +originalBattle.mimicRestores = { + { battler = originalBattle.player, entry = originalBattle.player.curMoves[1], + id = originalMoveId }, +} originalBattle.enemy.mon.hp = originalBattle.enemy.mon.hp - 5 originalBattle.enemy.shownHP = originalBattle.enemy.mon.hp originalBattle.enemy.disabledSlot = 1 originalBattle.enemy.disabledTurns = 2 +originalBattle.enemy.aiLayer2 = 1 +originalBattle.enemy.thrashTurns = 2 +originalBattle.enemy.mon.moves = { + { id = "FIX_SCRATCH", pp = 35 }, { id = "FIX_CUT", pp = 30 }, +} +originalBattle.enemy.curMoves = originalBattle.enemy.mon.moves +originalBattle.enemy.thrashMove = originalBattle.enemy.curMoves[1] originalBattle.participants = { [game.save.party[1]] = true } local checkpoint = assert(Checkpoint.capture(game)) -local expectedNext = love.math.random(1, 1000000) +local encounterDef = { grass = { + rate = 256, buckets = { 128, 256 }, + slots = { { species = "FIXMON_A", level = 4 }, + { species = "FIXMON_B", level = 7 } }, +} } +Encounter.load(Data) +local function randomOutcomes(battle) + local damage, detail = Damage.compute(battle.ruleset, battle.player, + battle.enemy, Data.moves.FIX_CUT, { rng = battle.rng }) + local hit = Damage.accuracyRoll(battle.ruleset, Data.moves.FIX_CUT, + battle.player, battle.enemy, battle.rng) + local ai = TrainerAI.chooseMove(battle.enemy, battle.rng, nil) + local escaped = battle:runRollVanilla(1, 100) + local encounter = Encounter.roll(encounterDef, love.math.random) + local nextValue = love.math.random(1, 1000000) + return { + damage = damage, critical = detail.crit, hit = hit, + ai = ai.id, escaped = escaped, encounter = encounter, + nextValue = nextValue, + } +end +local expectedOutcomes = randomOutcomes(originalBattle) settleOverworld(game) +game.save.options.ruleset = "modern_clean" rng = 777 local restored, code, message = Checkpoint.restore(game, checkpoint) T.check(restored == true, "battle checkpoint restores: " .. tostring(message or code)) @@ -132,17 +172,40 @@ if restored then T.eq(rebuilt.turnCount, 4, "turn count roundtrips") T.eq(rebuilt.runAttempts, 1, "escape state roundtrips") T.eq(rebuilt.player.stages.speed, 3, "player stages roundtrip") + T.check(rebuilt.mimicRestores and rebuilt.mimicRestores[1] + and rebuilt.mimicRestores[1].battler == rebuilt.player + and rebuilt.mimicRestores[1].entry == rebuilt.player.curMoves[1], + "Mimic restore references are rebuilt against the new battler") + rebuilt:restoreMimicked(rebuilt.player) + T.eq(rebuilt.player.curMoves[1].id, originalMoveId, + "restored Mimic move returns to its canonical id when battle copy leaves") + T.eq(rebuilt.player.curMoves[1].mimic, nil, + "restored Mimic marker clears with the battle copy") + -- Put the checkpointed battle state back before differential recapture. + rebuilt.player.curMoves[1].id = "FIX_CUT" + rebuilt.player.curMoves[1].mimic = true + rebuilt.mimicRestores = { + { battler = rebuilt.player, entry = rebuilt.player.curMoves[1], id = originalMoveId }, + } T.eq(rebuilt.enemy.disabledSlot, 1, "enemy volatile state roundtrips") T.eq(rebuilt.enemy.disabledTurns, 2, "enemy volatile duration roundtrips") + T.eq(rebuilt.enemy.aiLayer2, 1, "enemy AI selection layer roundtrips") + T.check(rebuilt.enemy.thrashMove == rebuilt.enemy.curMoves[1], + "multi-turn move references rebuild against the new move list") T.eq(rebuilt.enemy.mon.hp, checkpoint.runtime.battle.enemyMon.hp, "enemy Pokemon model roundtrips") T.eq(game.save.money, checkpoint.save.money, "persistent progress roundtrips") T.eq(game.save.party[1].hp, checkpoint.save.party[1].hp, "party model roundtrips") + T.eq(game.save.options.ruleset, "modern_clean", + "current global ruleset option remains untouched") + T.check(rebuilt.ruleset == require("src.battle.rulesets.gen1_faithful"), + "restored battle keeps the mechanics ruleset it was captured with") T.same(Checkpoint.capture(game), checkpoint, "capture A, discard, restore A, capture A2 yields normalized A == A2") - T.eq(love.math.random(1, 1000000), expectedNext, - "the exact next gameplay RNG result repeats after reload") + local replayed = randomOutcomes(rebuilt) + T.same(replayed, expectedOutcomes, + "damage, critical, accuracy, AI, escape, encounter and next RNG replay exactly") rebuilt.onFinish("run") T.same(game.overworld.lastRestoredFinish, { result = "run", origin = "wild_encounter" }, @@ -171,6 +234,46 @@ if restored then "trainer differential recapture is exact") end +local function clone(value) + return assert(SaveSerializer.decode(SaveSerializer.encode(value))) +end + +local beforeRejected = assert(Checkpoint.capture(trainerGame)) +local missingSpecies = clone(trainerCheckpoint) +missingSpecies.runtime.battle.enemyParty[1].species = "MISSING_SPECIES" +restored, code = Checkpoint.restore(trainerGame, missingSpecies) +T.check(restored == false and code == "invalid_content", + "unknown battle content is rejected before mutation") +T.same(Checkpoint.capture(trainerGame), beforeRejected, + "rejected battle content leaves runtime and RNG unchanged") + +local badOrigin = clone(trainerCheckpoint) +badOrigin.runtime.battle.origin.npcId = nil +restored, code = Checkpoint.restore(trainerGame, badOrigin) +T.check(restored == false and code == "battle_origin_unsupported", + "incomplete semantic continuation is rejected before mutation") +T.same(Checkpoint.capture(trainerGame), beforeRejected, + "rejected continuation leaves runtime and RNG unchanged") + +-- Fail after the new battle has been installed, when its RNG is applied. +-- The transaction must reconstruct the prior battle and restore its RNG. +local workingSetRandomState = love.math.setRandomState +local setCalls = 0 +love.math.setRandomState = function(state) + setCalls = setCalls + 1 + if setCalls == 1 then error("injected RNG restore failure") end + return workingSetRandomState(state) +end +local beforeFailure = assert(Checkpoint.capture(trainerGame)) +local rngBeforeFailure = rng +restored, code = Checkpoint.restore(trainerGame, trainerCheckpoint) +T.check(restored == false and code == "restore_failed", + "post-install RNG failure is returned as a structured restore failure") +T.eq(rng, rngBeforeFailure, "failed battle restore rolls RNG back exactly") +T.same(Checkpoint.capture(trainerGame), beforeFailure, + "failed battle restore rolls the complete runtime back exactly") +love.math.setRandomState = workingSetRandomState + love.math.random = oldRandom love.math.getRandomState, love.math.setRandomState = oldGet, oldSet Music.playBattle = oldPlayBattle From 5bffc89a2f3397db5b5baf3c1b114cdefd0e9d9d Mon Sep 17 00:00:00 2001 From: MaxTomahawk Date: Fri, 7 Aug 2026 16:41:13 +0200 Subject: [PATCH 40/63] docs: specify battle checkpoint extension --- docs/modding.md | 21 +-- docs/rfcs/0004-runtime-checkpoints.md | 9 +- docs/rfcs/0005-battle-runtime-checkpoints.md | 133 +++++++++++++++++++ 3 files changed, 150 insertions(+), 13 deletions(-) create mode 100644 docs/rfcs/0005-battle-runtime-checkpoints.md diff --git a/docs/modding.md b/docs/modding.md index 0b9422d3..8bbb7dde 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -181,17 +181,20 @@ end local ok, code, message = mod.checkpoints:restore(game, checkpoint) ``` -Checkpoint format 1 supports settled overworld control only: the overworld must -be topmost, the player stationary on a tile, and no transition, menu, script, -queued script movement, or partial field animation may be active. Refusals carry -a stable `reason` and readable `message`. Capture excludes global options and -runtime objects. Restore validates format, game/playthrough identity, content, -and coordinates before mutation; preserves current options; suppresses normal -map-entry/save-load side effects; verifies a recapture; and rolls back in memory -if reconstruction fails. Callers that need crash recovery should durably capture +Checkpoint format 1 supports settled overworld control and proven battle +player-decision safe points. Battle checkpoints are limited to ordinary +single-player wild/trainer origins with no suspended script; link, Safari, +ghost, demo, scripted, animation, message, queue, and forced-action phases fail +closed. New checkpoints preserve gameplay RNG, while legacy overworld records +without RNG remain loadable. Capture excludes global options and runtime +objects. Restore validates format, game/playthrough identity, content, +coordinates, battle relationships, continuation, and RNG before mutation; +preserves current options; suppresses normal map-entry/save-load/intro side +effects; verifies a recapture; and rolls back runtime plus RNG in memory if +reconstruction fails. Callers that need crash recovery should durably capture their own recovery checkpoint before restore. -See RFC 0003 and RFC 0004 for exact contracts and error codes. +See RFC 0003, RFC 0004, and RFC 0005 for exact contracts and error codes. ## Developer console diff --git a/docs/rfcs/0004-runtime-checkpoints.md b/docs/rfcs/0004-runtime-checkpoints.md index 0df1c421..54e928de 100644 --- a/docs/rfcs/0004-runtime-checkpoints.md +++ b/docs/rfcs/0004-runtime-checkpoints.md @@ -104,10 +104,11 @@ runtime exception, not process termination. ## Runtime boundary and future kinds -Format 1 intentionally rejects battles, menus, transitions, animations, and -suspended/queued scripts. Future battle or explicit script-checkpoint kinds must -have separate inventories, validation, reconstruction, deterministic RNG, and -differential tests; they are not implied by this RFC. +This RFC's original Level A contract intentionally rejects battles, menus, +transitions, animations, and suspended/queued scripts. RFC 0005 subsequently +adds a separately inventoried `battle` kind with deterministic RNG and +differential reconstruction tests; it does not broaden script or arbitrary-frame +support implied here. ## Migration note for existing mods diff --git a/docs/rfcs/0005-battle-runtime-checkpoints.md b/docs/rfcs/0005-battle-runtime-checkpoints.md new file mode 100644 index 00000000..6735f5d2 --- /dev/null +++ b/docs/rfcs/0005-battle-runtime-checkpoints.md @@ -0,0 +1,133 @@ +# RFC 0005 — Persistent battle safe-point checkpoints + +## Status + +Proposed. Extends RFC 0004. Engine: `BattleCheckpoint.lua`, `Checkpoint.lua`, +`Game.lua`, `BattleState.lua`, and `OverworldController.lua`. Tests: +`battle_checkpoint_*.lua`, `checkpoints.lua`, and the existing no-mod suites. + +## Motivation + +RFC 0004 lets a tool capture and reconstruct settled overworld progress without +private engine access. A battle is a different runtime: its queue can hold Lua +functions and UI factories, its controller contains renderer objects and live +references, completion is currently an `onFinish` closure, and scripted battles +resume a suspended `ScriptRunner` coroutine. Copying the controller would create +a record that is neither data-only nor process-independent. + +The engine can instead expose a narrow semantic safe point. This gives all mods +the strongest persistent battle checkpoint the current architecture can prove, +without claiming mid-animation or suspended-script support. + +## API delta + +No new facade is added. The existing additive `mod.checkpoints` API gains a +second format-1 runtime kind. + +### Capability + +`mod.checkpoints:inspect(game)` returns this only when an ordinary single-player +wild or trainer battle is settled at the player command menu: + +```lua +{ canCapture = true, canRestore = true, kind = "battle" } +``` + +The action/message queue, waits, UI, animations, HP/status presentation, and +faint processing must be settled. The player must actually control the menu. +The underlying overworld must have no running/queued script or scripted move, +and the battle must carry an engine-owned semantic continuation descriptor. + +Additional refusal codes are `battle_phase_busy`, `battle_origin_unsupported`, +`battle_variant_unsupported`, and `link_battle_unsupported`. Link, Safari, +ghost, old-man/demo, fishing, static-object, script-suspended, and mod-created +closure continuations remain rejected. + +### Capture + +A battle checkpoint remains detached and data-only: + +```lua +{ + format = 1, + kind = "battle", + identity = { engineVersion = "...", gameVersion = "red", + playthroughId = "..." }, + save = { -- canonical dynamic progress, excluding global options }, + runtime = { + overworld = { map = "ROUTE_1", x = 7, y = 8, + facing = "left", surfing = false }, + battle = { -- normalized semantic model and continuation }, + }, + rng = { love = "..." }, +} +``` + +The model carries player/enemy roster indices, dynamic enemy Pokémon, turn and +escape state, HP/PP/status/stages/volatiles, participants, level-up tracking, +trainer AI state, battle ruleset identity, side/field extension data, and +normalized pointer relationships such as multi-turn move slots and Mimic +restoration entries. Definitions, sprites, canvases, queues, callbacks, and +controller objects are reconstructed or excluded. + +Callback-bearing battle extension tokens fail with `battle_extension_unsafe`; +invalid live reference relationships fail with `battle_state_invalid`. Nothing +is silently stripped. + +New overworld checkpoints also carry the LÖVE gameplay RNG state. Legacy +format-1 overworld checkpoints without `rng` remain loadable and leave the +current stream untouched. + +### Restore + +Battle restore validates the detached save, map, content references, ruleset, +roster indices, move references, continuation identity, and RNG before live +mutation. The engine then: + +1. reconstructs the saved overworld return point without entry side effects; +2. creates a fresh `BattleState` from current content registries; +3. applies the normalized battle model and rebuilds object-reference relations; +4. binds an engine-owned wild/trainer completion continuation; +5. installs the battle directly at the settled menu without replaying its intro; +6. restores the RNG after reconstruction has finished; and +7. recaptures and compares the complete checkpoint. + +The pre-operation checkpoint is the transaction rollback. A failed post-install +RNG restore is covered: both battle runtime and RNG are reconstructed back to +their original values. + +## Continuation decision + +Ordinary random wild battles resume through `OverworldState:afterBattle`. +Ordinary trainer battles use a descriptor containing map id, stable NPC id, +trainer class/party, and optional header event; a win reapplies the same defeated +flag, event, reward, and `afterBattle` path. Reconstructed overworld input and +NPC freeze state are normalized instead of reviving the old closure. + +`Commands.start_battle` is deliberately unsupported: its completion closure +mutates script context and resumes a coroutine whose program counter and Lua +stack cannot be serialized. Existing script rejection remains the correct safe +contract until a separate semantic ScriptRunner checkpoint RFC exists. + +## Migration note + +**Existing mods require no changes.** The facade and format number are unchanged; +the new kind and RNG field are additive. Overworld-only callers may continue to +filter `capability.kind`. No-mod behavior is unchanged when checkpoints are +unused. + +## Verification + +- settled/unsafe boundary and every variant refusal; +- data-only wild and trainer capture, including callback-bearing extension + rejection; +- process-independent controller and continuation reconstruction; +- exact differential recapture for wild and trainer states; +- HP, PP, status/stages/volatiles, AI layer, participants, enemy roster, + multi-turn move references, and Mimic restore pointers; +- exact damage, critical, accuracy, random AI, escape, next encounter, and next + raw RNG result after reload; +- corrupt content/continuation rejection before mutation; +- injected post-install failure with full runtime and RNG rollback; +- legacy overworld checkpoint compatibility; +- complete ROM-free engine and public mod-API suites. From 5b1d0261ffb3009c026f3accc15b74837ff3e1e0 Mon Sep 17 00:00:00 2001 From: MaxTomahawk Date: Fri, 7 Aug 2026 16:56:38 +0200 Subject: [PATCH 41/63] test: prove public battle checkpoint roundtrip --- tests/modkit/cases/checkpoints.lua | 86 ++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/tests/modkit/cases/checkpoints.lua b/tests/modkit/cases/checkpoints.lua index 4c26ef87..4eba8dbc 100644 --- a/tests/modkit/cases/checkpoints.lua +++ b/tests/modkit/cases/checkpoints.lua @@ -14,6 +14,10 @@ local T = require("tests.harness").suite("mod checkpoints") local Loader = require("src.mods.Loader") local Runtime = require("src.mods.Runtime") local GameMethods = require("src.core.Game") +local BattleState = require("src.battle.BattleState") +local Fixtures = require("tests.modkit").fixtures +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") local StateStack = require("src.core.StateStack") local Version = require("src.core.Version") @@ -320,6 +324,88 @@ T.check(not restored and restoreCode == "restore_failed", T.same(checkpoints:capture(game), beforeFailure, "failed reconstruction rolls back the complete pre-operation checkpoint") +-- The same public facade must carry a real battle checkpoint end to end. The +-- engine-side fixture is deliberately constructed outside the probe mod; the +-- mod sees and calls only mod.checkpoints. +local function makeBattleGame() + local data = Fixtures.fresh() + local save = SaveData.newGame() + save.meta.playthroughId = "public-battle-playthrough" + save.party = { Pokemon.new(data, "FIXMON_A", 20) } + -- The tiny fixture registry intentionally omits several full-game defaults. + -- Normalize those once, then place the save on its fixture map. + SaveData.validate(save, data) + save.player.map, save.player.x, save.player.y = "FIX_TOWN", 2, 3 + save.player.facing, save.player.surfing = "left", false + local stack = setmetatable({ states = {} }, { __index = StateStack }) + local battleGame + local battleOw = { + map = { id = "FIX_TOWN" }, + player = { cellX = 2, cellY = 3, facing = "left", surfing = false }, + runner = { isRunning = function() return false end }, + parallelRunners = {}, pendingScripts = {}, parallelQueue = {}, scriptMoves = {}, + } + function battleOw:captureSave(target) + target.player.map = self.map.id + target.player.x, target.player.y = self.player.cellX, self.player.cellY + target.player.facing = self.player.facing + target.player.surfing = self.player.surfing and true or false + end + function battleOw:enter(mapId, x, y, facing) + self.map = { id = mapId } + self.player = { cellX = x, cellY = y, facing = facing, surfing = false } + end + function battleOw:restoreBattleContinuation(restoredBattle, origin) + if origin.kind ~= "wild_encounter" or origin.map ~= self.map.id then + return false + end + restoredBattle.onFinish = function() end + return true + end + battleGame = setmetatable({ + data = data, save = save, stack = stack, overworld = battleOw, + }, { __index = GameMethods }) + stack.states[1] = battleOw + local battle = BattleState.newWild(battleGame, "FIXMON_B", 12) + battle.phase, battle.queue = "menu", {} + battle.checkpointOrigin = { kind = "wild_encounter", map = "FIX_TOWN" } + battle.musicKind = battle:computeMusicKind() + battle.onFinish = function() end + stack.states[2] = battle + return battleGame, battle +end + +checkpointRngState = "public-battle-rng-A" +local battleGame, liveBattle = makeBattleGame() +T.same(checkpoints:inspect(battleGame), { + canCapture = true, canRestore = true, kind = "battle", +}, "public mod.checkpoints reports a settled battle boundary") +liveBattle.turnCount = 4 +liveBattle.player.stages.attack = 2 +local battleSnapshot, battleCaptureCode = checkpoints:capture(battleGame) +T.check(battleSnapshot and battleSnapshot.kind == "battle", + "public mod.checkpoints captures a data-only battle: " + .. tostring(battleCaptureCode)) +if battleSnapshot then + battleGame.save.money = 1 + liveBattle.turnCount = 99 + checkpointRngState = "public-battle-rng-B" + local battleRestored, battleRestoreCode, battleRestoreMessage = checkpoints:restore( + battleGame, battleSnapshot) + T.check(battleRestored == true, + "public mod.checkpoints reconstructs a battle: " + .. tostring(battleRestoreCode) .. " / " .. tostring(battleRestoreMessage)) + local restoredBattle = battleGame.stack:top() + T.eq(restoredBattle.turnCount, 4, + "public battle reconstruction restores the exact turn") + T.eq(restoredBattle.player.stages.attack, 2, + "public battle reconstruction restores battler stages") + T.eq(checkpointRngState, "public-battle-rng-A", + "public battle reconstruction restores gameplay RNG") + T.same(checkpoints:capture(battleGame), battleSnapshot, + "public battle capture/restore/capture is a normalized differential roundtrip") +end + Runtime.events, Runtime.hooks = savedEvents, savedHooks Runtime.currentMod = nil _G.MOD_CHECKPOINTS = nil From c979685a1f2557b49ce5ca1d4903a5209d4bdf8a Mon Sep 17 00:00:00 2001 From: MaxTomahawk Date: Fri, 7 Aug 2026 17:06:38 +0200 Subject: [PATCH 42/63] test: cover switched battle party fidelity --- tests/engine/battle_checkpoint_restore.lua | 35 ++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/tests/engine/battle_checkpoint_restore.lua b/tests/engine/battle_checkpoint_restore.lua index d3aa32dd..1ea08ea1 100644 --- a/tests/engine/battle_checkpoint_restore.lua +++ b/tests/engine/battle_checkpoint_restore.lua @@ -212,6 +212,41 @@ if restored then "restored battle receives a reconstructed semantic continuation") end +local partyGame, switchedOriginal = makeGame("wild") +partyGame.save.party[1].hp = 0 +local activeMon = partyGame.save.party[2] +activeMon.status = "PAR" +activeMon.moves[1].pp = activeMon.moves[1].pp - 4 +switchedOriginal.player = BattleState.makeBattler( + Data, activeMon, true, partyGame.save) +switchedOriginal.sides[1].battlers = { switchedOriginal.player } +switchedOriginal.participants = { + [partyGame.save.party[1]] = true, + [partyGame.save.party[2]] = true, +} +local partyCheckpoint = assert(Checkpoint.capture(partyGame)) +settleOverworld(partyGame) +partyGame.save.party[2].status = nil +partyGame.save.party[2].moves[1].pp = 1 +restored, code, message = Checkpoint.restore(partyGame, partyCheckpoint) +T.check(restored == true, + "switched/status/PP checkpoint restores: " .. tostring(message or code)) +local partyRebuilt = partyGame.stack:top() +if restored then + T.eq(partyGame.save.party[1].hp, 0, + "fainted non-active party member roundtrips") + T.check(partyRebuilt.player.mon == partyGame.save.party[2], + "switched active Pokemon reconstructs against restored party identity") + T.eq(partyRebuilt.player.mon.status, "PAR", "active status roundtrips") + T.eq(partyRebuilt.player.mon.moves[1].pp, + partyCheckpoint.save.party[2].moves[1].pp, "reduced PP roundtrips") + T.check(partyRebuilt.participants[partyGame.save.party[1]] == true + and partyRebuilt.participants[partyGame.save.party[2]] == true, + "participant references rebuild against fainted and active party members") + T.same(Checkpoint.capture(partyGame), partyCheckpoint, + "switch, faint, status and PP differential recapture is exact") +end + local trainerGame, trainerOriginal = makeGame("trainer") trainerOriginal.turnCount = 6 trainerOriginal.enemy.mon.hp = trainerOriginal.enemy.mon.hp - 3 From 12b6ef6350984436f214d76e5407f1baba904ba1 Mon Sep 17 00:00:00 2001 From: MaxTomahawk Date: Fri, 7 Aug 2026 17:07:48 +0200 Subject: [PATCH 43/63] test: prove complete overworld progress fidelity --- tests/modkit/cases/checkpoints.lua | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/tests/modkit/cases/checkpoints.lua b/tests/modkit/cases/checkpoints.lua index 4eba8dbc..adea5f90 100644 --- a/tests/modkit/cases/checkpoints.lua +++ b/tests/modkit/cases/checkpoints.lua @@ -71,7 +71,14 @@ local function baseSave() moves = { "TACKLE" } } }, flags = { GOT_STARTER = true }, inventory = { POTION = 1 }, - pcItems = {}, box = {}, boxes = {}, defeatedTrainers = {}, + pcItems = { POTION = 2 }, + box = { { species = "BULBASAUR", level = 4, hp = 16, + moves = { "TACKLE" } } }, + boxes = { [2] = { { species = "BULBASAUR", level = 3, hp = 14, + moves = { "TACKLE" } } } }, + defeatedTrainers = { PALLET_RIVAL = true }, + objectToggles = { PALLET_TOWN = { OAK = false } }, + itemsTaken = { PALLET_TOWN_POTION = true }, pokedex = { seen = { BULBASAUR = true }, owned = { BULBASAUR = true } }, modData = {}, options = { volume = 4, bindings = {} }, @@ -253,6 +260,15 @@ local original = snapshot game.save.money = 999999 game.save.flags.GOT_STARTER = nil game.save.party[1].hp = 1 +game.save.inventory.POTION = 99 +game.save.pcItems.POTION = nil +game.save.box = {} +game.save.boxes = {} +game.save.defeatedTrainers.PALLET_RIVAL = nil +game.save.objectToggles.PALLET_TOWN.OAK = true +game.save.itemsTaken.PALLET_TOWN_POTION = nil +game.save.pokedex.seen.BULBASAUR = nil +game.save.pokedex.owned.BULBASAUR = nil game.save.options.volume = 9 checkpointRngState = "overworld-rng-B" ow.map.id, ow.player.cellX, ow.player.cellY = "PALLET_TOWN", 2, 3 @@ -268,6 +284,17 @@ T.eq(game.save.options.volume, 9, "checkpoint restoration preserves current global settings") T.eq(checkpointRngState, "overworld-rng-A", "overworld checkpoint restores gameplay RNG") +T.eq(game.save.inventory.POTION, 1, "inventory progress roundtrips") +T.eq(game.save.pcItems.POTION, 2, "PC item progress roundtrips") +T.eq(game.save.box[1].hp, 16, "current box Pokemon roundtrips") +T.eq(game.save.boxes[2][1].hp, 14, "stored box collection roundtrips") +T.eq(game.save.defeatedTrainers.PALLET_RIVAL, true, + "defeated trainer progress roundtrips") +T.eq(game.save.objectToggles.PALLET_TOWN.OAK, false, + "map object toggle progress roundtrips") +T.eq(game.save.itemsTaken.PALLET_TOWN_POTION, true, + "taken-object progress roundtrips") +T.eq(game.save.pokedex.owned.BULBASAUR, true, "Pokedex progress roundtrips") T.check(game.lastEnterOpts and game.lastEnterOpts.checkpoint == true, "engine reconstruction is marked to suppress map-entry side effects") From 9fc6ba5b01922fe6d6332cfc49627bea05793b3b Mon Sep 17 00:00:00 2001 From: Jakub Lisicki Date: Fri, 7 Aug 2026 22:57:33 +0200 Subject: [PATCH 44/63] Add mod.world:startWildBattle Starting a wild encounter had no supported entry point, so mods built a BattleState and pushed it themselves -- silently losing onFinish (and with it evolutions and blackout-on-loss) and pushBattle (entry wipe, battle theme). Neither failure raises. Also covers awardExp -> leveledUp -> afterBattle -> checkParty, which parity_trainer_evolution_order stubs BattleState out of. --- src/world/WorldAPI.lua | 22 +++++ tests/parity_world_start_wild_battle.lua | 112 +++++++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 tests/parity_world_start_wild_battle.lua diff --git a/src/world/WorldAPI.lua b/src/world/WorldAPI.lua index 6adadc51..21fe9d87 100644 --- a/src/world/WorldAPI.lua +++ b/src/world/WorldAPI.lua @@ -164,6 +164,28 @@ function WorldAPI:queueScript(rows, extra) return true end +-- The supported way to start a wild encounter. Hand-rolling this -- build a +-- BattleState, push it -- silently costs evolutions and blackout-on-loss +-- (both hang off onFinish -> afterBattle) plus the entry wipe and battle +-- theme (both owned by pushBattle). Nothing raises when they are missing. +function WorldAPI:startWildBattle(species, level) + local ow = self:overworld() + if not ow then return nil, NO_OVERWORLD end + if not self.game.data.pokemon[species] then + return nil, "unknown species: " .. tostring(species) + end + level = tonumber(level) + if not level or level < 1 or level > 100 then + return nil, "level must be 1..100" + end + local battle = require("src.battle.BattleState") + .newWild(self.game, species, level) + if battle.dead then return nil, "no healthy party" end + battle.onFinish = function(result) ow:afterBattle(result, battle) end + ow:pushBattle(battle) + return true +end + -- drop a map's cached instance so the next load re-reads its record; when -- it is the active map the world reloads around the player in place function WorldAPI:invalidateMap(mapId) diff --git a/tests/parity_world_start_wild_battle.lua b/tests/parity_world_start_wild_battle.lua new file mode 100644 index 00000000..15ad79ed --- /dev/null +++ b/tests/parity_world_start_wild_battle.lua @@ -0,0 +1,112 @@ +-- mod.world:startWildBattle. What regresses is the handoff, not the battle: +-- a mod that pushes its own BattleState still fights and still levels, it just +-- loses onFinish -> afterBattle (evolutions, blackout-on-loss) and pushBattle +-- (entry wipe, battle theme), silently. Shipped mods have hit exactly this. +-- +-- T3, not the ROM-free tier: the handoff only exists once a real map is up +-- (OverworldState:enter binds the module-local Game and MapLoader needs real +-- tilesets), and the fixture dataset carries neither. + +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local Data = require("src.core.Data") +if not Data.maps then Data:load() end +local S = require("tests.harness").suite("parity world startWildBattle") +local check = S.check + +local Game = require("src.core.Game") +local SaveData = require("src.core.SaveData") +local StateStack = require("src.core.StateStack") +local Pokemon = require("src.pokemon.Pokemon") +local WorldAPI = require("src.world.WorldAPI") +local OverworldState = require("src.world.OverworldController") +require("src.render.Font").load(Data) + +-- data/generated/audio.lua only exists after an in-app ROM import; the Python +-- developer builder has no audio stage, so Music's entry points can be absent. +-- setMap plays map music on the way in, which is incidental to the handoff +-- under test -- no-op whatever is missing rather than depend on the dataset. +-- run_tests.lua dofiles every suite into one process, so this is restored at +-- the end: leaving it patched makes later suites pass that otherwise fail. +local Music = require("src.core.Music") +local musicSaved = {} +for _, fn in ipairs({ "playMap", "setSurfing", "playBattle" }) do + -- `or false` so a genuinely-absent entry point is still recorded and gets + -- restored to nil; storing the nil directly would drop the key entirely + musicSaved[fn] = Music[fn] or false + Music[fn] = Music[fn] or function() end +end +local function restoreMusic() + for fn, orig in pairs(musicSaved) do Music[fn] = orig or nil end +end + +local function freshWorld() + Game.data = Data + Game.save = SaveData.newGame() + Game.stack = StateStack; StateStack:init() + Game.renderer = { worldViewSize = function() return 160, 144 end } + Game.overworld = OverworldState + OverworldState:enter("ROUTE_1", 5, 5, "down") + return WorldAPI.new(Game, "testmod") +end + +-- ------- argument handling: every failure is a nil + reason, never a throw + +local world = WorldAPI.new({ data = Data }, "testmod") +local ok, err = world:startWildBattle("PIDGEY", 5) +check(ok == nil, "no overworld up refuses") +check(err == "no overworld", "and says so") + +world = freshWorld() +Game.save.party = { Pokemon.new(Data, "CATERPIE", 6) } + +ok, err = world:startWildBattle("NOT_A_MON", 5) +check(ok == nil, "an unknown species refuses") +check(err and err:find("unknown species", 1, true), "and names the species") + +for _, lv in ipairs({ 0, 101, "nope" }) do + check(world:startWildBattle("PIDGEY", lv) == nil, + "level " .. tostring(lv) .. " refuses") +end + +-- ------- the handoff: a level-up evolution is offered after the win + +world = freshWorld() +local caterpie = Pokemon.new(Data, "CATERPIE", 6) +Game.save.party = { caterpie } + +check(world:startWildBattle("PIDGEY", 25) == true, "a wild battle starts") + +-- pushBattle pushes the transition, which pushes the battle from its callback +local top = Game.stack:top() +check(top ~= nil, "something was pushed") +check(top.screenId ~= "BattleState", "the entry transition goes on first") + +local battle +for _ = 1, 400 do + local t = Game.stack:top() + if t and t.awardExp then battle = t break end + if t and t.update then t:update(1 / 60) else break end +end +check(battle ~= nil, "the transition hands off to the battle") + +battle.participants = { [caterpie] = true } +battle:awardExp() +check(caterpie.level >= 7, "the mon levels past its evolution threshold") +check(battle.leveledUp and battle.leveledUp[caterpie], + "awardExp records the level-up for EvolveAfterBattle") + +Game.stack:pop() +battle.onFinish("win") +for _ = 1, 12 do + local t = Game.stack:top() + if not t or t.screenId == "EvolutionState" then break end + Game.stack:pop() + if t.onDone then t.onDone() end +end +check(Game.stack:top() and Game.stack:top().screenId == "EvolutionState", + "the win reaches the evolution screen") + +restoreMusic() +S.finish() From 371edd1a02607281e6caf4776018141af2795ff2 Mon Sep 17 00:00:00 2001 From: AverageConsumer <35539970+AverageConsumer@users.noreply.github.com> Date: Sat, 8 Aug 2026 11:59:57 +0200 Subject: [PATCH 45/63] feat(mods): expose map tile shading --- src/world/WorldAPI.lua | 49 ++++++++++++++++++++++-- tests/engine/world_map_overview_test.lua | 16 ++++++++ 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/src/world/WorldAPI.lua b/src/world/WorldAPI.lua index 66aa493c..dadf91d6 100644 --- a/src/world/WorldAPI.lua +++ b/src/world/WorldAPI.lua @@ -6,6 +6,7 @@ -- stays unsupported; anything a mod legitimately needs belongs here. local Logger = require("src.core.Logger") +local Assets = require("src.render.Assets") local MapLoader = require("src.world.MapLoader") local Runtime = require("src.mods.Runtime") @@ -13,6 +14,45 @@ local WorldAPI = {} WorldAPI.__index = WorldAPI local NO_OVERWORLD = "no overworld" +local overviewShades = {} + +Assets.register(function() overviewShades = {} end) + +local function mapTileRows(map) + local tileset = map.tileset + if not (tileset and tileset.image and tileset.tilesPerRow) then return nil end + local cached = overviewShades[tileset.image] + if not cached then + local ok, pixels = pcall(Assets.imageData, tileset.image) + if not ok then return nil end + cached = { pixels = pixels, shades = {} } + overviewShades[tileset.image] = cached + end + local rows, perRow = {}, tileset.tilesPerRow + for ty = 0, map.heightCells * 2 - 1 do + local row = {} + for tx = 0, map.widthCells * 2 - 1 do + local tile = map:tileAt(tx, ty) + local shade = cached.shades[tile] + if shade == nil then + local sum = 0 + local ox, oy = (tile % perRow) * 8, math.floor(tile / perRow) * 8 + for py = 0, 7 do + for px = 0, 7 do + local r, g, b = cached.pixels:getPixel(ox + px, oy + py) + sum = sum + r * 0.2126 + g * 0.7152 + b * 0.0722 + end + end + shade = tostring(math.max(0, math.min(3, + math.floor((1 - sum / 64) * 3 + 0.5)))) + cached.shades[tile] = shade + end + row[#row + 1] = shade + end + rows[#rows + 1] = table.concat(row) + end + return rows +end function WorldAPI.new(game, modId) return setmetatable({ game = game, modId = modId }, WorldAPI) @@ -44,8 +84,8 @@ function WorldAPI:current() end -- A compact, read-only view of the active map for minimaps and companion UIs. --- Rows use " " for blocked terrain, "." for walkable land, "~" for water --- and "+" for a door or warp. +-- `rows` describes collision terrain; optional `tileRows` reduces each real +-- 8x8 map tile to its average Game Boy shade ("0" lightest, "3" darkest). function WorldAPI:mapOverview() local ow = self:overworld() if not ow or not ow.map then return nil, NO_OVERWORLD end @@ -59,8 +99,11 @@ function WorldAPI:mapOverview() end rows[#rows + 1] = table.concat(row) end + local tileRows = mapTileRows(map) return { mapId = map.id, width = map.widthCells, - height = map.heightCells, rows = rows } + height = map.heightCells, rows = rows, tileRows = tileRows, + tileWidth = tileRows and map.widthCells * 2, + tileHeight = tileRows and map.heightCells * 2 } end -- opts.arrive = "fly" | "teleport" picks the arrival FX; anything else diff --git a/tests/engine/world_map_overview_test.lua b/tests/engine/world_map_overview_test.lua index 054fea68..d0a4b825 100644 --- a/tests/engine/world_map_overview_test.lua +++ b/tests/engine/world_map_overview_test.lua @@ -1,8 +1,16 @@ package.path = "./?.lua;./?/init.lua;" .. package.path local T = require("tests.harness") +local Assets = require("src.render.Assets") local WorldAPI = require("src.world.WorldAPI") +Assets.imageData = function() + return { getPixel = function(_, x) + local shade = x < 8 and 1 or 0 + return shade, shade, shade, 1 + end } +end + local api = WorldAPI.new({ stack = { states = {} } }, "tester") local overview, err = api:mapOverview() T.eq(overview, nil, "map overview is unavailable outside the overworld") @@ -12,6 +20,7 @@ local map = { id = "TEST_MAP", widthCells = 2, heightCells = 2 } function map:isWarpTileCell(x, y) return x == 1 and y == 0 end function map:isWaterCell(x, y) return x == 0 and y == 1 end function map:isWalkableCell(x, y) return x == 0 and y == 0 end +function map:tileAt(x) return x % 2 end api = WorldAPI.new({ stack = { states = { { isOverworld = true, map = map }, @@ -22,5 +31,12 @@ T.eq(overview.width, 2, "map overview reports its width") T.eq(overview.height, 2, "map overview reports its height") T.eq(overview.rows[1], ".+", "walkable land and warps are distinct") T.eq(overview.rows[2], "~ ", "water and blocked terrain are distinct") +T.eq(overview.tileRows, nil, "tile overview is optional") + +map.tileset = { image = "test.png", tilesPerRow = 2 } +overview = api:mapOverview() +T.eq(overview.tileWidth, 4, "tile overview reports its width") +T.eq(overview.tileHeight, 4, "tile overview reports its height") +T.eq(overview.tileRows[1], "0303", "tile overview preserves map shading") T.finish("world map overview") From a710c64909ef149f198640a6f72733db4161e0f1 Mon Sep 17 00:00:00 2001 From: Jakub Lisicki Date: Sat, 8 Aug 2026 12:23:05 +0200 Subject: [PATCH 46/63] Refuse re-entrant, fractional-level and no-party wild battles overworld() resolves the world from under the stack, so a call from a battle hook stacked a second battle over the live one -- on a loss its afterBattle blacked out and warped with the outer battle still up. newWild marks the species SEEN before it reports an empty party, so a refused call still wrote the Pokedex; test the party before building it. tonumber accepts 5.5, which Pokemon.new writes straight into the stat calc and the exp curve. --- src/world/WorldAPI.lua | 28 +++++- tests/parity_world_start_wild_battle.lua | 104 +++++++++++++---------- 2 files changed, 86 insertions(+), 46 deletions(-) diff --git a/src/world/WorldAPI.lua b/src/world/WorldAPI.lua index 21fe9d87..fb2488af 100644 --- a/src/world/WorldAPI.lua +++ b/src/world/WorldAPI.lua @@ -7,6 +7,7 @@ local Logger = require("src.core.Logger") local MapLoader = require("src.world.MapLoader") +local Party = require("src.pokemon.Party") local Runtime = require("src.mods.Runtime") local WorldAPI = {} @@ -174,13 +175,34 @@ function WorldAPI:startWildBattle(species, level) if not self.game.data.pokemon[species] then return nil, "unknown species: " .. tostring(species) end + -- Pokemon.new writes the level through verbatim -- into level, the stat + -- calc and the exp curve -- so a fraction has to be refused here rather + -- than round somewhere downstream. The % test also catches NaN, which + -- passes both range comparisons. level = tonumber(level) - if not level or level < 1 or level > 100 then - return nil, "level must be 1..100" + if not level or level % 1 ~= 0 or level < 1 or level > 100 then + return nil, "level must be a whole number 1..100" + end + -- overworld() resolves the world from UNDER whatever sits on top of it, + -- so from a battle hook this would otherwise stack a second battle over + -- the live one -- and on a loss its afterBattle blacks out and warps + -- with the outer battle still on the stack. + local BattleTransition = require("src.render.BattleTransition") + for _, state in ipairs(self.game.stack and self.game.stack.states or {}) do + if state.awardExp or getmetatable(state) == BattleTransition then + return nil, "a battle is already running" + end + end + if ow.transitioning then return nil, "the world is mid-warp" end + -- BattleState.newWild marks the species SEEN before it reports an empty + -- party, so the party check comes first: a refused call must not leave a + -- Pokedex entry behind. + local save = self.game.save + if not (save and Party.firstHealthy(save.party or {})) then + return nil, "no healthy party" end local battle = require("src.battle.BattleState") .newWild(self.game, species, level) - if battle.dead then return nil, "no healthy party" end battle.onFinish = function(result) ow:afterBattle(result, battle) end ow:pushBattle(battle) return true diff --git a/tests/parity_world_start_wild_battle.lua b/tests/parity_world_start_wild_battle.lua index 15ad79ed..d9ee6cec 100644 --- a/tests/parity_world_start_wild_battle.lua +++ b/tests/parity_world_start_wild_battle.lua @@ -51,62 +51,80 @@ local function freshWorld() return WorldAPI.new(Game, "testmod") end --- ------- argument handling: every failure is a nil + reason, never a throw +-- The assertions run under pcall so the Music patch is handed back even when +-- one of them throws: run_tests.lua dofiles the later suites into this same +-- process, and a Music left stubbed lets their own music checks pass. +local function body() + -- ----- argument handling: every failure is a nil + reason, never a throw -local world = WorldAPI.new({ data = Data }, "testmod") -local ok, err = world:startWildBattle("PIDGEY", 5) -check(ok == nil, "no overworld up refuses") -check(err == "no overworld", "and says so") + local world = WorldAPI.new({ data = Data }, "testmod") + local ok, err = world:startWildBattle("PIDGEY", 5) + check(ok == nil, "no overworld up refuses") + check(err == "no overworld", "and says so") -world = freshWorld() -Game.save.party = { Pokemon.new(Data, "CATERPIE", 6) } + world = freshWorld() + Game.save.party = { Pokemon.new(Data, "CATERPIE", 6) } -ok, err = world:startWildBattle("NOT_A_MON", 5) -check(ok == nil, "an unknown species refuses") -check(err and err:find("unknown species", 1, true), "and names the species") + ok, err = world:startWildBattle("NOT_A_MON", 5) + check(ok == nil, "an unknown species refuses") + check(err and err:find("unknown species", 1, true), "and names the species") -for _, lv in ipairs({ 0, 101, "nope" }) do - check(world:startWildBattle("PIDGEY", lv) == nil, - "level " .. tostring(lv) .. " refuses") -end + -- 5.5 too: Pokemon.new writes the level through into the stat calc and the + -- exp curve verbatim, so a fraction has to be refused, not rounded + for _, lv in ipairs({ 0, 101, "nope", 5.5 }) do + check(world:startWildBattle("PIDGEY", lv) == nil, + "level " .. tostring(lv) .. " refuses") + end --- ------- the handoff: a level-up evolution is offered after the win + -- ----- the handoff: a level-up evolution is offered after the win -world = freshWorld() -local caterpie = Pokemon.new(Data, "CATERPIE", 6) -Game.save.party = { caterpie } + world = freshWorld() + local caterpie = Pokemon.new(Data, "CATERPIE", 6) + Game.save.party = { caterpie } -check(world:startWildBattle("PIDGEY", 25) == true, "a wild battle starts") + check(world:startWildBattle("PIDGEY", 25) == true, "a wild battle starts") --- pushBattle pushes the transition, which pushes the battle from its callback -local top = Game.stack:top() -check(top ~= nil, "something was pushed") -check(top.screenId ~= "BattleState", "the entry transition goes on first") + -- pushBattle pushes the transition, which pushes the battle from its + -- callback. awardExp is the BattleState marker the drain loop below + -- identifies it by; screenId would NOT work here -- only Screens.push + -- stamps that, and pushBattle pushes the battle straight onto the stack, + -- so `screenId ~= "BattleState"` holds even with the transition skipped. + local top = Game.stack:top() + check(top ~= nil, "something was pushed") + check(top.awardExp == nil, "the entry transition goes on first") -local battle -for _ = 1, 400 do - local t = Game.stack:top() - if t and t.awardExp then battle = t break end - if t and t.update then t:update(1 / 60) else break end -end -check(battle ~= nil, "the transition hands off to the battle") + -- overworld() resolves the world from under the battle, so a second call + -- while one is up has to refuse rather than stack another + check(world:startWildBattle("PIDGEY", 5) == nil, + "a battle already running refuses") -battle.participants = { [caterpie] = true } -battle:awardExp() -check(caterpie.level >= 7, "the mon levels past its evolution threshold") -check(battle.leveledUp and battle.leveledUp[caterpie], - "awardExp records the level-up for EvolveAfterBattle") + local battle + for _ = 1, 400 do + local t = Game.stack:top() + if t and t.awardExp then battle = t break end + if t and t.update then t:update(1 / 60) else break end + end + check(battle ~= nil, "the transition hands off to the battle") + + battle.participants = { [caterpie] = true } + battle:awardExp() + check(caterpie.level >= 7, "the mon levels past its evolution threshold") + check(battle.leveledUp and battle.leveledUp[caterpie], + "awardExp records the level-up for EvolveAfterBattle") -Game.stack:pop() -battle.onFinish("win") -for _ = 1, 12 do - local t = Game.stack:top() - if not t or t.screenId == "EvolutionState" then break end Game.stack:pop() - if t.onDone then t.onDone() end + battle.onFinish("win") + for _ = 1, 12 do + local t = Game.stack:top() + if not t or t.screenId == "EvolutionState" then break end + Game.stack:pop() + if t.onDone then t.onDone() end + end + check(Game.stack:top() and Game.stack:top().screenId == "EvolutionState", + "the win reaches the evolution screen") end -check(Game.stack:top() and Game.stack:top().screenId == "EvolutionState", - "the win reaches the evolution screen") +local ran, runErr = pcall(body) restoreMusic() +if not ran then error(runErr, 0) end S.finish() From 492e0344b54c9a406e1040986351f8642b18f4e5 Mon Sep 17 00:00:00 2001 From: Jakub Lisicki Date: Sat, 8 Aug 2026 12:35:06 +0200 Subject: [PATCH 47/63] Fold the startWildBattle coverage into mod_world_tests mod_world_tests is already the mod.world suite and already the T3 tier, with the same off-the-world refusal pattern the standalone file was duplicating. Reuses its liveWorld fixture instead of standing up a second one. --- tests/mod_world_tests.lua | 70 ++++++++++++ tests/parity_world_start_wild_battle.lua | 130 ----------------------- 2 files changed, 70 insertions(+), 130 deletions(-) delete mode 100644 tests/parity_world_start_wild_battle.lua diff --git a/tests/mod_world_tests.lua b/tests/mod_world_tests.lua index 60aefb97..32732000 100644 --- a/tests/mod_world_tests.lua +++ b/tests/mod_world_tests.lua @@ -893,6 +893,76 @@ do check(value == nil and err == "no overworld", "npc() off the world") value, err = api:queueScript({}) check(value == nil and err == "no overworld", "queueScript() off the world") + value, err = api:startWildBattle("PIDGEY", 5) + check(value == nil and err == "no overworld", "startWildBattle() off the world") +end + +-- startWildBattle: what regresses is the handoff, not the battle. A mod that +-- builds a BattleState and pushes it itself still fights and still levels; it +-- silently loses onFinish -> afterBattle (evolutions, blackout-on-loss) and +-- pushBattle (entry wipe, battle theme). Shipped mods have hit exactly this. +do + -- the real dataset, not fixture(): a battle reaches for type_chart, items, + -- battle_anims and more, and this block only reads + local data = Data + local state, game = liveWorld(data) + -- the handoff runs through these three, so each needs the live game + for _, fn in ipairs({ "pushBattle", "isDungeonTransitionMap", "afterBattle" }) do + check(bindGame(OW[fn], game), fn .. " binds Game") + end + state:setMap("PALLET_TOWN", 5, 6, "down", { via = "boot" }) + + local Pokemon = require("src.pokemon.Pokemon") + local api = WorldAPI.new(game, "tester") + + local value, err = api:startWildBattle("NOT_A_MON", 5) + check(value == nil and err:find("unknown species", 1, true), + "an unknown species refuses and names it") + -- Pokemon.new writes the level through into the stat calc and the exp curve + -- verbatim, so a fraction has to be refused rather than rounded downstream + for _, lv in ipairs({ 0, 101, "nope", 5.5 }) do + check(api:startWildBattle("PIDGEY", lv) == nil, + "level " .. tostring(lv) .. " refuses") + end + + local caterpie = Pokemon.new(data, "CATERPIE", 6) + game.save.party = { caterpie } + check(api:startWildBattle("PIDGEY", 25) == true, "a wild battle starts") + + -- pushBattle pushes the entry transition, which pushes the battle from its + -- own callback; awardExp is the BattleState marker (screenId would not work, + -- only Screens.push stamps that and pushBattle pushes the battle directly) + check(game.stack:top() ~= nil and game.stack:top().awardExp == nil, + "the entry transition goes on first") + -- overworld() resolves the world from UNDER the battle, so a second call + -- while one is up has to refuse rather than stack another + check(api:startWildBattle("PIDGEY", 5) == nil, + "a battle already running refuses") + + local battle + for _ = 1, 400 do + local t = game.stack:top() + if t and t.awardExp then battle = t break end + if t and t.update then t:update(1 / 60) else break end + end + check(battle ~= nil, "the transition hands off to the battle") + + battle.participants = { [caterpie] = true } + battle:awardExp() + check(caterpie.level >= 7, "the mon levels past its evolution threshold") + check(battle.leveledUp and battle.leveledUp[caterpie], + "awardExp records the level-up for EvolveAfterBattle") + + game.stack:pop() + battle.onFinish("win") + for _ = 1, 12 do + local t = game.stack:top() + if not t or t.screenId == "EvolutionState" then break end + game.stack:pop() + if t.onDone then t.onDone() end + end + check(game.stack:top() and game.stack:top().screenId == "EvolutionState", + "the win reaches the evolution screen") end do diff --git a/tests/parity_world_start_wild_battle.lua b/tests/parity_world_start_wild_battle.lua deleted file mode 100644 index d9ee6cec..00000000 --- a/tests/parity_world_start_wild_battle.lua +++ /dev/null @@ -1,130 +0,0 @@ --- mod.world:startWildBattle. What regresses is the handoff, not the battle: --- a mod that pushes its own BattleState still fights and still levels, it just --- loses onFinish -> afterBattle (evolutions, blackout-on-loss) and pushBattle --- (entry wipe, battle theme), silently. Shipped mods have hit exactly this. --- --- T3, not the ROM-free tier: the handoff only exists once a real map is up --- (OverworldState:enter binds the module-local Game and MapLoader needs real --- tilesets), and the fixture dataset carries neither. - -package.path = "./?.lua;./?/init.lua;" .. package.path -if not _G.love then _G.love = require("tests.love_stub") end - -local Data = require("src.core.Data") -if not Data.maps then Data:load() end -local S = require("tests.harness").suite("parity world startWildBattle") -local check = S.check - -local Game = require("src.core.Game") -local SaveData = require("src.core.SaveData") -local StateStack = require("src.core.StateStack") -local Pokemon = require("src.pokemon.Pokemon") -local WorldAPI = require("src.world.WorldAPI") -local OverworldState = require("src.world.OverworldController") -require("src.render.Font").load(Data) - --- data/generated/audio.lua only exists after an in-app ROM import; the Python --- developer builder has no audio stage, so Music's entry points can be absent. --- setMap plays map music on the way in, which is incidental to the handoff --- under test -- no-op whatever is missing rather than depend on the dataset. --- run_tests.lua dofiles every suite into one process, so this is restored at --- the end: leaving it patched makes later suites pass that otherwise fail. -local Music = require("src.core.Music") -local musicSaved = {} -for _, fn in ipairs({ "playMap", "setSurfing", "playBattle" }) do - -- `or false` so a genuinely-absent entry point is still recorded and gets - -- restored to nil; storing the nil directly would drop the key entirely - musicSaved[fn] = Music[fn] or false - Music[fn] = Music[fn] or function() end -end -local function restoreMusic() - for fn, orig in pairs(musicSaved) do Music[fn] = orig or nil end -end - -local function freshWorld() - Game.data = Data - Game.save = SaveData.newGame() - Game.stack = StateStack; StateStack:init() - Game.renderer = { worldViewSize = function() return 160, 144 end } - Game.overworld = OverworldState - OverworldState:enter("ROUTE_1", 5, 5, "down") - return WorldAPI.new(Game, "testmod") -end - --- The assertions run under pcall so the Music patch is handed back even when --- one of them throws: run_tests.lua dofiles the later suites into this same --- process, and a Music left stubbed lets their own music checks pass. -local function body() - -- ----- argument handling: every failure is a nil + reason, never a throw - - local world = WorldAPI.new({ data = Data }, "testmod") - local ok, err = world:startWildBattle("PIDGEY", 5) - check(ok == nil, "no overworld up refuses") - check(err == "no overworld", "and says so") - - world = freshWorld() - Game.save.party = { Pokemon.new(Data, "CATERPIE", 6) } - - ok, err = world:startWildBattle("NOT_A_MON", 5) - check(ok == nil, "an unknown species refuses") - check(err and err:find("unknown species", 1, true), "and names the species") - - -- 5.5 too: Pokemon.new writes the level through into the stat calc and the - -- exp curve verbatim, so a fraction has to be refused, not rounded - for _, lv in ipairs({ 0, 101, "nope", 5.5 }) do - check(world:startWildBattle("PIDGEY", lv) == nil, - "level " .. tostring(lv) .. " refuses") - end - - -- ----- the handoff: a level-up evolution is offered after the win - - world = freshWorld() - local caterpie = Pokemon.new(Data, "CATERPIE", 6) - Game.save.party = { caterpie } - - check(world:startWildBattle("PIDGEY", 25) == true, "a wild battle starts") - - -- pushBattle pushes the transition, which pushes the battle from its - -- callback. awardExp is the BattleState marker the drain loop below - -- identifies it by; screenId would NOT work here -- only Screens.push - -- stamps that, and pushBattle pushes the battle straight onto the stack, - -- so `screenId ~= "BattleState"` holds even with the transition skipped. - local top = Game.stack:top() - check(top ~= nil, "something was pushed") - check(top.awardExp == nil, "the entry transition goes on first") - - -- overworld() resolves the world from under the battle, so a second call - -- while one is up has to refuse rather than stack another - check(world:startWildBattle("PIDGEY", 5) == nil, - "a battle already running refuses") - - local battle - for _ = 1, 400 do - local t = Game.stack:top() - if t and t.awardExp then battle = t break end - if t and t.update then t:update(1 / 60) else break end - end - check(battle ~= nil, "the transition hands off to the battle") - - battle.participants = { [caterpie] = true } - battle:awardExp() - check(caterpie.level >= 7, "the mon levels past its evolution threshold") - check(battle.leveledUp and battle.leveledUp[caterpie], - "awardExp records the level-up for EvolveAfterBattle") - - Game.stack:pop() - battle.onFinish("win") - for _ = 1, 12 do - local t = Game.stack:top() - if not t or t.screenId == "EvolutionState" then break end - Game.stack:pop() - if t.onDone then t.onDone() end - end - check(Game.stack:top() and Game.stack:top().screenId == "EvolutionState", - "the win reaches the evolution screen") -end - -local ran, runErr = pcall(body) -restoreMusic() -if not ran then error(runErr, 0) end -S.finish() From aa3b2a18ec06d844f42be873278c5232628376fa Mon Sep 17 00:00:00 2001 From: MaxTomahawk Date: Sat, 8 Aug 2026 13:28:56 +0200 Subject: [PATCH 48/63] feat(mods): signal verified checkpoint restores --- docs/modding.md | 28 ++ docs/rfcs/0004-runtime-checkpoints.md | 14 +- docs/rfcs/0005-battle-runtime-checkpoints.md | 17 +- src/core/Checkpoint.lua | 11 +- tests/modkit/cases/checkpoint_cross_mod.lua | 342 +++++++++++++++++++ 5 files changed, 402 insertions(+), 10 deletions(-) create mode 100644 tests/modkit/cases/checkpoint_cross_mod.lua diff --git a/docs/modding.md b/docs/modding.md index 8bbb7dde..8aef72ed 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -194,6 +194,34 @@ effects; verifies a recapture; and rolls back runtime plus RNG in memory if reconstruction fails. Callers that need crash recovery should durably capture their own recovery checkpoint before restore. +Checkpoint ownership follows the persistence model rather than mod identity: + +- canonical `game.save` progress, including every mod's `save.modData` / + `mod.save` bucket and data-only fields added to saved Pokémon, rewinds; +- global and per-mod options remain at their current values; +- independently written `mod.storage` records do not rewind; and +- mod-owned runtime objects, references, and caches are never serialized. + +Successful restore emits `checkpoint.restored` only after reconstruction and +differential recapture have committed. Mods that cache rewound progress or hold +references to reconstructed runtime objects can re-read their own public state +and rebuild at that point: + +```lua +mod.events:on("checkpoint.restored", function(ev) + -- ev.kind is "overworld" or "battle"; ev.game is fully reconstructed. + cachedQuestStage = mod.save:get("quest_stage", 0) + rebuildRuntimeFor(ev.game, ev.kind) +end) +``` + +The event is not emitted for validation failure, failed reconstruction, or a +successful rollback. Its payload contains no checkpoint data or other mod's +private state. A mod that deliberately stores progress-coupled truth in +`mod.storage` must version and reconcile that relationship itself; the engine +cannot distinguish it safely from independent history, configuration, or cache +data. + See RFC 0003, RFC 0004, and RFC 0005 for exact contracts and error codes. ## Developer console diff --git a/docs/rfcs/0004-runtime-checkpoints.md b/docs/rfcs/0004-runtime-checkpoints.md index 54e928de..1df98749 100644 --- a/docs/rfcs/0004-runtime-checkpoints.md +++ b/docs/rfcs/0004-runtime-checkpoints.md @@ -97,7 +97,9 @@ events, `onEnter` scripts, forced-movement/current checks, and last-map rewrites it does not emit normal `save.loading`/`save.loaded` lifecycle events. After reconstruction, the engine recaptures and byte-compares normalized data. A failed apply rolls back and returns `restore_failed`; failure of that rollback returns -`rollback_failed`. +`rollback_failed`. Only after a successful comparison does the engine emit +`checkpoint.restored` with `{ game = game, kind = "overworld" }`. Validation +failure, failed apply, and successful rollback emit nothing. Durable recovery remains a caller responsibility: in-memory rollback handles a runtime exception, not process termination. @@ -112,9 +114,11 @@ support implied here. ## Migration note for existing mods -**Nothing.** No existing hook, event, save, controller, or world action changes +**Nothing required.** No existing hook, save, controller, or world action changes when `mod.checkpoints` is unused. The reconstruction path is called only by a -successful public restore after validation. +successful public restore after validation. Mods whose runtime caches derive from +rewound `game.save` or `mod.save` state may optionally subscribe to +`checkpoint.restored` and rebuild from their own public state. ## Parity tests @@ -124,7 +128,9 @@ successful public restore after validation. unsafe refusal, detached data-only capture, exact map/tile/facing/surf sync, `A -> mutate B -> restore A -> recapture A2` equality across representative progress, settings preservation, compatibility rejection without mutation, - map-side-effect suppression, and injected reconstruction rollback. + map-side-effect suppression, injected reconstruction rollback, mod-owned + metadata and `mod.save` rewind, independent `mod.storage`/options preservation, + and success-only runtime-cache reconciliation through `checkpoint.restored`. ## Deprecation etiquette diff --git a/docs/rfcs/0005-battle-runtime-checkpoints.md b/docs/rfcs/0005-battle-runtime-checkpoints.md index 6735f5d2..f94d2ba4 100644 --- a/docs/rfcs/0005-battle-runtime-checkpoints.md +++ b/docs/rfcs/0005-battle-runtime-checkpoints.md @@ -90,11 +90,14 @@ mutation. The engine then: 4. binds an engine-owned wild/trainer completion continuation; 5. installs the battle directly at the settled menu without replaying its intro; 6. restores the RNG after reconstruction has finished; and -7. recaptures and compares the complete checkpoint. +7. recaptures and compares the complete checkpoint; and +8. emits `checkpoint.restored` with `{ game = game, kind = "battle" }` after the + comparison succeeds. The pre-operation checkpoint is the transaction rollback. A failed post-install RNG restore is covered: both battle runtime and RNG are reconstructed back to -their original values. +their original values. Validation failure, failed reconstruction, and successful +rollback emit no checkpoint lifecycle event. ## Continuation decision @@ -112,9 +115,10 @@ contract until a separate semantic ScriptRunner checkpoint RFC exists. ## Migration note **Existing mods require no changes.** The facade and format number are unchanged; -the new kind and RNG field are additive. Overworld-only callers may continue to -filter `capability.kind`. No-mod behavior is unchanged when checkpoints are -unused. +the new kind, RNG field, and success-only lifecycle event are additive. +Overworld-only callers may continue to filter `capability.kind`. Mods with derived +runtime caches may rebuild them from restored public state when the event fires. +No-mod behavior is unchanged when checkpoints are unused. ## Verification @@ -129,5 +133,8 @@ unused. raw RNG result after reload; - corrupt content/continuation rejection before mutation; - injected post-install failure with full runtime and RNG rollback; +- mod-added Pokémon metadata and `mod.save` rewind while independent + `mod.storage` and options remain current; +- exactly one post-verification `checkpoint.restored` event and none on failure; - legacy overworld checkpoint compatibility; - complete ROM-free engine and public mod-API suites. diff --git a/src/core/Checkpoint.lua b/src/core/Checkpoint.lua index f6827fa8..e6a3b036 100644 --- a/src/core/Checkpoint.lua +++ b/src/core/Checkpoint.lua @@ -6,6 +6,7 @@ local SaveData = require("src.core.SaveData") local Version = require("src.core.Version") local BattleState = require("src.battle.BattleState") local BattleCheckpoint = require("src.core.BattleCheckpoint") +local ModRuntime = require("src.mods.Runtime") local Checkpoint = {} @@ -398,7 +399,15 @@ function Checkpoint.restore(game, checkpoint) if ok then local restored, verifyCode = Checkpoint.capture(game) if restored and validated.rng == nil then restored.rng = nil end - if restored and equalData(restored, validated) then return true end + if restored and equalData(restored, validated) then + if ModRuntime.wants("checkpoint.restored") then + ModRuntime.emit("checkpoint.restored", { + game = game, + kind = validated.kind, + }) + end + return true + end err = restored and ("restored state differed at " .. tostring(firstDifference(validated, restored) or "canonical encoding")) or ("restored state could not be captured: " .. tostring(verifyCode)) diff --git a/tests/modkit/cases/checkpoint_cross_mod.lua b/tests/modkit/cases/checkpoint_cross_mod.lua new file mode 100644 index 00000000..91e1a504 --- /dev/null +++ b/tests/modkit/cases/checkpoint_cross_mod.lua @@ -0,0 +1,342 @@ +-- Cross-mod checkpoint ownership and lifecycle contract through public API only. +-- The Pokemon metadata case models masterwebx/SHINY_POKEMON 1.0.8 at 2141b2e: +-- shiny identity is data on the plain Pokemon record (`dvs` plus `shiny`). + +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") + +local T = require("tests.harness").suite("checkpoint cross-mod compatibility") +local BattleState = require("src.battle.BattleState") +local Fixtures = require("tests.modkit").fixtures +local GameMethods = require("src.core.Game") +local Loader = require("src.mods.Loader") +local Pokemon = require("src.pokemon.Pokemon") +local Runtime = require("src.mods.Runtime") +local SaveData = require("src.core.SaveData") +local StateStack = require("src.core.StateStack") +local Stats = require("src.pokemon.Stats") + +local savedEvents, savedHooks = Runtime.events, Runtime.hooks +local oldGetRandomState = love.math.getRandomState +local oldSetRandomState = love.math.setRandomState +local rngState = "cross-mod-rng-A" +love.math.getRandomState = function() return rngState end +love.math.setRandomState = function(state) rngState = state end + +local function memfs(files) + return { + read = function(path) return files[path] end, + write = function(path, body) files[path] = body return true end, + remove = function(path) files[path] = nil return true end, + createDirectory = function() return true end, + getInfo = function(path) + if files[path] then return { type = "file" } end + local prefix = path .. "/" + for key in pairs(files) do + if key:sub(1, #prefix) == prefix then return { type = "directory" } end + end + return nil + end, + load = function(path) + if not files[path] then return nil, "no file: " .. path end + return load(files[path], path) + end, + getDirectoryItems = function(path) + local prefix, seen, out = path .. "/", {}, {} + for key in pairs(files) do + if key:sub(1, #prefix) == prefix then + local child = key:sub(#prefix + 1):match("^[^/]+") + if child and not seen[child] then + seen[child] = true + out[#out + 1] = child + end + end + end + table.sort(out) + return out + end, + } +end + +local function shinyDvs() + return { attack = 2, defense = 10, speed = 10, special = 10, hp = 0 } +end + +local function ordinaryDvs() + return { attack = 1, defense = 1, speed = 1, special = 1, hp = 15 } +end + +local function setPokemonIdentity(data, mon, shiny) + mon.dvs = shiny and shinyDvs() or ordinaryDvs() + mon.shiny = shiny and true or false + mon.stats = Stats.calc(data.pokemon[mon.species], mon.level, mon.dvs, mon.statExp) + mon.hp = math.min(mon.hp or mon.stats.hp, mon.stats.hp) +end + +local function setBattlerIdentity(data, battler, shiny) + setPokemonIdentity(data, battler.mon, shiny) + battler.curStats = battler.mon.stats + battler.shownHP = battler.mon.hp + battler.shownStatus = battler.mon.status +end + +local function makeGame() + local data = Fixtures.fresh() + local save = SaveData.newGame() + save.meta.playthroughId = "cross-mod-playthrough" + save.party = { Pokemon.new(data, "FIXMON_A", 20) } + SaveData.validate(save, data) + save.player.map, save.player.x, save.player.y = "FIX_TOWN", 2, 3 + save.player.facing, save.player.surfing = "left", false + save.options.modOptions = {} + + local stack = setmetatable({ states = {} }, { __index = StateStack }) + local game + local ow = { + map = { id = "FIX_TOWN" }, + player = { cellX = 2, cellY = 3, facing = "left", surfing = false }, + runner = { isRunning = function() return false end }, + parallelRunners = {}, pendingScripts = {}, parallelQueue = {}, scriptMoves = {}, + } + function ow:captureSave(target) + target.player.map = self.map.id + target.player.x, target.player.y = self.player.cellX, self.player.cellY + target.player.facing = self.player.facing + target.player.surfing = self.player.surfing and true or false + end + function ow:enter(mapId, x, y, facing, opts) + if game.failNextEnter then + game.failNextEnter = false + error("injected cross-mod reconstruction failure") + end + self.map = { id = mapId } + self.player = { + cellX = x, cellY = y, facing = facing, + surfing = game.save.player.surfing and true or false, + } + self.runner = { isRunning = function() return false end } + self.parallelRunners, self.pendingScripts = {}, {} + self.parallelQueue, self.scriptMoves = {}, {} + game.lastCheckpointEnter = opts + end + function ow:restoreBattleContinuation(battle, origin) + if origin.kind ~= "wild_encounter" or origin.map ~= self.map.id then + return false + end + battle.onFinish = function() end + return true + end + + game = setmetatable({ + data = data, save = save, stack = stack, overworld = ow, + }, { __index = GameMethods }) + stack.states[1] = ow + return game, ow +end + +local function manifest(id) + return ('{"id":"%s","name":"%s","version":"1.0.0",') + :format(id, id) .. '"entry":"main.lua","api":2,"profile":"content"}' +end + +local files = { + ["mods/cooperator/manifest.json"] = manifest("cooperator"), + ["mods/cooperator/main.lua"] = [[ +return function(mod) + local cachedStage = mod.save:get("stage", "unset") + local restoreCount = 0 + mod.options:define({ + { key = "mode", type = "choice", default = "default", + choices = { { "A", "A" }, { "B", "B" }, { "C", "C" } } }, + }) + mod.exports.checkpoints = mod.checkpoints + mod.exports.storage = mod.storage + mod.exports.setStage = function(stage) + mod.save:set("stage", stage) + cachedStage = stage + end + mod.exports.stage = function() return mod.save:get("stage", "unset") end + mod.exports.cachedStage = function() return cachedStage end + mod.exports.restoreCount = function() return restoreCount end + mod.events:on("checkpoint.restored", function(ev) + restoreCount = restoreCount + 1 + cachedStage = mod.save:get("stage", "unset") + mod.exports.lastRestore = { + game = ev.game, + kind = ev.kind, + top = ev.game.stack:top(), + } + end) +end +]], + ["mods/passive/manifest.json"] = manifest("passive"), + ["mods/passive/main.lua"] = [[ +return function(mod) + local cachedStage = mod.save:get("stage", "unset") + mod.exports.setStage = function(stage) + mod.save:set("stage", stage) + cachedStage = stage + end + mod.exports.stage = function() return mod.save:get("stage", "unset") end + mod.exports.cachedStage = function() return cachedStage end +end +]], +} + +local game, ow = makeGame() +local loader = Loader.new({ fs = memfs(files) }) +loader.game, game.mods = game, loader +T.check(loader:load({}) == true, "cooperating fixture mods load") +local cooperator = loader.exports.cooperator +local passive = loader.exports.passive +T.check(type(cooperator) == "table" and type(passive) == "table", + "fixture exposes only public mod exports") +if type(cooperator) ~= "table" or type(passive) ~= "table" then + Runtime.events, Runtime.hooks = savedEvents, savedHooks + love.math.getRandomState = oldGetRandomState + love.math.setRandomState = oldSetRandomState + T.finish() +end + +cooperator.setStage("A") +passive.setStage("A") +game:adoptSave(game.save, true) +local optionBucket = { mode = "A" } +game.save.options.modOptions.cooperator = optionBucket +loader.modOptions.cooperator = optionBucket +setPokemonIdentity(game.data, game.save.party[1], true) +T.check(Stats.isShiny(game.save.party[1].dvs), + "condition A uses the real Gen 2 DV shiny predicate") +T.check(cooperator.storage:write(game, "history", { generation = "A" }), + "independent history condition A writes through mod.storage") + +local overworldA, captureCode = cooperator.checkpoints:capture(game) +T.check(overworldA ~= nil, "condition A overworld captures: " .. tostring(captureCode)) + +-- Mutate canonical progress, both mod.save buckets, independent storage, +-- options, and runtime caches to condition B. +game.save.money = 999999 +setPokemonIdentity(game.data, game.save.party[1], false) +cooperator.setStage("B") +passive.setStage("B") +optionBucket.mode = "B" +rngState = "cross-mod-rng-B" +T.check(cooperator.storage:write(game, "history", { generation = "B" }), + "independent history advances to condition B") + +local bad = cooperator.checkpoints:capture(game) +bad.format = 99 +local rejected, rejectCode = cooperator.checkpoints:restore(game, bad) +T.check(rejected == false and rejectCode == "unsupported_format", + "failed checkpoint validation is reported") +T.eq(cooperator.restoreCount(), 0, "failed restore emits no lifecycle event") +T.eq(cooperator.cachedStage(), "B", "failed restore leaves runtime cache at B") +T.eq(cooperator.stage(), "B", "failed restore leaves mod.save at B") + +local failedTarget = cooperator.checkpoints:capture(game) +game.failNextEnter = true +local failed, failedCode = cooperator.checkpoints:restore(game, failedTarget) +T.check(failed == false and failedCode == "restore_failed", + "failed reconstruction rolls back without committing") +T.eq(cooperator.restoreCount(), 0, + "failed reconstruction and successful rollback emit no lifecycle event") +T.eq(cooperator.cachedStage(), "B", + "failed reconstruction leaves cooperating runtime cache at B") +T.eq(cooperator.stage(), "B", + "failed reconstruction rollback leaves mod.save at B") + +local restored, restoreCode, restoreMessage = + cooperator.checkpoints:restore(game, overworldA) +T.check(restored == true, + "condition A overworld restores: " .. tostring(restoreCode or restoreMessage)) +T.eq(game.save.money, overworldA.save.money, "core game progress rewinds to A") +T.eq(game.save.party[1].shiny, true, + "shiny marker rewinds with its Pokemon record") +T.check(Stats.isShiny(game.save.party[1].dvs), + "authoritative shiny DVs rewind with the Pokemon record") +T.eq(cooperator.stage(), "A", "cooperating mod.save progress rewinds to A") +T.eq(passive.stage(), "A", "all mods' mod.save progress rewinds generically") +T.eq(passive.cachedStage(), "B", + "runtime-only state is not serialized for a non-cooperating mod") +T.eq(cooperator.cachedStage(), "A", + "checkpoint lifecycle lets a cooperating mod rebuild its runtime cache") +T.same(cooperator.storage:read(game, "history"), { generation = "B" }, + "independent mod.storage history does not rewind") +T.eq(cooperator.restoreCount(), 1, "successful overworld restore emits once") +local overworldEvent = cooperator.lastRestore or {} +T.eq(overworldEvent.game, game, "restore event carries the final live game") +T.eq(overworldEvent.kind, "overworld", "restore event identifies overworld") +T.eq(overworldEvent.top, ow, + "restore event runs after the reconstructed overworld is installed") +T.eq(loader.modOptions.cooperator.mode, "B", + "per-mod global options stay at condition B") +T.eq(game.save.options.modOptions.cooperator.mode, "B", + "checkpoint reattaches the current global options table") + +-- Repeat the same ownership rules at a supported ordinary wild battle safe point. +cooperator.setStage("battle-A") +passive.setStage("battle-A") +setPokemonIdentity(game.data, game.save.party[1], true) +local battle = BattleState.newWild(game, "FIXMON_B", 12) +battle.phase, battle.queue = "menu", {} +battle.checkpointOrigin = { kind = "wild_encounter", map = "FIX_TOWN" } +battle.musicKind = battle:computeMusicKind() +battle.onFinish = function() end +setBattlerIdentity(game.data, battle.enemy, true) +game.stack.states[2] = battle +rngState = "cross-mod-battle-rng-A" + +local battleA, battleCaptureCode = cooperator.checkpoints:capture(game) +T.check(battleA and battleA.kind == "battle", + "condition A battle captures: " .. tostring(battleCaptureCode)) +if battleA then + setBattlerIdentity(game.data, battle.player, false) + setBattlerIdentity(game.data, battle.enemy, false) + cooperator.setStage("battle-B") + passive.setStage("battle-B") + optionBucket.mode = "C" + rngState = "cross-mod-battle-rng-B" + T.check(cooperator.storage:write(game, "history", { generation = "battle-B" }), + "independent history advances during battle") + + local battleRestored, battleRestoreCode, battleRestoreMessage = + cooperator.checkpoints:restore(game, battleA) + T.check(battleRestored == true, + "condition A battle restores: " + .. tostring(battleRestoreCode or battleRestoreMessage)) + local restoredBattle = game.stack:top() + T.eq(restoredBattle.player.mon, game.save.party[1], + "restored player battler rebinds to canonical party Pokemon") + T.eq(restoredBattle.player.mon.shiny, true, + "player shiny metadata rewinds through battle reconstruction") + T.check(Stats.isShiny(restoredBattle.player.mon.dvs), + "player shiny DVs rewind through battle reconstruction") + T.eq(restoredBattle.enemy.mon.shiny, true, + "enemy shiny metadata rewinds with copied battle Pokemon") + T.check(Stats.isShiny(restoredBattle.enemy.mon.dvs), + "enemy shiny DVs rewind through battle reconstruction") + T.eq(cooperator.stage(), "battle-A", "battle restore rewinds mod.save progress") + T.eq(cooperator.cachedStage(), "battle-A", + "battle restore event rebuilds cooperating runtime cache") + T.eq(passive.cachedStage(), "battle-B", + "battle restore still does not serialize arbitrary mod runtime") + T.same(cooperator.storage:read(game, "history"), { generation = "battle-B" }, + "battle restore leaves independent history current") + T.eq(loader.modOptions.cooperator.mode, "C", + "battle restore leaves per-mod global options current") + T.eq(cooperator.restoreCount(), 2, "successful battle restore emits once") + local battleEvent = cooperator.lastRestore or {} + T.eq(battleEvent.kind, "battle", "restore event identifies battle") + T.eq(battleEvent.top, restoredBattle, + "battle restore event runs after final battle installation") + T.same(cooperator.checkpoints:capture(game), battleA, + "combined battle and mod progress is a differential roundtrip") +end + +Runtime.events, Runtime.hooks = savedEvents, savedHooks +Runtime.currentMod = nil +love.math.getRandomState = oldGetRandomState +love.math.setRandomState = oldSetRandomState +_G.CROSS_MOD_CHECKPOINT = nil + +T.finish() From fdb3184c71f72514a7b22964d25a082dc60d062a Mon Sep 17 00:00:00 2001 From: thibautbus <310327033+thibautbus@users.noreply.github.com> Date: Sat, 8 Aug 2026 13:13:10 +0200 Subject: [PATCH 49/63] Fix Windows crash: decode LuaJIT dump output as UTF-8, not the locale codepage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit subprocess.run(..., capture_output=True, text=True) with no explicit encoding falls back to locale.getpreferredencoding(False) -- the OS default codepage. On Windows that's a legacy single-byte codepage (e.g. cp1252), never UTF-8. When the LuaJIT dump contains a byte with no mapping in that codepage, subprocess's internal _readerthread crashes with an uncaught UnicodeDecodeError in a background thread; the thread dies silently and the caller gets back stdout=None instead of a string, crashing one line later with AttributeError: 'NoneType' object has no attribute 'splitlines'. Concretely, the Yellow-side imported dataset contains: "_ColosseumHeightText" -> "...6’8” tall!" The right double quotation mark (U+201D) encodes in UTF-8 as E2 80 9D; 0x9D has no defined character in cp1252, so decoding as cp1252 fails outright. Verified against the real imported dataset: the Red/Blue-only dump has zero bytes outside cp1252's defined range; the Yellow dump has exactly one, at this row. UTF-8 is the actual encoding these dumps are produced in -- the driver Lua sources are read/written as UTF-8 throughout this file, and LuaJIT writes those source strings' bytes back out verbatim -- so passing encoding="utf-8" explicitly at the three affected call sites (run_loader, check_data_dump, dump_dataset) is a no-op on platforms whose default codepage is already UTF-8 (Linux/macOS) and a correctness fix on Windows. Co-Authored-By: Claude Sonnet 5 --- tools/modkit.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tools/modkit.py b/tools/modkit.py index 4eaf7f8b..09f868a5 100644 --- a/tools/modkit.py +++ b/tools/modkit.py @@ -716,7 +716,7 @@ def run_loader(repo, mod_dir, findings, base="fixture", notes=None): driver_path = handle.name try: proc = subprocess.run([LUAJIT, driver_path], cwd=repo, - capture_output=True, text=True, timeout=120) + capture_output=True, text=True, encoding="utf-8", timeout=120) except FileNotFoundError: findings.append(Finding("MK100", "error", f"cannot run {LUAJIT} (install luajit or " @@ -1056,7 +1056,7 @@ def check_data_dump(repo, path, base, rel): driver = DUMP_DRIVER % (lua_quote(path), lua_quote(vanilla)) try: proc = subprocess.run([LUAJIT, "-e", driver], cwd=repo, - capture_output=True, text=True, timeout=60) + capture_output=True, text=True, encoding="utf-8", timeout=60) except FileNotFoundError: # the gate must fail closed: a missing interpreter is a broken # environment, not a clean mod @@ -1416,7 +1416,7 @@ def dump_dataset(repo, base): handle.close() try: proc = subprocess.run([os.environ.get("LUA", "luajit"), handle.name], - cwd=repo, capture_output=True, text=True) + cwd=repo, capture_output=True, text=True, encoding="utf-8") finally: os.unlink(handle.name) if proc.returncode != 0: From ff914810188f0f4a299d7ccf31a617f8b30b4eff Mon Sep 17 00:00:00 2001 From: Shane McGovern Date: Sun, 9 Aug 2026 00:17:51 +0100 Subject: [PATCH 50/63] Fix Substitute zero-HP boundary Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/battle/MoveEffects.lua | 10 +++++----- tests/parity_substitute_anim.lua | 13 +++++++++++++ tests/run_tests.lua | 17 +++++++++-------- 3 files changed, 27 insertions(+), 13 deletions(-) diff --git a/src/battle/MoveEffects.lua b/src/battle/MoveEffects.lua index 93c24079..a96127fd 100644 --- a/src/battle/MoveEffects.lua +++ b/src/battle/MoveEffects.lua @@ -281,11 +281,11 @@ MoveEffects.primary = { failed = true } end local cost = math.floor(user.mon.stats.hp / 4) - -- substitute.asm only fails on subtraction underflow (current HP - -- strictly below maxHP/4); at equality the substitute is built and - -- the user is left standing on exactly 0 HP (it faints only when - -- the engine next checks HP, not here) - if user.mon.hp < cost then + -- A Substitute costs one quarter of max HP, rounded down. Do not let + -- the cost consume the user's last HP: the move must fail at the exact + -- boundary as well as below it, or the next turn's HP guard can leave a + -- trainer battle unable to progress. + if user.mon.hp <= cost then return { romText(battle.data, "_TooWeakSubstituteText", "Too weak to make\na SUBSTITUTE!"), failed = true } end diff --git a/tests/parity_substitute_anim.lua b/tests/parity_substitute_anim.lua index 8092c87b..1071d150 100644 --- a/tests/parity_substitute_anim.lua +++ b/tests/parity_substitute_anim.lua @@ -75,6 +75,19 @@ do check(anyText(tb, "SUBSTITUTE"), "the failure text still prints") end +-- Exact quarter HP is also not enough: accepting it would leave the user at +-- 0 HP with substituteHP set, so the next trainer-battle turn cannot advance. +do + local tb = freshBattle() + local cost = math.floor(tb.enemy.mon.stats.hp / 4) + tb.enemy.mon.hp = cost + tb:performMove(tb.enemy, tb.player, { id = "SUBSTITUTE", pp = 10 }, false) + check(tb.enemy.substituteHP == nil and tb.enemy.mon.hp == cost, + "exact quarter HP cannot create a zero-HP substitute") + check(not anyAnim(tb, "SUBSTITUTE"), + "the exact-boundary failure plays no animation") +end + -- .alreadyHasSubstitute: same, with a doll already standing do local tb = freshBattle() diff --git a/tests/run_tests.lua b/tests/run_tests.lua index 92b21cd2..eb1bbcc9 100644 --- a/tests/run_tests.lua +++ b/tests/run_tests.lua @@ -446,16 +446,17 @@ check(misted2.stages.attack == nil and mistMsgs[1]:find("MIST", 1, true) ~= nil, "primary stat drop still blocked by MIST") --- Substitute boundary: built at exactly 1/4 max HP, leaving 0 HP --- (substitute.asm only fails on subtraction underflow) +-- Substitute boundary: the move must fail when its quarter-HP cost would +-- consume all current HP, preventing a zero-HP user with a live substitute. local subUser = { mon = { stats = { hp = 40 }, hp = 10 }, name = "SUBBY" } -MoveEffects.primary.SUBSTITUTE_EFFECT(sideRng, subUser) -check(subUser.substituteHP ~= nil and subUser.mon.hp == 0, - "substitute built at exactly 1/4 max HP leaves 0 HP") -local subUser2 = { mon = { stats = { hp = 40 }, hp = 9 }, name = "SUBBY" } -local subMsgs = MoveEffects.primary.SUBSTITUTE_EFFECT(sideRng, subUser2) -check(subUser2.substituteHP == nil +local subMsgs = MoveEffects.primary.SUBSTITUTE_EFFECT(sideRng, subUser) +check(subUser.substituteHP == nil and subUser.mon.hp == 10 and subMsgs[1]:find("weak", 1, true) ~= nil, + "substitute fails at exactly 1/4 max HP") +local subUser2 = { mon = { stats = { hp = 40 }, hp = 9 }, name = "SUBBY" } +local subMsgs2 = MoveEffects.primary.SUBSTITUTE_EFFECT(sideRng, subUser2) +check(subUser2.substituteHP == nil and subUser2.mon.hp == 9 + and subMsgs2[1]:find("weak", 1, true) ~= nil, "substitute fails below 1/4 max HP") -- Haze clears Disable/X ACCURACY on both sides and forfeits the turn of From 81f18e244d8b6dc84b68e6d3f3bed01d18a39663 Mon Sep 17 00:00:00 2001 From: Thomas Armstrong <12937683+ArmstrongThomas@users.noreply.github.com> Date: Sun, 9 Aug 2026 04:10:39 -0400 Subject: [PATCH 51/63] Support variable-size anchored overworld sprites --- docs/architecture.md | 2 +- docs/modding.md | 38 +++++++ src/mods/Schemas.lua | 7 ++ src/render/SpriteRenderer.lua | 171 ++++++++++++++++++++++++------ src/world/OverworldController.lua | 18 +++- src/world/Player.lua | 9 +- tests/mod_graphics_tests.lua | 47 ++++++++ 7 files changed, 250 insertions(+), 42 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index a1d58df6..09bbc60d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -38,7 +38,7 @@ the same core data and graphics into the source tree for verification. | | `src/core/SaveData.lua` | Lua-serialized save in the LÖVE save dir | | render | `src/render/Renderer.lua` | 160x144 canvas, integer nearest scaling | | | `src/render/TileRenderer.lua` | one SpriteBatch per map (8x8 quads) + border-block ring | -| | `src/render/SpriteRenderer.lua` | 6-frame walker sheets, flipped right facing | +| | `src/render/SpriteRenderer.lua` | variable-size anchored sprite sheets, 6-frame walkers and flipped right facing | | | `src/render/Font.lua` | glyph rendering via charmap (greedy longest match) | | | `src/render/TextBox.lua` | dialogue box: typewriter, `\n` line, `\v` scroll, `\f` page | | | `src/render/Camera.lua`, `Transition.lua` | follow camera, warp fades | diff --git a/docs/modding.md b/docs/modding.md index 8bbb7dde..369479ad 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -100,6 +100,44 @@ Three rules worth knowing: Returning `nil` from `drawWorld` is a normal answer meaning "not this frame"; the engine draws the vanilla world instead. +## Variable-size overworld sprites + +The `sprites` registry keeps the vanilla 16x16 grounded walker as its default, +but a mod can describe any frame rectangle and anchor for player characters, +NPCs, followers, mounts, vehicles, bosses, or other field actors: + +```lua +mod.content.sprites:register("SPRITE_COMPANION", { + image = "mods/example/companion.png", -- one frame per row + frames = 6, + walker = true, + frameWidth = 32, + frameHeight = 32, + anchorX = 16, -- frame-relative bottom-center anchor + anchorY = 32, +}) +``` + +`frameWidth` and `frameHeight` are sheet pixels. `anchorX` and `anchorY` are +measured from each frame's top-left; when omitted they default to the frame's +horizontal center and bottom edge, so a larger sprite grows upward while its +feet stay on the same world cell. Omitting all four fields is exactly the +vanilla 16x16 placement. The normal player/NPC/follower draw paths consume +these values automatically, including horizontal flips and the fishing pose. + +Custom render pipelines can use the same geometry without reproducing the +pose rules: + +```lua +local geometry = sprite:getPoseGeometry(facing, walkPhase, stepFlip) +-- geometry.quad, .x/.y/.width/.height, .anchorX/.anchorY, .mirror +local originX, originY = sprite:getScreenOrigin(px, py, camX, camY) +``` + +`getFrameGeometry(frame)` is the corresponding accessor for a specific +zero-based sheet frame. Both accessors return fresh tables and share the +renderer’s frame selection and mirror conventions. + ## Battle sprite scaling The enemy's front pic draws at 1x and the player's back pic at 2x, the way diff --git a/src/mods/Schemas.lua b/src/mods/Schemas.lua index c834ce41..3c033bbe 100644 --- a/src/mods/Schemas.lua +++ b/src/mods/Schemas.lua @@ -592,6 +592,13 @@ R.sprites = { image = f.path, frames = f.int(1), walker = f.opt(f.bool), + -- Optional sheet geometry for mod actors. Defaults match the vanilla + -- 16x16 grounded walker; anchors are measured from each frame's + -- top-left in pixels (default: bottom-center). + frameWidth = f.opt(f.int(1)), + frameHeight = f.opt(f.int(1)), + anchorX = f.opt(f.num), + anchorY = f.opt(f.num), trueColor = f.opt(f.bool), -- Mod art can opt into an existing ROM sprite's Advanced-mode OBJ -- palette assignment without claiming that the image itself came from diff --git a/src/render/SpriteRenderer.lua b/src/render/SpriteRenderer.lua index 3f39f1d9..36ebb36e 100644 --- a/src/render/SpriteRenderer.lua +++ b/src/render/SpriteRenderer.lua @@ -1,7 +1,8 @@ --- Overworld character sprites. A 12-tile sheet (16x96 PNG) holds 6 16x16 --- frames: stand down/up/left, walk down/up/left (data/sprites/facings.asm). +-- Overworld character sprites. The vanilla 12-tile sheet (16x96 PNG) holds +-- 6 16x16 frames: stand down/up/left, walk down/up/left +-- (data/sprites/facings.asm). Mod records may opt into another frame size +-- and anchor; the defaults below preserve the original grounded placement. -- Right-facing frames are horizontal flips of the left frames. --- Sprites draw 4px above their cell, like the GB engine. local Assets = require("src.render.Assets") local PaletteFX = require("src.render.PaletteFX") @@ -76,6 +77,58 @@ local WALK = { down = 3, up = 4, left = 5, right = 5 } SpriteRenderer.STAND = STAND SpriteRenderer.WALK = WALK +-- Sprite records are anchored at the point where the actor stands in the +-- world. In the vanilla renderer that point is the bottom-center of a +-- 16x16 frame: the frame starts at (px, py - 4), so the ground point is +-- (px + 8, py + 12). Custom anchors are measured from the frame's top-left +-- in sheet pixels and may be fractional for a sub-pixel art style. +local DEFAULT_FRAME_WIDTH = 16 +local DEFAULT_FRAME_HEIGHT = 16 +local DEFAULT_ANCHOR_X = 8 +local DEFAULT_ANCHOR_Y = 16 +local WORLD_ANCHOR_X = 8 +local WORLD_ANCHOR_Y = 12 +SpriteRenderer.DEFAULT_FRAME_WIDTH = DEFAULT_FRAME_WIDTH +SpriteRenderer.DEFAULT_FRAME_HEIGHT = DEFAULT_FRAME_HEIGHT +SpriteRenderer.DEFAULT_ANCHOR_X = DEFAULT_ANCHOR_X +SpriteRenderer.DEFAULT_ANCHOR_Y = DEFAULT_ANCHOR_Y + +local function finiteNumber(value) + if type(value) ~= "number" or value ~= value + or value == math.huge or value == -math.huge then + return nil + end + return value +end + +local function positiveInteger(value, fallback) + value = finiteNumber(value) + if value and value >= 1 then return math.floor(value) end + return fallback +end + +local function numberOr(value, fallback) + return finiteNumber(value) or fallback +end + +local function pose(self, facing, walkPhase, stepFlip) + if self.frameCount <= 1 then return 0, false end + local frame = (self.def.walker and walkPhase == 1) + and WALK[facing] or STAND[facing] + frame = frame or 0 + -- Preserve the old fallback for a short custom sheet whose pose table + -- names a frame it does not provide. + if not self.frames[frame] then frame = 0 end + local flip = false + if facing == "right" then + flip = true + elseif (facing == "down" or facing == "up") + and walkPhase == 1 and stepFlip then + flip = true + end + return frame, flip +end + -- seed: any stable per-instance value (e.g. an NPC's `id`) used to resolve -- RED++'s per-instance "random" OBP sentinel (PaletteFX.spriteObp) function SpriteRenderer.new(spriteDef, seed) @@ -83,14 +136,62 @@ function SpriteRenderer.new(spriteDef, seed) self.def = spriteDef self.seed = seed self.image = getImage(spriteDef.image) + self.frameCount = positiveInteger(spriteDef.frames, 1) + self.frameWidth = positiveInteger(spriteDef.frameWidth, DEFAULT_FRAME_WIDTH) + self.frameHeight = positiveInteger(spriteDef.frameHeight, DEFAULT_FRAME_HEIGHT) + self.anchorX = numberOr(spriteDef.anchorX, self.frameWidth / 2) + self.anchorY = numberOr(spriteDef.anchorY, self.frameHeight) local iw, ih = self.image:getDimensions() self.frames = {} - for f = 0, spriteDef.frames - 1 do - self.frames[f] = love.graphics.newQuad(0, f * 16, 16, 16, iw, ih) + for f = 0, self.frameCount - 1 do + self.frames[f] = love.graphics.newQuad(0, f * self.frameHeight, + self.frameWidth, self.frameHeight, + iw, ih) end return self end +-- Return the sheet rectangle and top-left-relative anchor for a frame. The +-- result is a fresh table so a custom render pipeline may annotate it without +-- changing the renderer's shared definition. +function SpriteRenderer:getFrameGeometry(frame) + frame = math.floor(finiteNumber(frame) or 0) + if frame < 0 then frame = 0 end + if frame >= self.frameCount then frame = self.frameCount - 1 end + return { + frame = frame, + x = 0, + y = frame * self.frameHeight, + width = self.frameWidth, + height = self.frameHeight, + anchorX = self.anchorX, + anchorY = self.anchorY, + quad = self.frames[frame], + } +end + +-- Return the frame geometry selected by the ordinary 2D pose rules, plus the +-- horizontal mirror state that :draw applies. This is the supported hook for +-- custom render pipelines that need to draw actors with the same pose/flip. +function SpriteRenderer:getPoseGeometry(facing, walkPhase, stepFlip) + local frame, flip = pose(self, facing, walkPhase, stepFlip) + local geometry = self:getFrameGeometry(frame) + geometry.facing = facing + geometry.walkPhase = walkPhase + geometry.stepFlip = stepFlip + geometry.mirror = flip + return geometry +end + +-- Screen-space top-left for the actor's current world anchor. World-facing +-- effects such as fishing can use this instead of assuming a 16x16 frame. +function SpriteRenderer:getScreenOrigin(px, py, camX, camY) + local baseX = math.floor(px - camX) + WORLD_ANCHOR_X + local baseY = math.floor(py - camY) + WORLD_ANCHOR_Y + return math.floor(baseX - self.anchorX), + math.floor(baseY - self.anchorY) +end + -- The image this sprite would draw from right now: the plain sheet, or the -- OBP-recolored bake of it. Exposed so a render pipeline can texture its -- own geometry from the very same image -- the geometry carries sheet pixel @@ -125,27 +226,32 @@ end -- facing: down/up/left/right; walkPhase: 0 stand, 1 walk; flip: alternate -- steps mirror the walk frame for up/down (GB uses OAM flip for this). -local function blitFrame(image, quad, x, y, flip, redraw) +local function blitFrame(image, quad, x, y, flip, redraw, frameWidth) + frameWidth = frameWidth or DEFAULT_FRAME_WIDTH if flip then - love.graphics.draw(image, quad, x + 16, y, 0, -1, 1) - if redraw then PaletteFX.markSpriteRedraw(image, quad, x + 16, y, -1) end + love.graphics.draw(image, quad, x + frameWidth, y, 0, -1, 1) + if redraw then + PaletteFX.markSpriteRedraw(image, quad, x + frameWidth, y, -1) + end else love.graphics.draw(image, quad, x, y) if redraw then PaletteFX.markSpriteRedraw(image, quad, x, y, 1) end end end --- topHalf blits only the upper 8 rows of the frame: FishingAnim overwrites the --- bottom tile row of the standing frames with the fishing pose art, which the --- caller then draws itself through :drawTile (Player:draw, #384) +-- topHalf blits everything above the bottom 8-pixel tile row: FishingAnim +-- overwrites that row of the standing frames with fishing pose art, which the +-- caller then draws itself through :drawTile (Player:draw, #384). Vanilla +-- frames therefore still draw 8 rows, while taller frames keep their larger +-- body and reserve only the overlay row. function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip, topHalf) - local x = math.floor(px - camX) - local y = math.floor(py - camY) - 4 + local x, y = self:getScreenOrigin(px, py, camX, camY) local image = self.image local redraw = false - -- full-color art claims its 16x16 cell out of the shade-remap pass + -- True-color sheets bypass every palette bake; the screen-space exemption + -- is recorded below once the final frame/height is known. if self.def.trueColor then - PaletteFX.markTrueColor(x, y, 16, 16) + image = self.image elseif PaletteFX.usesGbcPack() then -- RED++: the world canvas is already true-color (TileRenderer bakes -- terrain, this bakes the sprite) and the world pass runs unshaded @@ -177,31 +283,28 @@ function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip, to -- being colorized by the zone IS the point (#301). image = getObpImage(self.def.image, PaletteFX.dmgObj()) end - -- single-frame sprites (item balls, fossils...) have one fixed pose; + -- Single-frame sprites (item balls, fossils...) have one fixed pose; -- still 3-frame sprites turn to face (the nurse at her machine, - -- facePlayer on STAY NPCs) but never show walk frames - if self.def.frames <= 1 then - blitFrame(image, self.frames[0], x, y, false, redraw) - return - end - local frame = (self.def.walker and walkPhase == 1) - and WALK[facing] or STAND[facing] - local flip = false - if facing == "right" then - flip = true - elseif (facing == "down" or facing == "up") and walkPhase == 1 and stepFlip then - flip = true - end - local quad = self.frames[frame] or self.frames[0] - if topHalf then + -- facePlayer on STAY NPCs) but never show walk frames. + local frame, flip = pose(self, facing, walkPhase, stepFlip) + local quad = self.frames[frame] + local drawHeight = self.frameHeight + if topHalf and self.frameCount > 1 then self.halfFrames = self.halfFrames or {} if not self.halfFrames[frame] then local iw, ih = self.image:getDimensions() - self.halfFrames[frame] = love.graphics.newQuad(0, frame * 16, 16, 8, iw, ih) + local topHeight = math.max(1, self.frameHeight - math.min(8, self.frameHeight)) + self.halfFrames[frame] = love.graphics.newQuad( + 0, frame * self.frameHeight, self.frameWidth, topHeight, iw, ih) end quad = self.halfFrames[frame] + drawHeight = math.max(1, self.frameHeight - math.min(8, self.frameHeight)) end - blitFrame(image, quad, x, y, flip, redraw) + -- Full-color art claims exactly the portion of the frame that was drawn. + if self.def.trueColor then + PaletteFX.markTrueColor(x, y, self.frameWidth, drawHeight) + end + blitFrame(image, quad, x, y, flip, redraw, self.frameWidth) end -- Blit a loose 16-wide fx tile at screen (x, y) wearing THIS sprite's OBJ @@ -225,7 +328,7 @@ function SpriteRenderer:drawTile(path, x, y, flip) self.tileQuads = self.tileQuads or {} self.tileQuads[path] = self.tileQuads[path] or love.graphics.newQuad(0, 0, iw, ih, iw, ih) - blitFrame(image, self.tileQuads[path], x, y, flip, redraw) + blitFrame(image, self.tileQuads[path], x, y, flip, redraw, iw) end return SpriteRenderer diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 1ac89816..ce3e7bf2 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -81,8 +81,10 @@ local HEAL_FLASH_MAP = { [0] = 0, [1] = 2, [2] = 1, [3] = 3 } -- above (screen = tile*8 + pixel - 8/16), measured against the player -- sprite's fixed screen spot: ResetPlayerSpriteData parks it at $3c/$40 -- (home/reset_player_sprite.asm), i.e. screen (64,60). So what ports over --- is the delta from the sprite's top-left, which SpriteRenderer:draw puts at --- (px, py - 4). `tile` indexes the three stacked 8x8 tiles of +-- is the delta from the sprite's top-left, which the vanilla +-- SpriteRenderer:draw puts at (px, py - 4); custom frame anchors move that +-- origin while keeping these offsets frame-relative. `tile` indexes the +-- three stacked 8x8 tiles of -- assets/generated/fx/fishing_rod.png: FishingRodOAM only ever draws $fd -- (row 0, up/down) and $fe (row 1, left/right), and RIGHT is the LEFT tile -- x-flipped. Blitting the whole 8x24 sheet is what drew the rod as a @@ -4799,9 +4801,15 @@ function OverworldState:drawWorld() end end local quad = self.rodQuads[oam.tile] - -- the sprite's top-left is 4px above its cell (SpriteRenderer:draw) - local rx = p.px - cam.x + oam.dx - local ry = p.py - cam.y - 4 + oam.dy + -- Place the rod against the active sprite's anchored top-left. The + -- vanilla result is still (px-cam, py-cam-4), while custom larger + -- sheets keep the rod attached to their feet. + -- Fishing always uses the on-foot player sheet; read its fields + -- directly so this FX pass does not advance pose-side animation. + local sprite, px, py = p.sprite, p.px, p.py + local sx, sy = sprite:getScreenOrigin(px, py, cam.x, cam.y) + local rx = sx + oam.dx + local ry = sy + oam.dy love.graphics.setColor(1, 1, 1, 1) if quad and oam.flip then love.graphics.draw(self.rodImg, quad, rx + 8, ry, 0, -1, 1) diff --git a/src/world/Player.lua b/src/world/Player.lua index 5fb257e8..65406b85 100644 --- a/src/world/Player.lua +++ b/src/world/Player.lua @@ -335,8 +335,13 @@ function Player:draw(camX, camY) local fishTile = self.fishing and self.fishTiles and self.fishTiles[facing] if fishTile then sprite:draw(px, py, camX, camY, facing, 0, false, true) - sprite:drawTile(fishTile, math.floor(px - camX), - math.floor(py - camY) - 4 + 8, facing == "right") + -- The fishing pose replaces the bottom 8-pixel tile. Use the sprite's + -- actual anchored frame origin so larger/custom sheets keep the pose at + -- their feet instead of falling back to the vanilla 16x16 top-left. + local sx, sy = sprite:getScreenOrigin(px, py, camX, camY) + sprite:drawTile(fishTile, sx, + sy + math.max(0, sprite.frameHeight - 8), + facing == "right") return end sprite:draw(px, py, camX, camY, facing, phase, flip) diff --git a/tests/mod_graphics_tests.lua b/tests/mod_graphics_tests.lua index b2b543a2..8e310e63 100644 --- a/tests/mod_graphics_tests.lua +++ b/tests/mod_graphics_tests.lua @@ -428,16 +428,42 @@ local spriteReg = Registry.new("sprites", Schemas.REGISTRIES.sprites) spriteReg:register("SPRITE_TITLE_LOGO", { image = "mods/logo/logo.png", frames = 1, trueColor = true }, "logo_mod") +spriteReg:register("SPRITE_LARGE_ACTOR", + { image = "mods/actor/actor.png", frames = 6, + walker = true, frameWidth = 32, frameHeight = 24, + anchorX = 16, anchorY = 24, trueColor = true }, + "actor_mod") local logoDef = spriteReg:get("SPRITE_TITLE_LOGO") check(Schemas.check(Schemas.REGISTRIES.sprites, "sprites", "SPRITE_TITLE_LOGO", logoDef, "register"), "a trueColor sprites record validates against the catalog schema") check(logoDef.trueColor == true, "and keeps the flag through the merge") +local largeDef = spriteReg:get("SPRITE_LARGE_ACTOR") +check(Schemas.check(Schemas.REGISTRIES.sprites, "sprites", "SPRITE_LARGE_ACTOR", + largeDef, "register"), + "a variable-size sprites record validates against the catalog schema") Renderer:init() local plainSprite = SpriteRenderer.new( { image = "assets/generated/sprites/red.png", frames = 1 }) local litSprite = SpriteRenderer.new(logoDef) +local largeSprite = SpriteRenderer.new(largeDef) +check(plainSprite.frameWidth == 16 and plainSprite.frameHeight == 16 + and plainSprite.anchorX == 8 and plainSprite.anchorY == 16, + "legacy sprite definitions keep the vanilla frame geometry") +local frameGeometry = largeSprite:getFrameGeometry(5) +check(frameGeometry.frame == 5 and frameGeometry.x == 0 + and frameGeometry.y == 120 and frameGeometry.width == 32 + and frameGeometry.height == 24 and frameGeometry.anchorX == 16 + and frameGeometry.anchorY == 24, + "frame geometry exposes a larger sheet rectangle and anchor") +local poseGeometry = largeSprite:getPoseGeometry("right", 1, true) +check(poseGeometry.frame == 5 and poseGeometry.mirror == true + and poseGeometry.quad == largeSprite.frames[5], + "pose geometry follows walker frame selection and right mirroring") +local originX, originY = largeSprite:getScreenOrigin(32, 32, 0, 0) +check(originX == 24 and originY == 20, + "a custom anchor keeps a larger sprite grounded at its cell") Renderer:beginFrame(true) check(#PaletteFX.trueColorRects("ui") == 0 @@ -471,6 +497,27 @@ check(#worldDrawn == 2, "the reported zone joins the world list endFrame blits") check(worldDrawn[1].shader and worldDrawn[2].shader == false, "the colorized pass runs first, then the sprite's rect with no shader") +-- Larger true-color frames claim their actual extent, and fishing's top-half +-- path reserves only the bottom 8-pixel tile for the overlay. +Renderer:beginFrame(true) +Renderer:beginWorldPass() +largeSprite:draw(32, 32, 0, 0, "down", 0, false) +local largeRects = PaletteFX.trueColorRects("world") +check(#largeRects == 1 and largeRects[1].x == 24 and largeRects[1].y == 20 + and largeRects[1].w == 32 and largeRects[1].h == 24, + "a larger trueColor sprite reports its full anchored extent") +Renderer:endWorldPass() + +Renderer:beginFrame(true) +Renderer:beginWorldPass() +largeSprite:draw(32, 32, 0, 0, "down", 0, false, true) +local topRects = PaletteFX.trueColorRects("world") +check(#topRects == 1 and topRects[1].h == 16 + and largeSprite.halfFrames[0].y == 0 + and largeSprite.halfFrames[0].h == 16, + "the fishing overlay keeps a larger frame's bottom tile clear") +Renderer:endWorldPass() + -- the same path on the UI canvas, which is where a full-color title logo -- or menu portrait lands Renderer:beginFrame(false) From b9e3c4a689091164c675aeca64ecfe96d8171764 Mon Sep 17 00:00:00 2001 From: AverageConsumer <35539970+AverageConsumer@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:46:33 +0200 Subject: [PATCH 52/63] feat(mods): expose battle UI visibility hooks --- docs/modding.md | 7 +++++ src/battle/BattleState.lua | 21 ++++++++++++--- src/battle/WideBattle.lua | 6 +++-- src/render/TextBox.lua | 7 +++++ tests/engine/wide_battle_shake_bug562.lua | 3 +++ tests/mod_qol_hooks_tests.lua | 31 +++++++++++++++++++++++ 6 files changed, 70 insertions(+), 5 deletions(-) diff --git a/docs/modding.md b/docs/modding.md index 8bbb7dde..1d326943 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -290,5 +290,12 @@ update and input ownership, so a mod can mirror a native menu on another display without reimplementing it. The default is `true`. Treat the wrapper as a pure predicate: the renderer may ask it more than once per frame. +`battle.bottom_ui_visible` and `battle.status_hud_visible` independently +control the battle text/menu layer and the HP/status panels. Both receive +`(next, state)` and default to `true`, so vanilla rendering is unchanged. +Pushed text boxes also pass through `battle.bottom_ui_visible`; a wrapper that +only owns battle presentation should return `false` only for its active battle +or text-box state. + Developer mode also arms the mod loader's dev tripwire, which flags mods that reach outside their permission set. diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 86b2ab18..0180fb47 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -127,6 +127,18 @@ function BattleState:sgbPalettes() return nil end +function BattleState:bottomUIVisible() + if not Runtime.wantsHook("battle.bottom_ui_visible") then return true end + return Runtime.call("battle.bottom_ui_visible", function() return true end, + self) ~= false +end + +function BattleState:statusHUDVisible() + if not Runtime.wantsHook("battle.status_hud_visible") then return true end + return Runtime.call("battle.status_hud_visible", function() return true end, + self) ~= false +end + local Rulesets = { gen1_faithful = require("src.battle.rulesets.gen1_faithful"), modern_clean = require("src.battle.rulesets.modern_clean"), @@ -5347,7 +5359,7 @@ function BattleState:drawPicsLayer(slide, sx, sy, onlySide, skipMenuClip) -- .mimicmenu) wipes rows 7+. The port draws pics above the menu -- layer in the colorized pipeline, so clip them to the visible rows. local g = love.graphics - local clipY = not skipMenuClip + local clipY = not skipMenuClip and self:bottomUIVisible() and (self.phase == "mimicSelect" and 56 or self.phase == "moveSelect" and 64) or nil @@ -5459,6 +5471,7 @@ function BattleState:drawHUDs(slide) -- per-pixel tint (grayFill) -- otherwise GREENBAR's red-channel-0 fill -- double-applies and the zone shade shader maps the whole bar to black (#229). local grayFill = self:colorMode() + local showStatus = self:statusHUDVisible() local barData = self.data local fx = self.fx local hudShake = (fx and fx.hudShakeX) or 0 @@ -5468,7 +5481,8 @@ function BattleState:drawHUDs(slide) -- DrawEnemyHUDAndHPBar is called from _InitBattleCommon (core.asm:6763) -- AFTER PrintBeginningBattleText returns, so "Wild X appeared!" shows the -- player's ball row with no enemy HUD beside it (#317) - if self.enemy and not self.showEnemyTrainer and not self.enemySendingOut + if showStatus and self.enemy and not self.showEnemyTrainer + and not self.enemySendingOut and not self:growInScale(self.enemy) and slide == 0 and not self.introBalls and not self.enemy.fainted then -- enemy HUD (DrawEnemyHUDAndHPBar): name row 0, +level (4,1), @@ -5555,7 +5569,7 @@ function BattleState:drawHUDs(slide) self:drawBallRow(self.playerParty or self.game.save.party, 88, 80, 8) end local hidePlayer = self.safari or self.demo - if self.player and not hidePlayer and not self.showPlayerBack + if showStatus and self.player and not hidePlayer and not self.showPlayerBack and slide == 0 then -- player HUD (DrawPlayerHUDAndHPBar): name (10,7), +level -- (14,8), HP bar (10,9), HP numbers row 10, underline row 11 with @@ -5580,6 +5594,7 @@ function BattleState:drawHUDs(slide) end function BattleState:drawTextArea() + if not self:bottomUIVisible() then return end Font.drawBox(0, 12, 20, 6) love.graphics.setColor(0, 0, 0, 1) if self.phase == "messages" diff --git a/src/battle/WideBattle.lua b/src/battle/WideBattle.lua index 72b122c7..87e43bd2 100644 --- a/src/battle/WideBattle.lua +++ b/src/battle/WideBattle.lua @@ -128,7 +128,8 @@ local function drawIntroBalls(battle) end local function drawHUDs(battle, slide) - if battle.enemy and not battle.showEnemyTrainer + local showStatus = battle:statusHUDVisible() + if showStatus and battle.enemy and not battle.showEnemyTrainer and not battle.enemySendingOut and not battle:growInScale(battle.enemy) and slide == 0 and not battle.introBalls and not battle.enemy.fainted then drawStatusPanel(battle, battle.enemy, 0, 0, false) @@ -139,7 +140,7 @@ local function drawHUDs(battle, slide) -- item, not a HUD element (DisplayBattleMenu prints wNumSafariBalls inside -- the battle menu box, engine/battle/core.asm:2074-2079), so it rides in -- drawCommandMenu below like the classic layout's (#540). - if not battle.safari and battle.player and not battle.demo + if showStatus and not battle.safari and battle.player and not battle.demo and not battle.showPlayerBack and slide == 0 then drawStatusPanel(battle, battle.player, 184, 56, true) end @@ -255,6 +256,7 @@ local function drawMoveMenu(battle) end local function drawTextArea(battle) + if not battle:bottomUIVisible() then return end if battle.phase == "messages" and (battle.current or battle.animPlaying) then drawMessageBox(battle) elseif battle.phase == "menu" then diff --git a/src/render/TextBox.lua b/src/render/TextBox.lua index 43696871..290c4ecc 100644 --- a/src/render/TextBox.lua +++ b/src/render/TextBox.lua @@ -7,11 +7,13 @@ -- the text is exhausted and A is pressed, then calls onDone. local Font = require("src.render.Font") +local Runtime = require("src.mods.Runtime") local Theme = require("src.ui.Theme") local Timing = require("src.core.Timing") local TextBox = {} TextBox.__index = TextBox +TextBox.isTextBox = true -- theme-free fallbacks; geometry resolves against Theme.textBox at -- construction time, so an unthemed boot stays byte-identical @@ -344,6 +346,11 @@ function TextBox:update(dt) end function TextBox:draw() + if Runtime.wantsHook("battle.bottom_ui_visible") + and Runtime.call("battle.bottom_ui_visible", function() return true end, + self) == false then + return + end -- The dialogue box belongs against the bottom of the screen, not floating -- in the middle of a zoomed-out letterbox. Declared per frame; the -- renderer blits this region to the screen edge and the rest of the UI diff --git a/tests/engine/wide_battle_shake_bug562.lua b/tests/engine/wide_battle_shake_bug562.lua index 4f367a12..b445748c 100644 --- a/tests/engine/wide_battle_shake_bug562.lua +++ b/tests/engine/wide_battle_shake_bug562.lua @@ -74,6 +74,9 @@ local function battleWith(fx, sprites) growInScale = function() return nil end, drawBallRow = function() end, statusLabel = function() return "" end, + statusHUDVisible = function() return true end, + bottomUIVisible = function() return true end, + caughtMarkerVisible = function() return false end, } end diff --git a/tests/mod_qol_hooks_tests.lua b/tests/mod_qol_hooks_tests.lua index 25a8b9ff..6fd63347 100644 --- a/tests/mod_qol_hooks_tests.lua +++ b/tests/mod_qol_hooks_tests.lua @@ -11,6 +11,7 @@ local Stats = require("src.pokemon.Stats") local Zoom = require("src.render.Zoom") local ListMenu = require("src.ui.ListMenu") local NamingScreen = require("src.ui.NamingScreen") +local TextBox = require("src.render.TextBox") local Player = require("src.world.Player") local Music = require("src.core.Music") @@ -161,6 +162,36 @@ do unsub() end +-- ------- battle UI visibility (companion / alternate renderers) + +do + local BattleState = require("src.battle.BattleState") + check(BattleState.bottomUIVisible({ phase = "menu" }), + "battle bottom UI is visible without a mod") + local seen + local unsub = wrap("battle.bottom_ui_visible", function(_, state) + seen = state + return false + end) + check(not BattleState.bottomUIVisible({ phase = "messages" }), + "a mod can hide the battle text and menu layer") + local text = setmetatable({}, TextBox) + text:draw() + check(seen == text, "pushed text boxes use the same visibility hook") + unsub() + check(BattleState.bottomUIVisible({ phase = "moveSelect" }), + "battle bottom UI returns when the hook is removed") + + check(BattleState.statusHUDVisible({}), + "battle status HUD is visible without a mod") + unsub = wrap("battle.status_hud_visible", function() return false end) + check(not BattleState.statusHUDVisible({}), + "a mod can hide the battle status HUD") + unsub() + check(BattleState.statusHUDVisible({}), + "battle status HUD returns when the hook is removed") +end + -- ------- music.volume (distance / indoor muffling) do From 8906133b934db2bec2947a2ee10503e0dd45e431 Mon Sep 17 00:00:00 2001 From: AverageConsumer <35539970+AverageConsumer@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:49:28 +0200 Subject: [PATCH 53/63] feat(ui): expose semantic PC list kinds --- docs/modding.md | 5 ++++ src/ui/BoxMenu.lua | 4 +++ src/ui/ListMenu.lua | 1 + src/ui/PlayerPC.lua | 3 +++ tests/engine/pc_list_kinds.lua | 48 ++++++++++++++++++++++++++++++++++ 5 files changed, 61 insertions(+) create mode 100644 tests/engine/pc_list_kinds.lua diff --git a/docs/modding.md b/docs/modding.md index 8bbb7dde..91aa01d7 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -290,5 +290,10 @@ update and input ownership, so a mod can mirror a native menu on another display without reimplementing it. The default is `true`. Treat the wrapper as a pure predicate: the renderer may ask it more than once per frame. +Scrollable list states expose `state.kind` for use with this hook. Generic +lists fall back to their title; PC lists use stable, localization-independent +identifiers: `pc_box_withdraw`, `pc_box_deposit`, `pc_box_release`, +`pc_box_change`, `pc_item_withdraw`, `pc_item_deposit`, and `pc_item_toss`. + Developer mode also arms the mod loader's dev tripwire, which flags mods that reach outside their permission set. diff --git a/src/ui/BoxMenu.lua b/src/ui/BoxMenu.lua index 332e90ef..7fed06b7 100644 --- a/src/ui/BoxMenu.lua +++ b/src/ui/BoxMenu.lua @@ -67,6 +67,7 @@ local function withdraw(game) game.stack:push(ListMenu.new(game, Strings("BOX %d (WITHDRAW)", game.save.currentBox), items, { noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570) + kind = "pc_box_withdraw", onChoose = function(item, list) local mon = box[item.value] if not mon then return end @@ -114,6 +115,7 @@ local function deposit(game) end game.stack:push(ListMenu.new(game, "PARTY (DEPOSIT)", items, { noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570) + kind = "pc_box_deposit", onChoose = function(item, list) local mon = game.save.party[item.value] if not mon then return end @@ -160,6 +162,7 @@ local function release(game) game.stack:push(ListMenu.new(game, Strings("BOX %d (RELEASE)", game.save.currentBox), items, { noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570) + kind = "pc_box_release", onChoose = function(_, list) local mon = box[list.index] if not mon then return end @@ -193,6 +196,7 @@ local function changeBox(game) end game.stack:push(ListMenu.new(game, "CHANGE BOX", items, { noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570) + kind = "pc_box_change", onChoose = function(item, list) -- the original asks BEFORE switching ("When you change a #MON -- BOX, data will be saved. OK?"); declining aborts the change diff --git a/src/ui/ListMenu.lua b/src/ui/ListMenu.lua index 5c1f0eaa..b3856a0c 100644 --- a/src/ui/ListMenu.lua +++ b/src/ui/ListMenu.lua @@ -51,6 +51,7 @@ function ListMenu.new(game, title, items, opts) local self = setmetatable({}, ListMenu) self.game = game self.title = title + self.kind = opts.kind or title self.items = items self.index = 1 self.scroll = 0 diff --git a/src/ui/PlayerPC.lua b/src/ui/PlayerPC.lua index 6fdb6b90..09b9e60b 100644 --- a/src/ui/PlayerPC.lua +++ b/src/ui/PlayerPC.lua @@ -72,6 +72,7 @@ end local function withdraw(game) local pc = game.save.pcItems game.stack:push(ListMenu.new(game, "WITHDRAW ITEM", buildItems(game, pc), { + kind = "pc_item_withdraw", messageBox = true, noSound = true, -- PlayerPCMenu holds BIT_NO_MENU_BUTTON_SOUND (#570) onChoose = function(item, list) @@ -110,6 +111,7 @@ local function deposit(game) if not Bag.isBadge(id) then depositable[id] = count end end game.stack:push(ListMenu.new(game, "DEPOSIT ITEM", buildItems(game, depositable), { + kind = "pc_item_deposit", messageBox = true, noSound = true, -- PlayerPCMenu holds BIT_NO_MENU_BUTTON_SOUND (#570) onChoose = function(item, list) @@ -131,6 +133,7 @@ end local function toss(game) local pc = game.save.pcItems game.stack:push(ListMenu.new(game, "TOSS ITEM", buildItems(game, pc), { + kind = "pc_item_toss", messageBox = true, noSound = true, -- PlayerPCMenu holds BIT_NO_MENU_BUTTON_SOUND (#570) onChoose = function(item, list) diff --git a/tests/engine/pc_list_kinds.lua b/tests/engine/pc_list_kinds.lua new file mode 100644 index 00000000..ad0238c3 --- /dev/null +++ b/tests/engine/pc_list_kinds.lua @@ -0,0 +1,48 @@ +-- Stable ListMenu identities for screen.render_visible and companion UIs. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Data = T.fixtures.load() + +local SaveData = require("src.core.SaveData") +local BoxMenu = require("src.ui.BoxMenu") +local ListMenu = require("src.ui.ListMenu") +local PlayerPC = require("src.ui.PlayerPC") +local Boxes = require("src.pokemon.Boxes") + +local pushed +local game = { + data = Data, + save = SaveData.newGame(), + stack = { push = function(_, state) pushed = state end }, +} + +local species = T.fixtures.ids.species[1] +Boxes.ensure(game.save)[1][1] = { species = species, level = 5 } +game.save.party[1] = { species = species, level = 5 } +game.save.party[2] = { species = species, level = 6 } +game.save.pcItems = { FIX_POTION = 2 } +game.save.inventory.FIX_POTION = 2 + +local generic = ListMenu.new(game, "VISIBLE TITLE", {}, {}) +T.eq(generic.kind, "VISIBLE TITLE", "generic lists fall back to their title") +local explicit = ListMenu.new(game, "Localized title", {}, { kind = "stable_id" }) +T.eq(explicit.kind, "stable_id", "explicit list kind is preserved") + +local box = BoxMenu.new(game) +for i, kind in ipairs({ "pc_box_withdraw", "pc_box_deposit", + "pc_box_release", "pc_box_change" }) do + pushed = nil + box.items[i].onSelect() + T.eq(pushed and pushed.kind, kind, kind .. " is stable") +end + +local items = PlayerPC.new(game) +for i, kind in ipairs({ "pc_item_withdraw", "pc_item_deposit", + "pc_item_toss" }) do + pushed = nil + items.items[i].onSelect() + T.eq(pushed and pushed.kind, kind, kind .. " is stable") +end + +T.finish("pc_list_kinds") From d881997acda5643b5c05b8f6c77d544294623312 Mon Sep 17 00:00:00 2001 From: AverageConsumer <35539970+AverageConsumer@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:57:17 +0200 Subject: [PATCH 54/63] feat(mods): add detailed map overview POIs --- docs/modding.md | 10 ++++ src/world/WorldAPI.lua | 68 ++++++++++++++++++------ tests/engine/world_map_overview_test.lua | 50 ++++++++++++++--- 3 files changed, 106 insertions(+), 22 deletions(-) diff --git a/docs/modding.md b/docs/modding.md index 8bbb7dde..3fd5bc51 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -35,6 +35,16 @@ An edited vanilla map becomes a `mod.content.maps:patch` carrying only the fields that moved; a new map becomes a `:register`. See `docs/new-features.md` and the extension's own README. +## Read-only map overviews + +`mod.world:mapOverview()` returns collision `rows` at map-cell resolution, +optional visual `tileRows` at 2x resolution, and optional `tileDetailRows` at +4x resolution. Visual rows contain Game Boy shades from `"0"` (lightest) to +`"3"` (darkest); their matching width and height fields describe the grid. +`markers` contains active `{ kind, x, y }` points in map-cell coordinates for +`warp`, visible `item`, and untaken `hidden` locations. All fields are +read-only snapshots; mods choose which layers to render. + ## Rendering pipelines Most registries hand the engine *content*. `render_pipelines` hands it diff --git a/src/world/WorldAPI.lua b/src/world/WorldAPI.lua index dadf91d6..e0a5d9db 100644 --- a/src/world/WorldAPI.lua +++ b/src/world/WorldAPI.lua @@ -18,6 +18,11 @@ local overviewShades = {} Assets.register(function() overviewShades = {} end) +local function shadeDigit(sum, pixelCount) + return tostring(math.max(0, math.min(3, + math.floor((1 - sum / pixelCount) * 3 + 0.5)))) +end + local function mapTileRows(map) local tileset = map.tileset if not (tileset and tileset.image and tileset.tilesPerRow) then return nil end @@ -28,30 +33,39 @@ local function mapTileRows(map) cached = { pixels = pixels, shades = {} } overviewShades[tileset.image] = cached end - local rows, perRow = {}, tileset.tilesPerRow + local rows, detailRows, perRow = {}, {}, tileset.tilesPerRow for ty = 0, map.heightCells * 2 - 1 do - local row = {} + local row, detailTop, detailBottom = {}, {}, {} for tx = 0, map.widthCells * 2 - 1 do local tile = map:tileAt(tx, ty) - local shade = cached.shades[tile] - if shade == nil then - local sum = 0 + local shades = cached.shades[tile] + if shades == nil then + local sums = { 0, 0, 0, 0 } local ox, oy = (tile % perRow) * 8, math.floor(tile / perRow) * 8 for py = 0, 7 do for px = 0, 7 do local r, g, b = cached.pixels:getPixel(ox + px, oy + py) - sum = sum + r * 0.2126 + g * 0.7152 + b * 0.0722 + local quadrant = math.floor(py / 4) * 2 + math.floor(px / 4) + 1 + sums[quadrant] = sums[quadrant] + + r * 0.2126 + g * 0.7152 + b * 0.0722 end end - shade = tostring(math.max(0, math.min(3, - math.floor((1 - sum / 64) * 3 + 0.5)))) - cached.shades[tile] = shade + shades = { + shadeDigit(sums[1] + sums[2] + sums[3] + sums[4], 64), + shadeDigit(sums[1], 16), shadeDigit(sums[2], 16), + shadeDigit(sums[3], 16), shadeDigit(sums[4], 16), + } + cached.shades[tile] = shades end - row[#row + 1] = shade + row[#row + 1] = shades[1] + detailTop[#detailTop + 1] = shades[2] .. shades[3] + detailBottom[#detailBottom + 1] = shades[4] .. shades[5] end rows[#rows + 1] = table.concat(row) + detailRows[#detailRows + 1] = table.concat(detailTop) + detailRows[#detailRows + 1] = table.concat(detailBottom) end - return rows + return rows, detailRows end function WorldAPI.new(game, modId) @@ -86,10 +100,12 @@ end -- A compact, read-only view of the active map for minimaps and companion UIs. -- `rows` describes collision terrain; optional `tileRows` reduces each real -- 8x8 map tile to its average Game Boy shade ("0" lightest, "3" darkest). +-- `tileDetailRows` preserves one shade per 4x4 quadrant. Markers identify +-- exits and item spots that are still active without exposing world internals. function WorldAPI:mapOverview() local ow = self:overworld() if not ow or not ow.map then return nil, NO_OVERWORLD end - local map, rows = ow.map, {} + local map, rows, markers = ow.map, {}, {} for y = 0, map.heightCells - 1 do local row = {} for x = 0, map.widthCells - 1 do @@ -99,11 +115,33 @@ function WorldAPI:mapOverview() end rows[#rows + 1] = table.concat(row) end - local tileRows = mapTileRows(map) + local def = map.def or {} + for _, warp in ipairs(def.warps or {}) do + markers[#markers + 1] = { kind = "warp", x = warp.x, y = warp.y } + end + local game, save = self.game, self.game.save or {} + for _, obj in ipairs(def.objects or {}) do + if obj.item and obj.item ~= "0" and obj.item ~= 0 + and ow.objectVisible(save, map.id, obj) then + markers[#markers + 1] = { kind = "item", x = obj.x, y = obj.y } + end + end + local hidden = game.data and game.data.field and game.data.field.hiddenItems + for _, item in ipairs(hidden and hidden[map.id] or {}) do + local key = map.id .. "_" .. item.x .. "_" .. item.y + if not (save.hiddenTaken and save.hiddenTaken[key]) then + markers[#markers + 1] = { kind = "hidden", x = item.x, y = item.y } + end + end + local tileRows, tileDetailRows = mapTileRows(map) return { mapId = map.id, width = map.widthCells, - height = map.heightCells, rows = rows, tileRows = tileRows, + height = map.heightCells, rows = rows, markers = markers, + tileRows = tileRows, tileWidth = tileRows and map.widthCells * 2, - tileHeight = tileRows and map.heightCells * 2 } + tileHeight = tileRows and map.heightCells * 2, + tileDetailRows = tileDetailRows, + tileDetailWidth = tileDetailRows and map.widthCells * 4, + tileDetailHeight = tileDetailRows and map.heightCells * 4 } end -- opts.arrive = "fly" | "teleport" picks the arrival FX; anything else diff --git a/tests/engine/world_map_overview_test.lua b/tests/engine/world_map_overview_test.lua index d0a4b825..32c540a2 100644 --- a/tests/engine/world_map_overview_test.lua +++ b/tests/engine/world_map_overview_test.lua @@ -5,8 +5,9 @@ local Assets = require("src.render.Assets") local WorldAPI = require("src.world.WorldAPI") Assets.imageData = function() - return { getPixel = function(_, x) - local shade = x < 8 and 1 or 0 + return { getPixel = function(_, x, y) + local shade = x >= 8 and 0 or ({ 1, 0, 2 / 3, 1 / 3 })[ + math.floor(y / 4) * 2 + math.floor(x / 4) + 1] return shade, shade, shade, 1 end } end @@ -16,15 +17,34 @@ local overview, err = api:mapOverview() T.eq(overview, nil, "map overview is unavailable outside the overworld") T.eq(err, "no overworld", "map overview reports why it is unavailable") -local map = { id = "TEST_MAP", widthCells = 2, heightCells = 2 } +local map = { + id = "TEST_MAP", widthCells = 2, heightCells = 2, + def = { + warps = { { x = 1, y = 0 } }, + objects = { { index = 1, x = 0, y = 1, item = "POTION" } }, + }, +} function map:isWarpTileCell(x, y) return x == 1 and y == 0 end function map:isWaterCell(x, y) return x == 0 and y == 1 end function map:isWalkableCell(x, y) return x == 0 and y == 0 end function map:tileAt(x) return x % 2 end -api = WorldAPI.new({ stack = { states = { - { isOverworld = true, map = map }, -} } }, "tester") +local save = {} +local world = { + isOverworld = true, + map = map, + objectVisible = function(s, mapId, obj) + return not (s.itemsTaken and s.itemsTaken[mapId .. "_obj_" .. obj.index]) + end, +} +local game = { + save = save, + data = { field = { hiddenItems = { + TEST_MAP = { { x = 1, y = 1, item = "NUGGET" } }, + } } }, + stack = { states = { world } }, +} +api = WorldAPI.new(game, "tester") overview = api:mapOverview() T.eq(overview.mapId, "TEST_MAP", "map overview identifies the active map") T.eq(overview.width, 2, "map overview reports its width") @@ -32,11 +52,27 @@ T.eq(overview.height, 2, "map overview reports its height") T.eq(overview.rows[1], ".+", "walkable land and warps are distinct") T.eq(overview.rows[2], "~ ", "water and blocked terrain are distinct") T.eq(overview.tileRows, nil, "tile overview is optional") +T.eq(#overview.markers, 3, "active exits and untaken items are marked") +T.eq(overview.markers[1].kind, "warp", "warp marker is semantic") +T.eq(overview.markers[2].kind, "item", "visible item marker is semantic") +T.eq(overview.markers[3].kind, "hidden", "hidden item marker is semantic") + +save.itemsTaken = { TEST_MAP_obj_1 = true } +save.hiddenTaken = { TEST_MAP_1_1 = true } +overview = api:mapOverview() +T.eq(#overview.markers, 1, "collected items disappear from the overview") +T.eq(overview.markers[1].kind, "warp", "exits remain after collecting items") map.tileset = { image = "test.png", tilesPerRow = 2 } overview = api:mapOverview() T.eq(overview.tileWidth, 4, "tile overview reports its width") T.eq(overview.tileHeight, 4, "tile overview reports its height") -T.eq(overview.tileRows[1], "0303", "tile overview preserves map shading") +T.eq(overview.tileRows[1], "2323", "tile overview preserves average shading") +T.eq(overview.tileDetailWidth, 8, "detail overview reports its width") +T.eq(overview.tileDetailHeight, 8, "detail overview reports its height") +T.eq(overview.tileDetailRows[1], "03330333", + "detail overview preserves top tile quadrants") +T.eq(overview.tileDetailRows[2], "12331233", + "detail overview preserves bottom tile quadrants") T.finish("world map overview") From 0afce96c8d03837e4e28a8dbb64c83bf87540942 Mon Sep 17 00:00:00 2001 From: steve1337 Date: Sat, 8 Aug 2026 14:48:17 +0700 Subject: [PATCH 55/63] [#969] Fixes linux AppImage StartupWMClass matches what SDL reports: AppRun execs bin/love, so the window's WM_CLASS / Wayland app_id is "love" and without this the taskbar entry never resolves back to this desktop file (no name, no icon). --- scripts/build.sh | 43 +++++++++++++++++++++++---- scripts/linux-arm64/build_appimage.sh | 1 + 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/scripts/build.sh b/scripts/build.sh index 4d187f5e..5ae82de6 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -357,13 +357,44 @@ build_linux() { cp "$LOVE_FILE" "$appdir/game.love" - # The .desktop's Icon=love resolves against the AppDir root by basename, - # so drop the stock love.svg and provide our PNG under the same name; - # .DirIcon is what appimaged/thumbnailers show for the file itself. + # Replace LÖVE's own desktop entry rather than keeping it: it says + # Name=LÖVE / Icon=love, which is what appimaged, app menus and file + # managers displayed this image as. Same file as the arm64 build writes, + # so both architectures integrate under the game's name. + local stock_desktop + stock_desktop="$(find "$appdir" -maxdepth 1 -name '*.desktop' | wc -l | tr -d ' ')" + [ "$stock_desktop" = 1 ] \ + || fail "expected exactly one .desktop at the AppDir root, found $stock_desktop" + rm -f "$appdir"/*.desktop + + # share/ carries a second, NoDisplay copy of the same entry plus the .love + # file-type icons and mime rule, all left over from LÖVE's `make install` + # (its Exec even points at the CI runner that built it). Nothing at runtime + # reads them -- only share/lua and share/luajit-* are on LUA_PATH -- but + # AppRun puts $APPDIR/share on XDG_DATA_DIRS, so anyone extracting the image + # gets a "LÖVE" entry back. The arm64 AppDir never had them. + rm -rf "$appdir/share/applications" "$appdir/share/pixmaps" \ + "$appdir/share/mime" "$appdir/share/icons" + + cat > "$appdir/$APP_NAME.desktop" </dev/null - cp "$appdir/love.png" "$appdir/.DirIcon" + rm -f "$appdir/love.svg" "$appdir/love.png" "$appdir/.DirIcon" + sips -z 512 512 "$ICON_SRC" --out "$appdir/$APP_NAME.png" >/dev/null + cp "$appdir/$APP_NAME.png" "$appdir/.DirIcon" sed -i '' 's|^#FUSE_PATH="$APPDIR/my_game.love"$|FUSE_PATH="$APPDIR/game.love"|' "$appdir/AppRun" grep -q '^FUSE_PATH="\$APPDIR/game.love"$' "$appdir/AppRun" \ diff --git a/scripts/linux-arm64/build_appimage.sh b/scripts/linux-arm64/build_appimage.sh index 53fc8fdc..f5bcdbcc 100755 --- a/scripts/linux-arm64/build_appimage.sh +++ b/scripts/linux-arm64/build_appimage.sh @@ -369,6 +369,7 @@ Name=gen1recomp Comment=Pokémon Gen 1 recompilation Exec=$APP_NAME Icon=$APP_NAME +StartupWMClass=love Categories=Game; Terminal=false EOF From e79107c64462cbd4242c5454b67c58a7acc498ef Mon Sep 17 00:00:00 2001 From: david Date: Sun, 9 Aug 2026 20:05:06 -0700 Subject: [PATCH 56/63] Add core.update/core.quit_to_launcher platform lifecycle hooks (RFC 0006) A platform-specific launcher wrapper (a native shell embedding this engine, owning its own UI around the game window) needs to pause the simulation while its own UI is on top, live-reload options it wrote outside any Lua UI, and veto main.lua's "closing the window returns to the Lua launcher" behavior when it owns that job itself. Implementing this by hand-patching main.lua's love.update/love.quit directly ties every such integration to editing the one file every other engine change also touches, guaranteeing merge conflicts. No existing hook covers "should the per-frame simulation step run" or "should closing the window return to the Lua launcher." Adds two generic, additive hooks (src/core/PlatformHooks.lua): core.update and core.quit_to_launcher, replacing what would otherwise be inline main.lua special-casing. Also adds Manifest.force_enable_env, letting a mod that cannot function disabled on the one build where its env var is set (a platform-bridge mod bundled only with that build) re-enable itself regardless of a saved disable. RFC 0006 status: Proposed. --- docs/modding.md | 33 ++++++ docs/rfcs/0006-platform-lifecycle-hooks.md | 107 ++++++++++++++++++ main.lua | 19 +++- src/core/PlatformHooks.lua | 18 +++ src/mods/Loader.lua | 10 ++ src/mods/Manifest.lua | 1 + tests/mod_loader_tests.lua | 32 ++++++ tests/mod_manifest_tests.lua | 2 + .../modkit/cases/platform_lifecycle_hooks.lua | 93 +++++++++++++++ 9 files changed, 312 insertions(+), 3 deletions(-) create mode 100644 docs/rfcs/0006-platform-lifecycle-hooks.md create mode 100644 src/core/PlatformHooks.lua create mode 100644 tests/modkit/cases/platform_lifecycle_hooks.lua diff --git a/docs/modding.md b/docs/modding.md index 8bbb7dde..cab56c47 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -290,5 +290,38 @@ update and input ownership, so a mod can mirror a native menu on another display without reimplementing it. The default is `true`. Treat the wrapper as a pure predicate: the renderer may ask it more than once per frame. +## Process-lifecycle hooks + +These exist so a platform-specific launcher integration (a native shell +that embeds this engine and wraps its window in platform UI) can live +entirely in a mod instead of hand-patching `main.lua`, which every other +engine change also touches. + +`core.update` receives `(next, game, dt)` once per frame from +`love.update`. Vanilla behavior is `game:update(dt)`, unconditionally. A +mod may skip calling `next(game, dt)` to pause the simulation for that +frame (e.g. while a native settings sheet is on top), and may run +additional per-frame polling before or after that call regardless of +whether it calls `next` -- useful for one-shot flags that must be observed +every frame even while paused. + +`core.quit_to_launcher` receives `(next)` once from `love.quit()`. `next()` +returns the engine's own decision for whether closing the window should +return to the Lua launcher instead of exiting; a mod may return `false` +outright, without ever calling `next`, to veto that and let the process +really quit -- for a platform host that owns its own "return to launcher" +UI and would otherwise get looped straight back into the game it just +quit. + +A manifest may also declare `force_enable_env`, an environment variable +name that re-enables the mod regardless of a saved disable in +`options.mods` when that variable is set to `"1"`. This is for a mod that +cannot function disabled on the one build where its env var is set (a +platform-bridge mod bundled only with that build's launcher, for example). + +Neither hook needs a `Runtime.wantsHook` guard before calling it: `Hooks:call` +already falls straight through to the vanilla function when no mod has +wrapped the name, at negligible cost. + Developer mode also arms the mod loader's dev tripwire, which flags mods that reach outside their permission set. diff --git a/docs/rfcs/0006-platform-lifecycle-hooks.md b/docs/rfcs/0006-platform-lifecycle-hooks.md new file mode 100644 index 00000000..9763fed1 --- /dev/null +++ b/docs/rfcs/0006-platform-lifecycle-hooks.md @@ -0,0 +1,107 @@ +# RFC 0006 — Generic process-lifecycle hooks for platform launcher integrations + +## Status + +Proposed. Engine: `PlatformHooks.lua` (new), `main.lua`, `Manifest.lua`, +`Loader.lua`. Tests: `tests/modkit/cases/platform_lifecycle_hooks.lua`, +`tests/mod_loader_tests.lua`, `tests/mod_manifest_tests.lua`. + +## Motivation + +A platform-specific launcher wrapper -- a native shell that embeds this +engine and owns its own UI around the game window (a mobile app shell, +say, presenting its own settings/import/save screens and only handing +control to the LÖVE window once play starts) needs three things no +current hook covers: + +1. Pause the simulation while its own UI is on top of the game window. +2. Live-reload options it wrote from outside any Lua UI. +3. Veto `main.lua`'s "closing the window returns to the Lua launcher" + behavior when the platform shell owns that job itself -- without this, + a shell that re-fronts its own launcher UI on quit gets looped straight + back into `HostShell.restart()`'s in-process reboot instead. + +Implementing this by hand-patching `main.lua`'s `love.update`/`love.quit` +directly ties every such integration to editing the one file every other +engine change also touches, guaranteeing merge conflicts for any second +platform integration (or any unrelated engine PR landing around the same +time). No existing hook covers "should the per-frame simulation step run" +or "should closing the window return to the Lua launcher." + +## The decision it extends + +No prior D-number. Extends the hook-contract section of `docs/modding.md` +alongside `input.step`, `render.hud`, `screen.render_visible`, etc. + +## The exact API delta + +Backward-compatible, additive-only. + +### `core.update` + +New hook, `(game, dt) -> nil` through the public wrapper signature +`(next, game, dt)`, called once per frame from `love.update` via +`src/core/PlatformHooks.lua`'s `PlatformHooks.update(game, dt)`. Vanilla +behavior (used when no mod claims the hook) is `game:update(dt)`, +unconditionally -- identical to `love.update`'s behavior before this hook +existed. A subscriber may skip calling `next(game, dt)` to pause the +simulation for that frame, or do additional per-frame work before/after +calling it regardless of whether it calls `next`. + +### `core.quit_to_launcher` + +New hook, `() -> boolean` through the public wrapper signature `(next)`, +called once from `love.quit()` via +`PlatformHooks.quitToLauncher(vanilla)`. `vanilla` is the pre-existing +non-platform-specific decision (`Game and not Importer and not +quitToLauncher and not scripted and not launchedIntoGame`). A subscriber +may return `false` outright to veto returning to the Lua launcher (without +ever calling `next`, so the vanilla condition is never evaluated), or call +`next()` and return its result to pass the vanilla decision through +unchanged. + +Neither hook is guarded by `Runtime.wantsHook` -- both fire unconditionally +every call, matching the existing `input.step` precedent +(`src/core/Game.lua`), since `Hooks:call` already fast-paths to a bare +`vanilla(...)` call when no mod has wrapped the name. + +### `Manifest.force_enable_env` + +New optional manifest field, a bare env-var name. `Loader:load` re-enables +a mod carrying this field whenever that variable is set to `"1"`, +regardless of a saved disable in `options.mods`. This exists for exactly +the mod class this RFC is for: a platform-bridge mod that ships only with +one build and cannot function disabled there, but must still behave like +every other mod (a manifest opt-in, not an engine special case) on every +build that doesn't set its variable. + +## Migration note for existing mods + +**Nothing.** With no subscriber, `love.update` still calls `Game:update(dt)` +unconditionally every frame and `love.quit()`'s restart-to-launcher +decision is exactly the pre-existing condition -- bit-identical to today's +behavior on every platform where no mod wraps either hook. A manifest with +no `force_enable_env` field behaves exactly as before. + +## Parity tests + +- **No-mod:** `core.update`'s vanilla runs exactly once per call with the + hook chain empty; `core.quit_to_launcher`'s vanilla return value passes + through unchanged. Both hooks are picked up automatically by the + catalog-driven no-mod gate (`tests/engine/gate_hooks.lua`, which scans + for `Runtime.call("...")` call sites), so neither needs a dedicated + no-mod test file. +- **Mod-API:** `tests/modkit/cases/platform_lifecycle_hooks.lua` proves, + through a fixture mod loaded via the public loader (not the engine's + internals), that a subscriber can skip the vanilla update call (pause), + run extra per-frame polling regardless of pause state, and veto the + quit-to-launcher decision without the vanilla condition ever running. +- `tests/mod_loader_tests.lua` and `tests/mod_manifest_tests.lua` cover + `force_enable_env`: a matching env var re-enables a mod saved as + disabled, and an unset one leaves the saved disable alone. + +## Deprecation etiquette + +Nothing deprecated. These are two additive hooks and one additive manifest +field; `main.lua`'s only footprint is one `require` and two call sites +into `src/core/PlatformHooks.lua`. diff --git a/main.lua b/main.lua index cb57bc37..f8c349e8 100644 --- a/main.lua +++ b/main.lua @@ -13,6 +13,7 @@ local editorMode = os.getenv("POKEPORT_EDITOR") == "1" or POKEPORT_EDITOR_MODE = local SwitchDiagnostics = require("src.debug.SwitchDiagnostics") local LaunchOptions = require("src.core.LaunchOptions") local NxDisplay = require("src.core.NxDisplay") +local PlatformHooks = require("src.core.PlatformHooks") -- Lua errors: persist a redacted trace in the save dir and surface a hint. do @@ -426,7 +427,11 @@ function love.update(dt) end return end - Game:update(dt) + -- Mods may wrap or veto the per-frame simulation step (pause it, react + -- to external platform state, etc.) -- see docs/modding.md's core.update + -- entry. Vanilla behavior (used when no mod claims the hook) is just + -- Game:update(dt), unconditionally, exactly as before this hook existed. + PlatformHooks.update(Game, dt) end function love.draw() @@ -819,8 +824,16 @@ function love.quit() or os.getenv("POKEPORT_IMPORT_ONLY") == "1" or os.getenv("POKEPORT_IMPORT_ROM") -- #887: a shortcut session (--game / POKEPORT_GAME) has no launcher to go -- back to and the restart would re-read the shortcut, so it exits instead. - if Game and not Importer and not quitToLauncher and not scripted - and not launchedIntoGame then + -- + -- A platform launcher that owns "return to launcher" itself (see + -- docs/modding.md's core.quit_to_launcher entry) may veto returning to + -- this Lua launcher via that hook. Vanilla behavior (used when no mod + -- claims the hook) is exactly the condition below. + local wouldReturnToLauncher = PlatformHooks.quitToLauncher(function() + return Game and not Importer and not quitToLauncher and not scripted + and not launchedIntoGame + end) + if wouldReturnToLauncher then quitToLauncher = true -- Tell the fresh boot to ignore any boot-straight-into-a-game option this -- once, so the restart really does land in the launcher (#887). A failed diff --git a/src/core/PlatformHooks.lua b/src/core/PlatformHooks.lua new file mode 100644 index 00000000..77bf83f5 --- /dev/null +++ b/src/core/PlatformHooks.lua @@ -0,0 +1,18 @@ +-- Generic process-lifecycle mod hooks so a platform-specific launcher +-- integration (a native shell embedding this engine, e.g. wrapping the +-- window in a platform UI) can live entirely in a mod instead of +-- hand-patching main.lua, which every other engine change also touches. +-- See docs/modding.md's "Process-lifecycle hooks" section. +local ModRuntime = require("src.mods.Runtime") + +local PlatformHooks = {} + +function PlatformHooks.update(game, dt) + return ModRuntime.call("core.update", function(g, d) g:update(d) end, game, dt) +end + +function PlatformHooks.quitToLauncher(vanilla) + return ModRuntime.call("core.quit_to_launcher", vanilla) +end + +return PlatformHooks diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index daca6548..fa6f6a34 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -951,6 +951,16 @@ function Loader:load(data) end end end + -- A manifest may name an env var that force-enables it regardless of a + -- saved disable in options.mods -- generic, not tied to any mod id, for + -- a mod (e.g. a native-launcher bridge) that cannot function disabled on + -- the one build where its env var is set. + for id, mod in pairs(self.mods) do + local envName = mod.manifest.force_enable_env + if envName and os.getenv(envName) == "1" then + self.disabled[id] = nil + end + end for id, mod in pairs(self.mods) do mod.enabled = not self.disabled[id] mod.state = mod.enabled and "pending" or "disabled" diff --git a/src/mods/Manifest.lua b/src/mods/Manifest.lua index 97bed0ac..9d1378a6 100644 --- a/src/mods/Manifest.lua +++ b/src/mods/Manifest.lua @@ -229,6 +229,7 @@ function Manifest.validate(raw, path) permissionSet = permissionSet, options_schema = optionalFile(raw.options_schema, "options_schema"), assets_transforms = optionalFile(raw.assets_transforms, "assets_transforms"), + force_enable_env = optionalFile(raw.force_enable_env, "force_enable_env"), path = path, raw = raw, } diff --git a/tests/mod_loader_tests.lua b/tests/mod_loader_tests.lua index 4b4633a6..e14d2b78 100644 --- a/tests/mod_loader_tests.lua +++ b/tests/mod_loader_tests.lua @@ -271,6 +271,38 @@ do "and nothing is disabled off a failed migration") end +-- ------- force_enable_env: an env var can override a saved disable +-- (src/mods/Loader.lua's enable-resolution block, added for a mod that +-- cannot function disabled on the one build where its env var is set -- +-- e.g. a platform-launcher bridge mod). +do + local forceFiles = { + ["options.lua"] = "return { mods = { forced = false } }", + ["mods/forced/manifest.json"] = + [[{"id":"forced","name":"forced","version":"1.0.0","entry":"main.lua",]] + .. [["force_enable_env":"SOME_TEST_ENV"}]], + ["mods/forced/main.lua"] = "return function(mod) end", + } + + local realGetenv = os.getenv + os.getenv = function(name) + if name == "SOME_TEST_ENV" then return "1" end + return realGetenv(name) + end + local onLoader = Loader.new({ fs = memfs(forceFiles) }) + check(onLoader:load({ pokemon = {} }) == true, + "force_enable_env: load succeeds with the env var set") + check(onLoader.mods.forced.enabled == true, + "a matching force_enable_env re-enables a mod saved as disabled") + os.getenv = realGetenv + + local offLoader = Loader.new({ fs = memfs(forceFiles) }) + check(offLoader:load({ pokemon = {} }) == true, + "force_enable_env: load succeeds with the env var unset") + check(offLoader.mods.forced.enabled == false, + "with the env var unset, the saved disable is left alone") +end + -- leave shared singletons the way we found them for later chained tests local StateStack = require("src.core.StateStack") while StateStack:top() do StateStack:pop() end diff --git a/tests/mod_manifest_tests.lua b/tests/mod_manifest_tests.lua index d6c615e9..569479d2 100644 --- a/tests/mod_manifest_tests.lua +++ b/tests/mod_manifest_tests.lua @@ -105,6 +105,7 @@ local full = Manifest.validate({ api = 2, profile = "overhaul", permissions = { "network" }, dependencies = { "colorlib@^1.2" }, conflicts = { "always_noon" }, options_schema = "options.lua", assets_transforms = "transforms.lua", + force_enable_env = "SOME_ENV", }, "mods/full") check(full.api == 2 and full.profile == "overhaul", "api and profile parse") check(full.affects_link == true, "overhaul defaults to affecting link play") @@ -115,6 +116,7 @@ check(full.conflictSpecs[1].id == "always_noon" and full.conflictSpecs[1].range "a bare conflict entry has no range") check(full.options_schema == "options.lua" and full.assets_transforms == "transforms.lua", "declared files are kept") +check(full.force_enable_env == "SOME_ENV", "force_enable_env is kept") -- ------- github / experimental / incompatible local gh = Manifest.validate({ diff --git a/tests/modkit/cases/platform_lifecycle_hooks.lua b/tests/modkit/cases/platform_lifecycle_hooks.lua new file mode 100644 index 00000000..88d95336 --- /dev/null +++ b/tests/modkit/cases/platform_lifecycle_hooks.lua @@ -0,0 +1,93 @@ +-- core.update / core.quit_to_launcher through the public mod API: a +-- platform-launcher integration can pause the simulation and veto the +-- return-to-launcher decision from a mod, with no main.lua patch. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local PlatformHooks = require("src.core.PlatformHooks") + +local FIXTURE = { + ["mods/fix_platform_bridge/manifest.json"] = [[{ + "id": "fix_platform_bridge", + "name": "Fixture Platform Bridge", + "version": "1.0.0", + "entry": "main.lua", + "api": 2 + }]], + ["mods/fix_platform_bridge/main.lua"] = [[ + local mod = ... + local paused = false + local extraPolls = 0 + mod.hooks:wrap("core.update", function(nextFn, game, dt) + extraPolls = extraPolls + 1 + if not paused then nextFn(game, dt) end + end) + mod.hooks:wrap("core.quit_to_launcher", function(nextFn) + if os.getenv("FIXTURE_VETO_QUIT") == "1" then return false end + return nextFn() + end) + -- test-only knobs, read back through mod.storage-free globals since + -- this fixture never leaves the process + _G.__fixturePlatformBridge = { + setPaused = function(v) paused = v end, + extraPolls = function() return extraPolls end, + } + ]], +} + +-- core.update: a subscriber can pause (skip vanilla) and still run every frame +do + local run = T.sdk.loadMods({ "mods/fix_platform_bridge" }, + { fs = T.sdk.memfs(FIXTURE) }) + T.eq(#run.errors, 0, + "the fixture mod loads clean (" .. tostring(run.errors[1]) .. ")") + + local calls = 0 + local fakeGame = { update = function(self, dt) calls = calls + 1 end } + + _G.__fixturePlatformBridge.setPaused(false) + PlatformHooks.update(fakeGame, 1 / 60) + T.eq(calls, 1, "unpaused: vanilla Game:update runs") + T.eq(_G.__fixturePlatformBridge.extraPolls(), 1, + "the subscriber's wrapper runs every frame") + + _G.__fixturePlatformBridge.setPaused(true) + PlatformHooks.update(fakeGame, 1 / 60) + T.eq(calls, 1, "paused: vanilla Game:update is skipped") + T.eq(_G.__fixturePlatformBridge.extraPolls(), 2, + "the subscriber keeps polling every frame while paused") + + run.release() + _G.__fixturePlatformBridge = nil +end + +-- core.quit_to_launcher: a subscriber can veto without the vanilla +-- condition ever running, or pass it through unchanged +do + local run = T.sdk.loadMods({ "mods/fix_platform_bridge" }, + { fs = T.sdk.memfs(FIXTURE) }) + T.eq(#run.errors, 0, + "the fixture mod loads clean (" .. tostring(run.errors[1]) .. ")") + + local realGetenv = os.getenv + os.getenv = function(name) + if name == "FIXTURE_VETO_QUIT" then return "1" end + return realGetenv(name) + end + local vanillaCalls = 0 + local vetoed = PlatformHooks.quitToLauncher(function() + vanillaCalls = vanillaCalls + 1 + return true + end) + T.eq(vetoed, false, "a subscriber can veto the return-to-launcher decision") + T.eq(vanillaCalls, 0, "a veto never evaluates the vanilla condition") + os.getenv = realGetenv + + local passed = PlatformHooks.quitToLauncher(function() return true end) + T.eq(passed, true, "with no veto, the vanilla decision passes through unchanged") + + run.release() +end + +T.finish("platform_lifecycle_hooks") From 3c71afb9fa614f94813bf5d6189c8da920956fa2 Mon Sep 17 00:00:00 2001 From: david Date: Sun, 9 Aug 2026 20:13:21 -0700 Subject: [PATCH 57/63] Per-category GAME SPEED: overworld/battle/menu + core.logic_speed hook (RFC 0007) GameSpeed is a single fast-forward multiplier applied uniformly to the whole logic clock -- overworld walking, menu navigation and battle turns all scale together. A player who wants 4X battles but 1X overworld (so a cutscene or NPC dialogue doesn't blur past) has no way to get both. Splits save.options.speed into speedOverworld/speedBattle/speedMenu, each cycling independently, with an automatic migration so an existing save's speed choice carries over. Game.speedCategoryInStack resolves which category is active by walking the state stack (the same idiom wideBattleInStack/fillScaleInStack already use), so a menu opened mid- battle inherits battle speed rather than resetting to whatever "menu" defaults to. Adds a new core.logic_speed hook so a mod can read or override the resolved multiplier for the current frame regardless of which category produced it, sitting after the link-play and run-argument overrides so neither is a seam a mod can defeat. RFC 0007 status: Proposed. --- docs/modding.md | 13 ++ docs/rfcs/0007-per-category-game-speed.md | 212 ++++++++++++++++++ src/battle/BattleState.lua | 6 + src/core/Game.lua | 67 +++++- src/core/GameSpeed.lua | 15 ++ src/core/SaveData.lua | 21 +- src/import/LauncherSettings.lua | 20 +- src/ui/OptionsMenu.lua | 29 ++- tests/drivers/online_match_host.lua | 8 +- tests/drivers/online_match_join.lua | 6 +- .../route1_seam_pacing_bug487_test.lua | 4 +- tests/engine/game_speed_categories_test.lua | 196 ++++++++++++++++ tests/mod_ui_tests.lua | 7 +- tests/run_tests.lua | 32 ++- 14 files changed, 602 insertions(+), 34 deletions(-) create mode 100644 docs/rfcs/0007-per-category-game-speed.md create mode 100644 tests/engine/game_speed_categories_test.lua diff --git a/docs/modding.md b/docs/modding.md index 8bbb7dde..54a850f2 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -290,5 +290,18 @@ update and input ownership, so a mod can mirror a native menu on another display without reimplementing it. The default is `true`. Treat the wrapper as a pure predicate: the renderer may ask it more than once per frame. +`core.logic_speed` receives `(next, game)` once per `Game:logicSpeed()` call +(once per frame). Vanilla behavior resolves the per-category GAME SPEED +option (`GameSpeed.CATEGORIES`: overworld/battle/menu) for whichever +category `Game.speedCategoryInStack` says is active right now. A mod may +call `next(game)` and return its result to pass that resolution through, or +return a different number outright to override it for that frame (a bot mod +forcing 1X for one route segment, say, regardless of the category or saved +option). The result is clamped to the nearest valid `GameSpeed.LEVELS` entry +regardless of what a subscriber returns, so a bad value (0, negative, `nil`) +cannot destabilize the fixed-step accumulator. This hook runs *after* link +play's 1X lock and the `--speed`/equivalent run-argument override, both of +which stay unconditional and are never visible to a subscriber. + Developer mode also arms the mod loader's dev tripwire, which flags mods that reach outside their permission set. diff --git a/docs/rfcs/0007-per-category-game-speed.md b/docs/rfcs/0007-per-category-game-speed.md new file mode 100644 index 00000000..eb34b3a4 --- /dev/null +++ b/docs/rfcs/0007-per-category-game-speed.md @@ -0,0 +1,212 @@ +# RFC 0007 — Per-category GAME SPEED and the `core.logic_speed` hook + +## Status + +Proposed. Engine: `GameSpeed.lua`, `Game.lua`, `BattleState.lua`, +`OptionsMenu.lua`, `SaveData.lua`, `LauncherSettings.lua`. Tests: +`tests/engine/game_speed_categories_test.lua`, +`tests/engine/gate_hooks.lua` (structural, automatic), `tests/run_tests.lua` +(OptionsMenu row walk), `tests/mod_ui_tests.lua` (row id/order). + +## Motivation + +`GameSpeed` (`src/core/GameSpeed.lua`) is a single fast-forward multiplier +applied uniformly to the whole logic clock in `Game:logicSpeed()` / +`Game:update()` -- overworld walking, menu navigation and battle turns all +scale together. A player who wants 4X battles (grinding, a long gym fight) +but 1X overworld (so a scripted cutscene or NPC dialogue doesn't blur past) +has no way to get both; the one GAME SPEED row is a single ladder that +applies everywhere at once. + +This needs to be an engine change, not a mod: there is no per-frame seam a +mod can use to swap the multiplier mid-step, and no public event granular +enough to say "which category is active" (`screen.pushed`/`screen.popped` +and `battle.started`/`battle.ended` are the closest and are not enough -- +see Decisions below). The engine's own speed resolution has to become +category-aware. + +A category-aware speed resolution is also the general seam a +platform-launcher integration or automation tool needs to read or override +the effective multiplier for a given frame without caring which category +produced it -- this RFC's `core.logic_speed` hook is written for that case +alongside the player-facing Options rows. + +## The decision it extends + +No prior D-number. Extends `GameSpeed.lua`'s multiplier ladder (unchanged) +with per-category resolution. + +## The exact API delta + +Backward-compatible except for one save-data field rename, which ships with +an automatic migration (see below) -- nothing in the public mod API (hooks, +events, registries, `mod.*`) is renamed or removed. + +### `save.options`: `speed` -> `speedOverworld` / `speedBattle` / `speedMenu` + +`GameSpeed.CATEGORIES = { "overworld", "battle", "menu" }` is the new list +of categories, and `GameSpeed.optionKey(category)` maps a category to its +`save.options` field name (`"overworld"` -> `"speedOverworld"`, etc.). +`GameSpeed.LEVELS`, `.DEFAULT`, `.levelLabel`, `.clamp` and `.cycle` are +unchanged -- the ladder and its behavior are exactly what they were, just +applied three times instead of once. + +`SaveData.defaultOptions()` drops `speed = 1` and adds `speedOverworld = 1`, +`speedBattle = 1`, `speedMenu = 1`. `SaveData.mergeOptions()` migrates: a +loaded options table that still has `speed` and none of the three new +fields seeds all three from it, so an existing player's fast-forward +preference carries over instead of two of the three categories silently +resetting to 1X. `speed` is dropped on the way out (not carried forward), +so a re-save never re-triggers the migration. + +### `Game.speedCategoryInStack(stack)` + +New static helper, `(stack) -> "battle" | "overworld" | "menu"`. Walks the +whole state stack top-down -- the same idiom `Game.wideBattleInStack` and +`Game.fillScaleInStack` already use -- looking for `state.isBattle` (new +marker, `BattleState.isBattle = true`, covering every battle: wild, +trainer, link, safari, the old-man demo) or `state.isOverworld` (existing +marker, `OverworldController`'s `OverworldState.isOverworld = true`). The +first match wins; a state with neither marker (a menu, a text box, a +naming screen, a cutscene) is transparent to the walk and falls through to +whatever is under it. Nothing in the stack matching either falls back to +`"menu"`. + +### `Game:logicSpeed()` / `Game:_resolveLogicSpeed()` + +`Game:_resolveLogicSpeed()` is new: it resolves `Game.speedCategoryInStack` +against the live stack, maps the category to its `save.options` key via +`GameSpeed.optionKey`, and returns `GameSpeed.clamp` of that option (or +`GameSpeed.DEFAULT`). This is the exact category-resolution logic the new +hook wraps. + +`Game:logicSpeed()` keeps its existing early returns -- link play forces +`1`, a run-argument speed override wins over the saved option -- unchanged, +and in the same order, before ever calling the hook. Only once neither +applies does it call the `core.logic_speed` hook. + +### `Game:_cycleSpeed(dir)` + +The keyboard hotkey and the gamepad shoulders/triggers that used to cycle +the single `speed` option now cycle whichever category +`Game.speedCategoryInStack` says is active: pressing the hotkey during a +battle speeds up just the battle, on the overworld just the walk, in a menu +just the menu. This is the natural per-category answer for a control that +used to have one option to reach and now has three -- see Decisions below +for why this reading was chosen over, say, always cycling `overworld`. + +### `core.logic_speed` + +New hook, `(game) -> number` through the public wrapper signature +`(next, game)`, called once per `Game:logicSpeed()` (i.e. once per frame). +Vanilla behavior (used when no mod claims the hook) is +`Game:_resolveLogicSpeed()` -- exactly the category resolution above, +nothing else. A subscriber may call `next(game)` and return its result to +pass the vanilla multiplier through, or return a different number outright +to override it for that frame (e.g. a bot mod forcing `1` during one route +segment regardless of what category or option is active). + +This intentionally sits *after* the link and speed-override checks in +`Game:logicSpeed()`, not around them: link play staying locked to 1X "no +matter what either player set this to" is exactly the invariant that would +break if a mod's hook could override it, and the run-argument override +exists so a bot/screenshot run's speed does not depend on a mod any more +than on the player's saved option. Both stay unconditional early returns a +mod never sees. + +Not guarded by `Runtime.wantsHook`: `Hooks:call` already fast-paths to a +bare `vanilla(...)` call when no mod has wrapped the name, and this hook +fires every frame regardless. + +## Decisions on the issue's open questions + +**1. Overlays on top of another category's state (a party menu, a choice +box, a naming screen opened mid-battle or mid-overworld).** Resolved by +making the category a property of stack *position*, not of the overlay's +own type: an overlay with no `isBattle`/`isOverworld` marker is transparent +to `Game.speedCategoryInStack`'s walk and inherits whatever is under it. A +party swap opened mid-battle reads as `"battle"`; a bag opened while +walking reads as `"overworld"`. This was chosen over giving every UI state +its own fixed category (which would make a fast-forwarded battle visibly +stutter back to 1X every time its party menu opens) because it matches +what the player is actually doing moment to moment, and it reuses a +pattern the codebase already leans on for exactly this "menus opened over +X should behave like X" class of problem (`Game.fillScaleInStack`, +`Game.wideBattleInStack`). + +**2. Cutscenes/scripts.** No fourth category. A scripted sequence runs +through the owning state's own machinery -- the overworld's script runner +or a battle's message queue -- rather than pushing a state of its own, so +it is already covered by decision 1: it inherits whatever category the +state driving it resolves to. A cutscene state that genuinely has nothing +under it (a pre-game intro) falls to `"menu"`, the default for anything +that is not battle or overworld gameplay -- consistent with those being +pre-game presentation, not something a player is likely to want scaled +differently from menu navigation. + +**3. Category granularity (splitting "menu" further).** Deferred. Start +with the three named here; `GameSpeed.CATEGORIES` and `GameSpeed.optionKey` +are written so adding a fourth later (a Pokédex/Bag category, say) is one +entry plus one new `save.options` field, not a resolution-logic rewrite. +No current request motivates it. + +**4. The GAME SPEED hotkey/shoulder buttons, once "the" speed is three +things.** `Game:_cycleSpeed` now cycles whichever category is currently +active (`Game.speedCategoryInStack`), rather than, say, always cycling +`overworld` or requiring a modifier key to pick a category. A single +physical control that means "speed up whatever I'm looking at right now" +is the reading that needs no new UI and matches what a player pressing it +mid-battle almost certainly wants. + +## Migration note for existing mods + +**Nothing**, for the mod API surface: `content.X:register/override/get`, +`events:on`, `hooks:wrap`, `mod.log`, `mod:read`, manifest v1 fields are +untouched, and `GameSpeed.LEVELS`/`.DEFAULT`/`.levelLabel`/`.clamp`/`.cycle` +keep their exact signatures and behavior. + +**One save-data field**, for anything that read `save.options.speed` +directly (not a formal registry/hook surface, but worth naming): it is +superseded by `speedOverworld`/`speedBattle`/`speedMenu`, migrated +automatically on load (see above) so a save from before this RFC keeps its +player's chosen speed. A mod reading `save.options.speed` after this change +sees `nil` (the key is dropped on migration, not kept as a stale alias) and +should read the per-category fields, or hook `core.logic_speed` to observe +the resolved multiplier directly regardless of which category produced it. + +## Parity tests + +- **No-mod:** `core.logic_speed` needs no dedicated no-mod test file -- + `tests/engine/gate_hooks.lua` walks the live hook catalog (which scans + `src` for `Runtime.call("...")` call sites), so the new + `Runtime.call("core.logic_speed", ...)` site is picked up and gated + automatically: vanilla runs exactly once with an empty hook chain, an + unsubscribed-but-live bus passes values and multiple returns through + unchanged, and `Runtime.wantsHook` reads `false`. +- **Mod-API:** `tests/engine/game_speed_categories_test.lua` exercises the + hook through the public API (`Hooks.new()` + `bus:wrap("core.logic_speed", + ...)` + `Runtime.call`, the same idiom other hooks' tests use) -- a + subscriber can read the vanilla category resolution via `next(game)` and + can override it outright -- plus direct coverage of + `Game.speedCategoryInStack` (battle-on-top, overworld-on-top, an overlay + inheriting each, an empty/unmatched stack falling to `"menu"`) and + `Game:logicSpeed()`'s precedence (link forces 1X over all three + categories and over a hook override; the run-argument override wins over + the category resolution). +- `tests/run_tests.lua`'s OptionsMenu walk exercises the three new rows + (OVERWORLD SPEED / BATTLE SPEED / MENU SPEED) cycling and wrapping + independently, in place of the old single GAME SPEED row. +- `tests/mod_ui_tests.lua`'s row-id/order check and hardcoded row-index + activations (MODS, CONTROLS) are updated for the two extra rows. +- A link-play driver should set all three per-category speeds high before + asserting `game:logicSpeed()` reads `1` during a real link session, + proving the lock wins over every category at once, not just whichever + one happens to be active. + +## Deprecation etiquette + +Nothing deprecated in the mod-facing hook/event/registry catalog -- this +adds one hook, additive. The `save.options.speed` field is superseded with +an automatic migration rather than a deprecation notice, since it was never +a registered mod-API surface (no schema entry, no registry) -- the same +treatment any other `save.options` field would get if it needed reshaping. diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 86b2ab18..02d54fc3 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -37,6 +37,12 @@ local BattleState = {} BattleState.__index = BattleState BattleState.isOpaque = true +-- Category identity for per-category GAME SPEED (RFC 0007), the same +-- style OverworldController.isOverworld already uses. Every battle -- +-- wild, trainer, link, safari, the old-man demo -- is this metatable, so +-- Game.speedCategoryInStack needs no special-casing beyond this one flag. +BattleState.isBattle = true + function BattleState:romText(label, fallback, ...) return romText(self.data, label, fallback, ...) end diff --git a/src/core/Game.lua b/src/core/Game.lua index 5226dd16..9cb05fda 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -221,24 +221,44 @@ function Game:step(dt) -- it on its own real-time 60Hz accumulator instead. end +-- The per-category (RFC 0007) save.options multiplier for whichever of +-- "battle"/"overworld"/"menu" Game.speedCategoryInStack says is active +-- right now. This is the "vanilla" the core.logic_speed hook wraps below +-- -- Game:logicSpeed calls it AFTER the link and speedOverride checks, so +-- neither a mod nor the category resolution ever has a seam to defeat them. +function Game:_resolveLogicSpeed() + local GameSpeed = require("src.core.GameSpeed") + local category = Game.speedCategoryInStack(self.stack) + local key = GameSpeed.optionKey(category) + local opts = self.save and self.save.options + return GameSpeed.clamp(opts and opts[key] or GameSpeed.DEFAULT) +end + -- The logic multiplier for this frame. Read live rather than cached so the --- Options row takes effect immediately; speedOverride is the --speed / +-- Options rows take effect immediately; speedOverride is the --speed / -- POKEPORT_SPEED run argument, which wins over the saved option so a bot -- or screenshot run does not depend on whatever the player last chose. function Game:logicSpeed() local GameSpeed = require("src.core.GameSpeed") -- Link play is always 1X on both machines, and this wins over every other - -- source including POKEPORT_SPEED. Fast-forward multiplies the logic - -- clock, so a peer at 10X burned a tournament shot clock ten times faster - -- than the opponent it is racing, and drove its own animation/message - -- queue at a different rate than the peer it is locked to. Nothing about - -- a match should depend on what either player set this to. + -- source including POKEPORT_SPEED and every per-category option. + -- Fast-forward multiplies the logic clock, so a peer at 10X burned a + -- tournament shot clock ten times faster than the opponent it is racing, + -- and drove its own animation/message queue at a different rate than the + -- peer it is locked to. Nothing about a match should depend on what + -- either player set this to -- checked here, before the core.logic_speed + -- hook ever runs, so a mod cannot defeat it either. if self.linkSession or (self.linkNet and not self.linkNet.closed) then return 1 end if self.speedOverride then return GameSpeed.clamp(self.speedOverride) end - local opts = self.save and self.save.options - return GameSpeed.clamp(opts and opts.speed or GameSpeed.DEFAULT) + -- Clamp here too, not just in _resolveLogicSpeed's vanilla path: a mod's + -- core.logic_speed hook can return anything (0, negative, nil, NaN) and + -- Hooks:call only guards against a hook that throws, not one that + -- returns a bad value, so an unclamped result would flow straight into + -- the FixedStep accumulator math below and freeze or destabilize logic. + return GameSpeed.clamp(ModRuntime.call("core.logic_speed", + function(g) return g:_resolveLogicSpeed() end, self)) end function Game:update(dt) @@ -337,6 +357,28 @@ function Game.wideBattleInStack(stack) return nil end +-- Which of "battle"/"overworld"/"menu" per-category GAME SPEED (RFC 0007) +-- applies right now. Whole-stack, the same idiom as fillScaleInStack/ +-- wideBattleInStack above: an overlay with neither marker (PartyMenu, +-- ChoiceBox, a NamingScreen, a text box) is transparent to the walk and +-- inherits whatever is under it, making the category a property of the +-- STACK POSITION the overlay sits over, not of the overlay itself. A +-- scripted sequence (script.started/ended) never pushes a state of its +-- own either -- it runs through the owning overworld/battle state's own +-- script runner or message queue -- so it inherits the same way. Nothing +-- identifying as either (the title screen, credits, an intro cutscene +-- with nothing under it) falls to "menu", the bucket every non-gameplay +-- screen gets; see the RFC's Decisions section for the full reasoning. +function Game.speedCategoryInStack(stack) + local states = stack and stack.states + for i = #(states or {}), 1, -1 do + local state = states[i] + if state and state.isBattle then return "battle" end + if state and state.isOverworld then return "overworld" end + end + return "menu" +end + -- Whether a state on the stack composes its own screen and so wants the -- edge anchors held off (BattleState.holdsUIAnchors). Whole-stack, like -- everything else here: the text box and YES/NO a battle puts up are states @@ -556,8 +598,15 @@ function Game:_cycleSpeed(dir) or ow.engaging or ow.emote)) end if busy then return end + -- Cycles whichever category Game.speedCategoryInStack says is active + -- right now (RFC 0007) -- pressing the hotkey during a battle speeds up + -- just the battle, on the overworld just the walk, in a menu just the + -- menu. A single physical control that means "speed up whatever I'm + -- looking at right now" needs no new UI and matches what a player + -- pressing it mid-battle almost certainly wants. local GameSpeed = require("src.core.GameSpeed") - self.save.options.speed = GameSpeed.cycle(self.save.options.speed, dir) + local key = GameSpeed.optionKey(Game.speedCategoryInStack(self.stack)) + self.save.options[key] = GameSpeed.cycle(self.save.options[key], dir) self:writeOptions() end diff --git a/src/core/GameSpeed.lua b/src/core/GameSpeed.lua index 0a5ddde3..96f08815 100644 --- a/src/core/GameSpeed.lua +++ b/src/core/GameSpeed.lua @@ -51,4 +51,19 @@ function GameSpeed.cycle(v, dir) return levels[nextIdx] end +-- Per-category speed (RFC 0007): overworld walking, battle turns and menu +-- navigation each cycle their own multiplier instead of one global "speed" +-- value. This list is the single source of truth for which categories +-- exist and the order the Options rows/save.options keys follow; +-- Game.lua's stack-walk (Game.speedCategoryInStack) decides WHICH category +-- is active on a given frame, this module only knows the category names. +GameSpeed.CATEGORIES = { "overworld", "battle", "menu" } + +-- the save.options field name a category's multiplier lives under, e.g. +-- "overworld" -> "speedOverworld". Centralized so Game.lua, OptionsMenu.lua, +-- LauncherSettings.lua and the SaveData migration never hand-spell the key. +function GameSpeed.optionKey(category) + return "speed" .. category:sub(1, 1):upper() .. category:sub(2) +end + return GameSpeed diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index 8de64ea8..4b8511bf 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -261,8 +261,12 @@ function SaveData.defaultOptions() -- (the PIKACHU VOL row appears only on Yellow; see Sound.lua) pikaVol = 7, musicFilter = 0, - -- logic fast-forward multiplier; audio is unaffected (GameSpeed.lua) - speed = 1, + -- Per-category logic fast-forward multiplier (RFC 0007); audio is + -- unaffected (GameSpeed.lua). Superseded from a single "speed" field -- + -- mergeOptions migrates an old save's value into all three below. + speedOverworld = 1, + speedBattle = 1, + speedMenu = 1, -- port display options (OptionsMenu / hotkeys 2/3/4/5) colors = "gbc", tilt = 0, @@ -339,6 +343,19 @@ function SaveData.mergeOptions(loaded) for k, v in pairs(loaded) do opts[k] = v end + -- RFC 0007 migration: a save from before per-category GAME SPEED still + -- has a single "speed" and none of the three new fields, so seed all + -- three from it -- an existing player's fast-forward preference + -- carries over instead of two of the three categories silently + -- resetting to 1X. "speed" is dropped on the way out (not kept as a + -- stale alias), so a re-save never re-triggers this migration. + if loaded.speed ~= nil and loaded.speedOverworld == nil + and loaded.speedBattle == nil and loaded.speedMenu == nil then + opts.speedOverworld = loaded.speed + opts.speedBattle = loaded.speed + opts.speedMenu = loaded.speed + end + opts.speed = nil end return opts end diff --git a/src/import/LauncherSettings.lua b/src/import/LauncherSettings.lua index aba010b6..03f1b8cc 100644 --- a/src/import/LauncherSettings.lua +++ b/src/import/LauncherSettings.lua @@ -214,10 +214,24 @@ local function coreRows(opts, hooks) local okSpd, GameSpeed = pcall(require, "src.core.GameSpeed") if okSpd then - add(Strings("GAME SPEED"), - function() return GameSpeed.levelLabel(opts.speed) end, + -- Per-category (RFC 0007): overworld/battle/menu each cycle their own + -- multiplier, mirroring OptionsMenu.lua's three rows. + add(Strings("OVERWORLD SPEED"), + function() return GameSpeed.levelLabel(opts.speedOverworld) end, function(dir) - opts.speed = GameSpeed.cycle(opts.speed, dir) + opts.speedOverworld = GameSpeed.cycle(opts.speedOverworld, dir) + return true + end) + add(Strings("BATTLE SPEED"), + function() return GameSpeed.levelLabel(opts.speedBattle) end, + function(dir) + opts.speedBattle = GameSpeed.cycle(opts.speedBattle, dir) + return true + end) + add(Strings("MENU SPEED"), + function() return GameSpeed.levelLabel(opts.speedMenu) end, + function(dir) + opts.speedMenu = GameSpeed.cycle(opts.speedMenu, dir) return true end) end diff --git a/src/ui/OptionsMenu.lua b/src/ui/OptionsMenu.lua index c44f5718..cca24cd9 100644 --- a/src/ui/OptionsMenu.lua +++ b/src/ui/OptionsMenu.lua @@ -392,14 +392,35 @@ local function buildRows(game) return true end }, -- fast-forward the logic clock only; music and sfx keep their tempo - -- (src/core/GameSpeed.lua), so this is safe to leave on - { id = "speed", label = Strings("GAME SPEED"), + -- (src/core/GameSpeed.lua), so this is safe to leave on. Per-category + -- (RFC 0007): overworld walking, battle turns and menu navigation each + -- cycle their own multiplier -- GameSpeed.CATEGORIES is the single + -- source of truth for which three rows exist. + { id = "speedOverworld", label = Strings("OVERWORLD SPEED"), value = function(g) - return GameSpeed.levelLabel(g.save.options.speed) + return GameSpeed.levelLabel(g.save.options.speedOverworld) end, step = function(g, dir) local o = g.save.options - o.speed = GameSpeed.cycle(o.speed, dir) + o.speedOverworld = GameSpeed.cycle(o.speedOverworld, dir) + return true + end }, + { id = "speedBattle", label = Strings("BATTLE SPEED"), + value = function(g) + return GameSpeed.levelLabel(g.save.options.speedBattle) + end, + step = function(g, dir) + local o = g.save.options + o.speedBattle = GameSpeed.cycle(o.speedBattle, dir) + return true + end }, + { id = "speedMenu", label = Strings("MENU SPEED"), + value = function(g) + return GameSpeed.levelLabel(g.save.options.speedMenu) + end, + step = function(g, dir) + local o = g.save.options + o.speedMenu = GameSpeed.cycle(o.speedMenu, dir) return true end }, -- the manager's discoverable home (18-mod-manager-ux); inert until diff --git a/tests/drivers/online_match_host.lua b/tests/drivers/online_match_host.lua index 8e9be6de..0a6c9123 100644 --- a/tests/drivers/online_match_host.lua +++ b/tests/drivers/online_match_host.lua @@ -55,8 +55,12 @@ return function(game) U.wait(10) -- the GAME SPEED forcing under test: a link session pins the logic clock - -- to 1X no matter what the option or POKEPORT_SPEED says - game.save.options.speed = 10 + -- to 1X no matter what the option or POKEPORT_SPEED says. RFC 0007: set + -- all three per-category speeds high, since the link lock has to win over + -- every one of them, not just whichever category happens to be active. + game.save.options.speedOverworld = 10 + game.save.options.speedBattle = 10 + game.save.options.speedMenu = 10 U.wait(2) log("logicSpeed with GAME SPEED=10 during link:", game:logicSpeed()) diff --git a/tests/drivers/online_match_join.lua b/tests/drivers/online_match_join.lua index 48f8545d..31d3a593 100644 --- a/tests/drivers/online_match_join.lua +++ b/tests/drivers/online_match_join.lua @@ -55,7 +55,11 @@ return function(game) local link = LinkState.new(game) game.stack:push(link) U.wait(10) - game.save.options.speed = 20 + -- RFC 0007: set all three per-category speeds high, since the link lock + -- has to win over every one of them, not just whichever is active. + game.save.options.speedOverworld = 20 + game.save.options.speedBattle = 20 + game.save.options.speedMenu = 20 U.wait(2) log("logicSpeed with GAME SPEED=20 during link:", game:logicSpeed()) diff --git a/tests/drivers/route1_seam_pacing_bug487_test.lua b/tests/drivers/route1_seam_pacing_bug487_test.lua index 981b0e15..48ea282a 100644 --- a/tests/drivers/route1_seam_pacing_bug487_test.lua +++ b/tests/drivers/route1_seam_pacing_bug487_test.lua @@ -46,7 +46,9 @@ return function(game) os.getenv("POKEPORT_DRIVER") == nil) check("no fast-forward multiplier is set", (tonumber(os.getenv("POKEPORT_SPEED")) or 1) == 1 - and (game.save.options.speed or 1) == 1) + and (game.save.options.speedOverworld or 1) == 1 + and (game.save.options.speedBattle or 1) == 1 + and (game.save.options.speedMenu or 1) == 1) check("a window is up to watch (this is a visual call)", love.window ~= nil and love.window.isOpen and love.window.isOpen()) U.log("MAX FPS reads", FrameCap.label(game.save.options.fpsCap), diff --git a/tests/engine/game_speed_categories_test.lua b/tests/engine/game_speed_categories_test.lua new file mode 100644 index 00000000..57bcd86f --- /dev/null +++ b/tests/engine/game_speed_categories_test.lua @@ -0,0 +1,196 @@ +-- Per-category GAME SPEED (RFC 0007): Game.speedCategoryInStack's stack +-- walk, Game:logicSpeed()'s precedence (link lock / run-argument override / +-- the core.logic_speed hook), Game:_cycleSpeed's per-category cycling, and +-- the core.logic_speed hook itself exercised through the public mod API +-- (Hooks.new() + bus:wrap, the same idiom other hooks' tests use -- not a +-- private require). +-- luajit tests/engine/game_speed_categories_test.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local check, eq = T.check, T.eq + +local Game = require("src.core.Game") +local GameSpeed = require("src.core.GameSpeed") +local Hooks = require("src.mods.Hooks") +local Runtime = require("src.mods.Runtime") + +-- ------- Game.speedCategoryInStack: the whole-stack walk + +local function stack(...) return { states = { ... } } end + +local battle = { isBattle = true } +local overworld = { isOverworld = true } +local overlay = {} -- a party menu/choice box/naming screen/text box: no marker + +eq(Game.speedCategoryInStack(nil), "menu", "a nil stack falls to menu") +eq(Game.speedCategoryInStack(stack()), "menu", "an empty stack falls to menu") +eq(Game.speedCategoryInStack(stack(overlay)), "menu", + "an unmarked state alone (title screen, credits, a standalone cutscene) is menu") + +eq(Game.speedCategoryInStack(stack(overworld)), "overworld", + "the overworld alone resolves to overworld") +eq(Game.speedCategoryInStack(stack(battle)), "battle", + "a battle alone resolves to battle") + +eq(Game.speedCategoryInStack(stack(overworld, overlay)), "overworld", + "a menu opened while walking inherits overworld") +eq(Game.speedCategoryInStack(stack(battle, overlay)), "battle", + "a menu opened mid-battle inherits battle, not menu") +eq(Game.speedCategoryInStack(stack(overworld, overlay, overlay)), "overworld", + "the inheritance walk sees through more than one stacked overlay") + +eq(Game.speedCategoryInStack(stack(overworld, battle)), "battle", + "a battle opened over the overworld reads as battle, not the overworld underneath it") +eq(Game.speedCategoryInStack(stack(overworld, battle, overlay)), "battle", + "and a menu on top of THAT still reads as battle") + +-- ------- Game:_resolveLogicSpeed: category -> save.options key -> clamp + +local unpack = table.unpack or unpack + +local function gameWith(states, options) + return setmetatable({ + save = { options = options }, + stack = stack(unpack(states or {})), + }, { __index = Game }) +end + +do + local g = gameWith({ overworld }, + { speedOverworld = 4, speedBattle = 10, speedMenu = 2 }) + eq(g:_resolveLogicSpeed(), 4, "overworld reads speedOverworld") +end +do + local g = gameWith({ battle }, + { speedOverworld = 4, speedBattle = 10, speedMenu = 2 }) + eq(g:_resolveLogicSpeed(), 10, "battle reads speedBattle") +end +do + local g = gameWith({ overlay }, + { speedOverworld = 4, speedBattle = 10, speedMenu = 2 }) + eq(g:_resolveLogicSpeed(), 2, "menu reads speedMenu") +end +do + local g = gameWith({ overworld }, { speedOverworld = 7 }) + eq(g:_resolveLogicSpeed(), GameSpeed.clamp(7), + "an odd value clamps to the nearest LEVELS entry, like the old single field") +end +do + local g = gameWith({ overworld }, nil) + eq(g:_resolveLogicSpeed(), GameSpeed.DEFAULT, + "no save.options at all defaults rather than erroring") +end + +-- ------- Game:logicSpeed(): link and speedOverride win over every category +-- and over a hook override; the hook only ever sees the ordinary case + +do + local g = gameWith({ battle }, { speedBattle = 50 }) + g.linkSession = true + eq(g:logicSpeed(), 1, "an active link session forces 1X even at 50X battle") +end +do + local g = gameWith({ battle }, { speedBattle = 50 }) + g.linkNet = { closed = false } + eq(g:logicSpeed(), 1, "an open linkNet forces 1X the same way") +end +do + local g = gameWith({ battle }, { speedBattle = 50 }) + g.linkNet = { closed = true } + eq(g:logicSpeed(), 50, "a CLOSED linkNet does not force 1X") +end +do + local g = gameWith({ overworld }, { speedOverworld = 4 }) + g.speedOverride = 20 + eq(g:logicSpeed(), 20, + "speedOverride (--speed/POKEPORT_SPEED) wins over the category option") +end +do + local g = gameWith({ battle }, { speedBattle = 50 }) + g.linkSession = true + local bus = Hooks.new() + local savedHooks = Runtime.hooks + Runtime.hooks = bus + local hookRan = false + local unsub = bus:wrap("core.logic_speed", function(next, game) + hookRan = true + return 999 + end) + eq(g:logicSpeed(), 1, + "the link lock wins even over a mod's core.logic_speed override") + check(not hookRan, "...because the hook is never called during link play") + unsub() + Runtime.hooks = savedHooks +end + +-- ------- core.logic_speed: the mod-API seam, driven through Runtime.call/ +-- Hooks.new + bus:wrap like every other hook's public-API test + +local function callLogicSpeed(g) + return Runtime.call("core.logic_speed", + function(gg) return gg:_resolveLogicSpeed() end, g) +end + +do + local g = gameWith({ battle }, { speedBattle = 4 }) + eq(callLogicSpeed(g), 4, + "with no subscriber, the hook returns the vanilla category resolution") +end + +do + local g = gameWith({ overworld }, { speedOverworld = 4 }) + local bus = Hooks.new() + local savedHooks = Runtime.hooks + Runtime.hooks = bus + + local nextArg = nil + local unsub = bus:wrap("core.logic_speed", function(next, game) + nextArg = next(game) + return nextArg + end) + eq(callLogicSpeed(g), 4, + "a subscriber calling next(game) passes the vanilla value through") + eq(nextArg, 4, "...and next(game) itself returned the vanilla resolution") + unsub() + + -- a bot mod forcing 1X for one route segment regardless of the category + unsub = bus:wrap("core.logic_speed", function(next, game) return 1 end) + eq(callLogicSpeed(g), 1, + "a subscriber may override the resolved multiplier outright") + unsub() + + Runtime.hooks = savedHooks +end + +-- ------- Game:_cycleSpeed: cycles whichever category is active, and only it + +do + local writeOptions = { calls = 0 } + local g = gameWith({ battle }, + { speedOverworld = 1, speedBattle = 1, speedMenu = 1 }) + function g:writeOptions() writeOptions.calls = writeOptions.calls + 1 end + g:_cycleSpeed(1) + eq(g.save.options.speedBattle, 2, "cycling during battle bumps speedBattle") + eq(g.save.options.speedOverworld, 1, "...and leaves speedOverworld alone") + eq(g.save.options.speedMenu, 1, "...and leaves speedMenu alone") + eq(writeOptions.calls, 1, "a successful cycle persists the option") +end +do + local g = gameWith({ overworld }, + { speedOverworld = 1, speedBattle = 1, speedMenu = 1 }) + function g:writeOptions() end + g:_cycleSpeed(1) + eq(g.save.options.speedOverworld, 2, "cycling on the overworld bumps speedOverworld") + eq(g.save.options.speedBattle, 1, "...and leaves speedBattle alone") +end +do + local g = gameWith({ overlay }, + { speedOverworld = 1, speedBattle = 1, speedMenu = 1 }) + function g:writeOptions() end + g:_cycleSpeed(1) + eq(g.save.options.speedMenu, 2, "cycling in a menu bumps speedMenu") +end + +T.finish("game_speed_categories") diff --git a/tests/mod_ui_tests.lua b/tests/mod_ui_tests.lua index 1ef1c640..51bde011 100644 --- a/tests/mod_ui_tests.lua +++ b/tests/mod_ui_tests.lua @@ -287,7 +287,8 @@ local WANT_IDS = { "textSpeed", "animations", "battleStyle", "battleLayout", "performance", "colors", "tilt", "gbcfx", "zoom", "voidFill", "videoMode", "faithfulRes", "fpsCap", - "speed", "mods", "controls" } + "speedOverworld", "speedBattle", "speedMenu", + "mods", "controls" } check(#om.rows == #WANT_IDS, "vanilla options row count (plus MODS/CONTROLS)") for i, id in ipairs(WANT_IDS) do check(om.rows[i].id == id, "options row order: " .. id) @@ -383,7 +384,7 @@ check(FrameCap.current == 60, "FrameCap.applyOptions defaults a missing key to 6 -- the MODS row is the manager's discoverable home local mgGame = optGame() om = OptionsMenu.new(mgGame) -om.rows[22].activate(mgGame) +om.rows[24].activate(mgGame) check(getmetatable(mgGame.stack:top()) == ManagerState, "the MODS row opens the manager") check(mgGame.stack:top().screenId == "ManagerState", @@ -393,7 +394,7 @@ check(mgGame.stack:top().screenId == "ManagerState", local BindingsMenu = require("src.ui.BindingsMenu") local cbGame = optGame() om = OptionsMenu.new(cbGame) -om.rows[23].activate(cbGame) +om.rows[25].activate(cbGame) local bm = cbGame.stack:top() check(getmetatable(bm) == BindingsMenu, "the CONTROLS row opens the rebind list") diff --git a/tests/run_tests.lua b/tests/run_tests.lua index 92b21cd2..2915cbbe 100644 --- a/tests/run_tests.lua +++ b/tests/run_tests.lua @@ -2759,29 +2759,43 @@ do -- SPEED below: a full loop of #STEPS presses returns to the 60 default. for _ = 1, #FrameCap.STEPS - 1 do press("a") end eq(og.save.options.fpsCap, 60, "MAX FPS wraps back to 60") + -- RFC 0007: the single GAME SPEED row is now three independent rows, + -- one per GameSpeed.CATEGORIES entry. press("down") - eq(om.index, 21, "cursor reaches GAME SPEED") + eq(om.index, 21, "cursor reaches OVERWORLD SPEED") press("a") - eq(og.save.options.speed, 2, "A cycles GAME SPEED to 2X") + eq(og.save.options.speedOverworld, 2, "A cycles OVERWORLD SPEED to 2X") -- Driven by the level list rather than a literal press count: adding a -- speed (20X went in for the bot runs) otherwise fails this as a wrap -- bug when the cycling is fine and the row is simply one longer. for _ = 1, #GameSpeed.LEVELS - 1 do press("a") end - eq(og.save.options.speed, 1, "GAME SPEED wraps back to NORMAL") + eq(og.save.options.speedOverworld, 1, "OVERWORLD SPEED wraps back to NORMAL") press("down") - eq(om.index, 22, "cursor reaches MODS") + eq(om.index, 22, "cursor reaches BATTLE SPEED") + press("a") + eq(og.save.options.speedBattle, 2, "A cycles BATTLE SPEED to 2X") + for _ = 1, #GameSpeed.LEVELS - 1 do press("a") end + eq(og.save.options.speedBattle, 1, "BATTLE SPEED wraps back to NORMAL") press("down") - eq(om.index, 23, "cursor reaches CONTROLS") + eq(om.index, 23, "cursor reaches MENU SPEED") + press("a") + eq(og.save.options.speedMenu, 2, "A cycles MENU SPEED to 2X") + for _ = 1, #GameSpeed.LEVELS - 1 do press("a") end + eq(og.save.options.speedMenu, 1, "MENU SPEED wraps back to NORMAL") press("down") - eq(om.index, 24, "CANCEL stays the fixed final row") - eq(om.scroll, 19, "CANCEL keeps the last option boxes on screen") + eq(om.index, 24, "cursor reaches MODS") + press("down") + eq(om.index, 25, "cursor reaches CONTROLS") + press("down") + eq(om.index, 26, "CANCEL stays the fixed final row") + eq(om.scroll, 21, "CANCEL keeps the last option boxes on screen") om:draw() -- smoke: scrolled layout draws under the headless stub press("a") check(popped, "A on CANCEL closes the options menu") local om2 = OptionsMenu.new(og) OInput.pressed = { up = true }; om2:update(1 / 60); OInput.pressed = {} - eq(om2.index, 24, "up from the top wraps to CANCEL") - eq(om2.scroll, 19, "wrapping to CANCEL scrolls to the tail") + eq(om2.index, 26, "up from the top wraps to CANCEL") + eq(om2.scroll, 21, "wrapping to CANCEL scrolls to the tail") -- headless-safe: no love.audio, setters only update internal state require("src.core.Music").applyOptions(og.save.options) require("src.core.Sound").applyOptions(og.save.options) From 3c6242717edf54d003ac0d283843edf112e5fd7d Mon Sep 17 00:00:00 2001 From: thibautbus <310327033+thibautbus@users.noreply.github.com> Date: Sun, 9 Aug 2026 23:07:45 +0200 Subject: [PATCH 58/63] Use 1x TTF rasterizer on Android --- src/render/Font.lua | 6 ++++-- tests/engine/ttf_font_mode.lua | 21 +++++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/render/Font.lua b/src/render/Font.lua index 760c91b2..3b59cffb 100644 --- a/src/render/Font.lua +++ b/src/render/Font.lua @@ -137,8 +137,10 @@ function Font.load(data) -- typo'd path degrades exactly like a missing page image does above. if type(def.ttf) == "table" then local file = def.ttf.file or Font.PLAINPIXEL - local ok, obj = pcall(love.graphics.newFont, file, - def.ttf.size or Font.PLAINPIXEL_SIZE, "mono") + local size = def.ttf.size or Font.PLAINPIXEL_SIZE + -- The game renders into a pixel-exact canvas, so keep the TTF rasterizer + -- on that same 1x grid instead of inheriting the window DPI on mobile. + local ok, obj = pcall(love.graphics.newFont, file, size, "mono", 1) if ok and obj then -- nearest keeps the pixel font crisp under the integer UI scale if obj.setFilter then pcall(obj.setFilter, obj, "nearest", "nearest") end diff --git a/tests/engine/ttf_font_mode.lua b/tests/engine/ttf_font_mode.lua index 283f47a6..351f9a89 100644 --- a/tests/engine/ttf_font_mode.lua +++ b/tests/engine/ttf_font_mode.lua @@ -40,6 +40,27 @@ T.eq(Font.advanceOf(0x80), 8, "and the pen stays 8px monospace") -- ------------------------------------------------- the ttf takes over +-- Font rasterizers must use the same 1x pixel grid as PixelCanvas, rather +-- than inheriting Android's window density. Keep this local fake so the +-- contract is testable without requiring a real LÖVE window. +do + local g, oldNewFont = love.graphics, love.graphics.newFont + local args + g.newFont = function(...) + args = { ... } + return oldNewFont(Font.PLAINPIXEL, Font.PLAINPIXEL_SIZE) + end + local loaded, err = pcall(Font.load, { + font = { charmap = CHARMAP, ttf = { file = "custom.ttf", size = 13 } }, + }) + g.newFont = oldNewFont + if not loaded then error(err, 0) end + T.eq(args[1], "custom.ttf", "rasterizer uses the configured file") + T.eq(args[2], 13, "rasterizer uses the configured size") + T.eq(args[3], "mono", "rasterizer keeps the pixel hinting mode") + T.eq(args[4], 1, "rasterizer stays on the pixel canvas scale") +end + Font.load({ font = { charmap = CHARMAP, ttf = {} } }) T.check(Font.ttfActive(), "an empty ttf table loads the bundled font") From 02c4de897da0e8f83100f78f14003334867148b5 Mon Sep 17 00:00:00 2001 From: Shane McGovern Date: Mon, 10 Aug 2026 10:59:06 +0100 Subject: [PATCH 59/63] Fix champion rival walk-out route Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- data/scripts/story.lua | 25 +++++++++------------- tests/drivers/hall_of_fame_bug704_test.lua | 6 ++++-- tests/parity_B.lua | 11 ++++++++++ 3 files changed, 25 insertions(+), 17 deletions(-) diff --git a/data/scripts/story.lua b/data/scripts/story.lua index 3b114d6b..61eb95df 100644 --- a/data/scripts/story.lua +++ b/data/scripts/story.lua @@ -1067,23 +1067,18 @@ local championsRoomRivalScript = { { "show_text", "_ChampionsRoomOakComeWithMeText" }, -- 23 { "move_npc", 2, "up", 2 }, -- 24 OakExitChampionsRoomMovement { "hide_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 25 - -- ChampionsRoomPlayerFollowsOakScript / WalkToHallOfFame_RLEMovement - -- (PAD_UP 4, PAD_LEFT 1): the player walks out after Oak instead of the - -- screen just fading on the spot (#704). The entrance walk leaves the - -- player at (4,3) and both north-wall warps sit on row 0, so the original - -- only ever spends three of those simulated steps -- CheckWarpsNoCollision - -- takes the HALL_OF_FAME warp the moment the walk lands on (4,0) and the - -- trailing UP/LEFT are dropped. Scripted steps ignore collision here just - -- as they do in the original (CollisionCheckOnLand skips its checks while - -- wSimulatedJoypadStatesIndex is non-zero), so stepping through the - -- rival's cell at (4,2) is the ported behavior, not a clip. Re-reported - -- as a clip in #847 and re-checked against home/overworld.asm - -- CollisionCheckOnLand, which is still the authority: do not "fix" it. - { "move_player", "up", 3 }, -- 26 + -- ChampionsRoomPlayerFollowsOakScript / WalkToHallOfFame_RLEMovement. + -- The player walks out after Oak instead of the screen just fading on the + -- spot (#704). Route one tile right before walking north so the player + -- reaches the north-wall HALL_OF_FAME warp without sharing the rival's + -- (4,2) cell. The original simulated movement bypasses entity collision, + -- but this scene should not visibly walk through the defeated rival. + { "move_player", "right", 1 }, -- 26 + { "move_player", "up", 3 }, -- 27 -- hand the induction off to the HALL_OF_FAME room (consumed by its -- onEnter), then warp up into it (destWarp 1 lands at (4,7) facing up) - { "set_field", "pendingHallOfFame", true }, -- 27 - { "warp", "HALL_OF_FAME", 4, 7, "up" }, -- 28 + { "set_field", "pendingHallOfFame", true }, -- 28 + { "warp", "HALL_OF_FAME", 4, 7, "up" }, -- 29 } M.CHAMPIONS_ROOM = { diff --git a/tests/drivers/hall_of_fame_bug704_test.lua b/tests/drivers/hall_of_fame_bug704_test.lua index d46dcabe..69f4a2a4 100644 --- a/tests/drivers/hall_of_fame_bug704_test.lua +++ b/tests/drivers/hall_of_fame_bug704_test.lua @@ -195,7 +195,7 @@ return function(game) ow:queueScript(slice, { npc = rival }) local startY = ow.player.cellY - local minY, walkShot = startY, false + local minY, walkShot, sharedCell = startY, false, false local hof for i = 1, 5000 do local top = game.stack:top() @@ -205,8 +205,9 @@ return function(game) end local w = game.overworld if w and w.map and w.map.id == "CHAMPIONS_ROOM" then - local y = w.player.cellY + local x, y = w.player.cellX, w.player.cellY if y < minY then minY = y end + if x == rival.cellX and y == rival.cellY then sharedCell = true end if y <= 2 and not walkShot then walkShot = U.shot(game, DIR .. "/hof704_follows_oak.png") end @@ -215,6 +216,7 @@ return function(game) end check("the player walked out of the room before the warp (#704)", minY < startY) + check("the player routed around the rival", not sharedCell) check("walk-out screenshot", walkShot) check("the induction started", hof ~= nil) if not hof then diff --git a/tests/parity_B.lua b/tests/parity_B.lua index 3a0291a9..4c5876c0 100644 --- a/tests/parity_B.lua +++ b/tests/parity_B.lua @@ -60,6 +60,17 @@ for _, r in ipairs(rows) do end check(not hasRecord, "CHAMPIONS_ROOM rival script no longer calls record_hall_of_fame") +-- The post-battle walk takes the right-hand detour before heading north, so +-- the player does not visibly pass through the rival at (4,2). +local route = {} +for _, r in ipairs(rows) do + if r[1] == "move_player" then route[#route + 1] = r end +end +eq(route[#route - 1] and route[#route - 1][2], "right", + "walk-out route first moves right around the rival") +eq(route[#route] and route[#route][2], "up", + "walk-out route then heads north to Hall of Fame") + -- (3) Commands.face_player_dir sets the player's facing local Commands = require("src.script.Commands") check(type(Commands.face_player_dir) == "function", "Commands.face_player_dir is a function") From ae1b59ce7feb254dcdda259f1f0b1b0e8f734195 Mon Sep 17 00:00:00 2001 From: Shane McGovern Date: Mon, 10 Aug 2026 12:15:18 +0100 Subject: [PATCH 60/63] fix: restore wild encounter grace period Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/world/OverworldController.lua | 16 ++++++++++++++++ tests/mod_world_tests.lua | 23 +++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 1ac89816..969179c1 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -37,6 +37,10 @@ local mapScripts -- registry of hand-ported map scripts local COMPASS = { up = "north", down = "south", left = "west", right = "east" } local DIRVEC = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } } +-- pokered's wNumberOfNoRandomBattleStepsLeft: three completed steps +-- after a wild battle before another random battle can start. +local WILD_ENCOUNTER_GRACE_STEPS = 3 + -- Fly animation coord paths (engine/overworld/player_animations.asm): -- y/x pairs in GB screen pixels, one pair every 3 frames (DoFlyAnimation's -- Delay3). The port anchors a path on the player's own position instead @@ -224,6 +228,8 @@ function OverworldState:enter(mapId, x, y, facing, opts) -- a fresh entry, or a stale flag can freeze player input forever self.engaging = false self.emote = nil + -- volatile WRAM state in pokered; never serialize across save/load + self.wildEncounterGraceSteps = 0 -- survives save/load: a loaded game may start inside a building whose -- exit mat is a LAST_MAP warp self.lastOutdoor = Game.save.lastOutdoor @@ -3472,6 +3478,10 @@ end function OverworldState:onStepComplete() local p = self.player + local suppressWildEncounter = self.wildEncounterGraceSteps > 0 + if suppressWildEncounter then + self.wildEncounterGraceSteps = self.wildEncounterGraceSteps - 1 + end self.todSteps = (self.todSteps or 0) + 1 -- UpdatePikachuHappinessAndMood rides the step counter (poison.asm) require("src.world.PikachuFollower").onStep(Game.save) @@ -3584,6 +3594,9 @@ function OverworldState:onStepComplete() -- wild encounters in grass, on water while surfing, or -- on indoor -- maps whose tileset is not FOREST -- on EVERY tile -- (wild_encounters.asm: caves, towers, the Mansion, Power Plant) + -- The cooldown is checked after all other step processing so repel and + -- movement systems continue to advance during the protected steps. + if suppressWildEncounter then return end local encDef = Game.data.encounters[self.map.id] local enc local indoor = Game.data.field.indoorEncounters @@ -3970,6 +3983,9 @@ end -- battle is optional; when given, Oak's Lab OPP_RIVAL1 losses skip the -- blackout (pret HandlePlayerBlackOut) so the map script can HealParty. function OverworldState:afterBattle(result, battle) + if battle and battle.kind == "wild" then + self.wildEncounterGraceSteps = WILD_ENCOUNTER_GRACE_STEPS + end local lead = Game.save.party[1] Logger.info("battle over: %s (lead %s %d/%d)", tostring(result), lead and lead.species or "-", lead and lead.hp or 0, diff --git a/tests/mod_world_tests.lua b/tests/mod_world_tests.lua index 60aefb97..08319cb4 100644 --- a/tests/mod_world_tests.lua +++ b/tests/mod_world_tests.lua @@ -823,6 +823,29 @@ do and caught.enemy.mon.species == "TANGELA", "and it is the species the authored encounter table names") + -- Trainer battles do not arm the wild-encounter cooldown. + walker.wildEncounterGraceSteps = 0 + walker:afterBattle("win", { kind = "trainer" }) + check(walker.wildEncounterGraceSteps == 0, + "trainer battles do not start the wild encounter grace period") + + -- pokered grants three completed steps after a wild battle before the + -- next random battle can start (end_of_battle.asm + home/overworld.asm). + local finishedWild = caught + walker:afterBattle("run", finishedWild) + caught = nil + withBuses(function(_, hooks) + hooks:wrap("encounter.roll", function() + return { species = "TANGELA", level = 5 } + end, 0, "grace-period") + for step = 1, 3 do + pcall(walker.onStepComplete, walker) + check(caught == nil, "wild encounter grace period blocks step " .. step) + end + pcall(walker.onStepComplete, walker) + check(caught ~= nil, "wild encounter is eligible on step 4") + end) + -- the same walk with an encounter.roll wrapper never starts a battle withBuses(function(_, hooks) hooks:wrap("encounter.roll", function() return nil end, 0, "nuzlocke") From 12c2677dc2c2aa76561e52302ec2d1a9054f8095 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Mon, 10 Aug 2026 14:00:26 -0400 Subject: [PATCH 61/63] title screen issues, audio issues, and replacing gf c --- src/battle/BattleState.lua | 5 + src/core/ChipSynth.lua | 113 ++++++++++---- src/core/Music.lua | 76 +++++----- src/ui/IntroMovie.lua | 124 +++++++-------- src/ui/TitleState.lua | 215 +++++++++++++++++++++------ src/world/OverworldController.lua | 12 +- tests/drivers/title_cycle_test.lua | 66 ++++++++ tests/drivers/util.lua | 8 +- tests/engine/chip_analog_path.lua | 103 +++++++++++++ tests/engine/drum_envelope_ring.lua | 82 ++++++++++ tests/engine/map_music_fade.lua | 94 ++++++++++++ tests/engine/nx_yellow_boot_test.lua | 3 +- tests/engine/title_mon_cycle.lua | 109 ++++++++++++++ tests/parity_true_color_ui.lua | 1 + 14 files changed, 830 insertions(+), 181 deletions(-) create mode 100644 tests/drivers/title_cycle_test.lua create mode 100644 tests/engine/chip_analog_path.lua create mode 100644 tests/engine/drum_envelope_ring.lua create mode 100644 tests/engine/map_music_fade.lua create mode 100644 tests/engine/title_mon_cycle.lua diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 86b2ab18..453c872a 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -1893,6 +1893,7 @@ function BattleState:update(dt) end self.menuIndex = row * 2 + col + 1 if input:wasPressed("a") then + require("src.core.Sound").play(self.data, "Press_AB") self:safariAction(({ "ball", "bait", "rock", "run" })[self.menuIndex]) end return @@ -1929,6 +1930,7 @@ function BattleState:update(dt) end self.menuIndex = row * 2 + col + 1 if input:wasPressed("a") then + require("src.core.Sound").play(self.data, "Press_AB") local choice = ({ "fight", "pkmn", "item", "run" })[self.menuIndex] if choice == "fight" and self.ghost then self:say(Strings("%s is too\nscared to move!", self.player.name)) @@ -1990,9 +1992,11 @@ function BattleState:update(dt) self.moveSwapIndex = self.moveIndex end elseif input:wasPressed("b") then + require("src.core.Sound").play(self.data, "Press_AB") self.moveSwapIndex = nil self.phase = "menu" elseif input:wasPressed("a") then + require("src.core.Sound").play(self.data, "Press_AB") if self.moveSwapIndex then self:swapMoves(self.moveSwapIndex, self.moveIndex) self.moveSwapIndex = nil @@ -2031,6 +2035,7 @@ function BattleState:update(dt) elseif input:wasPressed("down") then self.mimicIndex = self.mimicIndex < #moves and self.mimicIndex + 1 or 1 elseif input:wasPressed("a") then + require("src.core.Sound").play(self.data, "Press_AB") local pick = moves[self.mimicIndex] local ctx = self.mimicCtx self.mimicMoves, self.mimicCtx = nil, nil diff --git a/src/core/ChipSynth.lua b/src/core/ChipSynth.lua index 1ca31740..01f4f376 100644 --- a/src/core/ChipSynth.lua +++ b/src/core/ChipSynth.lua @@ -111,6 +111,11 @@ local NOISE_DIVISORS = { [4] = 64, [5] = 80, [6] = 96, [7] = 112, } + +local HPF_CHARGE = 0.999958 ^ (GB_CLOCK / SAMPLE_RATE) +local LPF_ALPHA = 0.8 +local MIX_SCALE = 0.5 + local function snapTicks(ticks) return math.floor((ticks * 1470 + 256) / 512) end @@ -269,6 +274,7 @@ function Channel.new(engine, spec, options) phase = 0, noiseLfsr = 0x7FFF, noiseClock = 0, + drumTail = nil, timeTicks = 0, }, Channel) end @@ -527,6 +533,25 @@ local function envelopeVolume(volume, fade, elapsed) return math.min(15, volume + steps) end + +local function envelopeRingSamples(volume, fade) + if not fade or fade <= 0 or not volume or volume <= 0 then return 0 end + return math.floor(volume * (fade / 64) * SAMPLE_RATE + 0.5) +end + +local function extendDrumEnvelope(segments) + local last = segments and segments[#segments] + if not last then return segments end + local ringEnd = last.startSample + envelopeRingSamples(last.volume, last.fade) + if ringEnd > last.endSample then last.endSample = ringEnd end + return segments +end + +local function drumAudioEnd(drum) + local last = drum and drum[#drum] + return last and last.endSample or 0 +end + function Channel:resetNoise() self.noiseLfsr = 0x7FFF self.noiseClock = 0 @@ -566,8 +591,7 @@ function Channel:sampleNoise(parameter) end end end - -- LuaGB: instantaneous inverted LFSR LSB (high when bit0 == 0) - return bit.band(self.noiseLfsr, 1) == 0 and 1 or -1 + return bit.band(self.noiseLfsr, 1) == 0 and 1 or 0 end local function sweepCalculation(register, sweep) @@ -611,21 +635,51 @@ end function Channel:sample() while not self.ended and (not self.event or self.event.sample >= self.event.samples) do + local prev = self.event self.event = self:nextEvent() self.phase = 0 - self:resetNoise() + if self.event and self.event.drum then + self.drumTail = nil + self:resetNoise() + elseif prev and prev.drum and prev.sample < drumAudioEnd(prev.drum) then + -- ..(audio/engine_1.asm ln 197) + self.drumTail = prev + elseif not (self.event and self.event.silence and self.drumTail) then + self.drumTail = nil + self:resetNoise() + end end local event = self.event - if not event then return 0 end + local gain = channelVolume[self.hardware] or 1 + if not event then + local tail = self.drumTail + if not tail then return 0 end + local sampleIndex = tail.sample + tail.sample = sampleIndex + 1 + if sampleIndex >= drumAudioEnd(tail.drum) then + self.drumTail = nil + return 0 + end + return self:sampleDrum(tail, sampleIndex) * gain + end local sampleIndex = event.sample event.elapsed = sampleIndex / SAMPLE_RATE event.sample = sampleIndex + 1 - if event.silence then return 0 end - - local gain = channelVolume[self.hardware] or 1 + if event.silence then + local tail = self.drumTail + if not tail then return 0 end + local tailIndex = tail.sample + tail.sample = tailIndex + 1 + if tailIndex >= drumAudioEnd(tail.drum) then + self.drumTail = nil + return 0 + end + return self:sampleDrum(tail, tailIndex) * gain + end if event.drum then return self:sampleDrum(event, sampleIndex) * gain end + self.drumTail = nil local volume = envelopeVolume( event.volume or 0, event.fade or 0, event.elapsed) if event.noise then @@ -665,7 +719,8 @@ function Channel:sample() -- a def-local program may omit its wave table entirely if not wave then return 0 end local index = math.min(32, math.floor(phase * 32) + 1) - return wave[index] * event.waveLevel * gain + local nibble = math.max(0, math.min(15, wave[index] * 8 + 8)) + return (nibble / 15) * event.waveLevel * gain end local duty = event.duty if type(duty) == "table" then @@ -674,7 +729,7 @@ function Channel:sample() local pattern = WAVE_PATTERN_TABLES[duty or 2] or WAVE_PATTERN_TABLES[2] local step = math.floor(phase * 8) % 8 if pattern[step + 1] == 0 then - return -volume / 15 * gain + return 0 end return volume / 15 * gain end @@ -685,7 +740,7 @@ Engine.__index = Engine function Engine:noiseInstrument(number) -- a def-local drum wins over the ROM engine's table for that id local custom = self.customDrums and self.customDrums[number] - if custom then return custom end + if custom then return extendDrumEnvelope(custom) end local cached = self.noiseInstruments[number] if cached then return cached end @@ -718,6 +773,7 @@ function Engine:noiseInstrument(number) end end + extendDrumEnvelope(segments) self.noiseInstruments[number] = segments return segments end @@ -792,6 +848,8 @@ function Engine.new(data, header, options) customDrums = chip and chip.drums or nil, noiseInstruments = {}, channels = {}, + hpfCap = 0, hpfCapLeft = 0, hpfCapRight = 0, + lpf = 0, lpfLeft = 0, lpfRight = 0, }, Engine) -- header.tempo: the Music_*AlternateTempo override Music.play stamps onto -- a copy of the song def (audio/alternate_tempo.asm) (#847) @@ -826,10 +884,20 @@ function Engine:finished() return true end +local function analogOut(engine, input, hpfField, lpfField) + local cap = engine[hpfField] + local hp = input - cap + engine[hpfField] = input - hp * HPF_CHARGE + local prev = engine[lpfField] + local lp = prev + LPF_ALPHA * (hp - prev) + engine[lpfField] = lp + return math.max(-1, math.min(1, lp * MIX_SCALE)) +end + function Engine:sample() local value = 0 for _, channel in ipairs(self.channels) do value = value + channel:sample() end - return math.max(-1, math.min(1, value / 4)) + return analogOut(self, value, "hpfCap", "lpf") end function Engine:sampleStereo() @@ -840,8 +908,8 @@ function Engine:sampleStereo() if not event or event.panLeft ~= false then left = left + value end if not event or event.panRight ~= false then right = right + value end end - return math.max(-1, math.min(1, left / 4)), - math.max(-1, math.min(1, right / 4)) + return analogOut(self, left, "hpfCapLeft", "lpfLeft"), + analogOut(self, right, "hpfCapRight", "lpfRight") end function Engine:sampleChannel(number) @@ -850,7 +918,7 @@ function Engine:sampleChannel(number) local value = channel:sample() if channel.number == number then selected = value end end - return math.max(-1, math.min(1, selected / 4)) + return analogOut(self, selected, "hpfCap", "lpf") end -- render `samples` frames into a fresh SoundData (mono or stereo). love.sound @@ -870,22 +938,7 @@ local function soundData(engine, samples, channels) return result end --- Render a one-shot effect (SFX/cry) to a two-channel SoundData, or nil when --- it is too short to be audible. The caller wraps it in a static --- love.audio.Source (a playback concern, hence not done here). --- --- The synthesis is mono (one summed value per frame, unlike the music path's --- sampleStereo), but the buffer is written stereo on purpose: OpenAL only --- spatializes 1-channel Sources, and a Source left at the default (0,0,0) --- position, exactly where the listener sits, is rendered as an ambient sound --- spread over EVERY output channel the device exposes at gains that differ --- from the front pair. On an interface with more than two outputs that put --- the SFX on outputs 5+6 as well, while the 2-channel music source --- (ChipAudio.playMusic) stayed on 1+2 (#626). Multi-channel buffers skip --- spatialization entirely and map onto the front pair, so duplicating the --- sample costs one buffer's memory and makes effects route exactly like --- music. Deliberately not sampleStereo: that honors the NR51 panning byte --- and would newly hard-pan any effect whose header issues command 0xEE. + local function renderEffectData(data, header, options) if not header then return nil end options = options or {} diff --git a/src/core/Music.lua b/src/core/Music.lua index 745d45a9..7ac621bc 100644 --- a/src/core/Music.lua +++ b/src/core/Music.lua @@ -1,12 +1,3 @@ --- Music playback supports compact ROM channel programs synthesized live by --- ChipAudio, def-local chip programs (ChipAsm), and file definitions. The --- branch is chosen per song definition, never by a global import flag, so a --- file-backed song and a chip song coexist in one dataset. Songs with split --- files chain def.file into def.loopFile in Music.update(). --- Map themes switch on map change; battles override with the battle --- theme and restore afterwards; riding the bike overrides outdoor map --- themes with the bike song until dismount. - local Logger = require("src.core.Logger") local Runtime = require("src.mods.Runtime") @@ -14,20 +5,10 @@ local Music = {} local VOLUME = 0.7 --- port additions driven by OptionsMenu / save.options: musicVol scales --- VOLUME (0-7 level like the GB's NR50 master volume) and musicFilter --- low-passes the song. Each filter step keeps 40% of the previous --- step's treble (highgain 0.4^level), so 2X/3X are the 1X filter --- applied twice/three times over. local volumeScale = 1 local FILTER_HIGHGAIN = { 0.4, 0.16, 0.064 } local filterLevel = 0 --- Forward-declared here so applyVolume (below) closes over the real playback --- state rather than a nil global: the table literal is assigned further down, --- but a `local state = {}` there would leave every reference above it bound --- to the global `state`. Before this, registering the `music.volume` mod --- hook crashed applyVolume on `state.current` (a nil index). local state local function applyVolume(src) @@ -115,11 +96,6 @@ function Music.duckForFanfare(src) end end --- Overworld themes where the bike can be ridden (outdoor maps plus the --- caves/dungeons where gen-1 allows cycling). Indoor themes such as --- Pokecenter/Gym/SilphCo never get replaced by the bike theme. --- data.audio.outdoorSongs supersedes this; the copy stays as the fallback --- for caches built before the importer wrote the table. local OUTDOOR = { Music_PalletTown = true, Music_Cities1 = true, @@ -218,6 +194,7 @@ end -- the single choke point every song choice passes through, so one hook -- covers map themes, battle themes, jingles and scene music local function selectSong(song, ctx) + if ctx and ctx.selected then return song end if not Runtime.wantsHook("music.select") then return song end return Runtime.call("music.select", function(chosen) return chosen end, song, { reason = ctx and ctx.reason or "direct", @@ -234,17 +211,28 @@ end function Music.play(data, song, loop, ctx) if not song then return end if not love.audio then return end -- headless test stub + ctx = ctx or {} song = selectSong(song, ctx) - -- ctx.tempo is a Music_*AlternateTempo cue (audio/alternate_tempo.asm): - -- the same song restarted with channel 1 re-pointed at a stub whose only - -- difference is its `tempo`, so the same label at a different tempo is a - -- different cue and must not be deduped away (#847) + local tempo = ctx and ctx.tempo or nil -- a hook may silence the cue outright, or swap in a label the dedupe -- below has to compare against if not song or (song == state.current and tempo == state.tempo) then return end local def = songDef(data, song) if not def or state.failed[song] then return end + + if ctx.fade and state.source then + local queued = {} + for key, value in pairs(ctx) do queued[key] = value end + queued.fade, queued.selected = nil, true + local pending = { data = data, song = song, loop = loop, ctx = queued } + if state.fade then + state.fade.pending = pending + else + Music.fadeOut(ctx.fade, pending) + end + return + end if tempo then -- shallow copy: the registry def is shared, only this playback is slowed local slowed = {} @@ -317,24 +305,26 @@ function Music.reload() Music.stop() end --- Ramp the current song's volume to silence, then stop it, mirroring the --- Game Boy's audio fade-out (home/fade_audio.asm FadeOutAudio + --- home/audio.asm's .fadeOut): rAUDVOL's master volume steps 7 -> 0 in --- integer levels, one level every `control` frames, and the music stops --- when it reaches 0. `control` is the wAudioFadeOutControl value the ROM --- writes (oak_speech.asm sets 10 at the shrink beat -> 7*10 = 70 frames --- to silence). Ticked once per frame from Music.update(). -function Music.fadeOut(control) - if not state.source then Music.stop() return end +function Music.fadeOut(control, pending) + if not state.source then + Music.stop() + if pending then + Music.play(pending.data, pending.song, pending.loop, pending.ctx) + end + return + end control = math.max(1, control or 10) state.fade = { control = control, counter = control, -- frames until the next volume step level = 7, -- current master-volume level (rAUDVOL nibble) from = VOLUME * volumeScale, -- level-7 (full) source volume + pending = pending, } end +Music.MAP_FADE = 10 + -- the song a map should currently play, honoring the bike/surf overrides local function effectiveMapSong(data, song) if not song or not outdoorSongs(data)[song] then return song end @@ -351,14 +341,17 @@ end -- overworld map theme; onBike/surfing override outdoor themes with the -- bike/surf songs and restore the map theme when they end -function Music.playMap(data, mapId, onBike, surfing) +function Music.playMap(data, mapId, onBike, surfing, fade) local song = data and data.audio and data.audio.mapSongs and mapId and data.audio.mapSongs[mapId] or nil state.mapSong = song state.onBike = not not onBike state.surfing = not not surfing local play = effectiveMapSong(data, song) - if play then Music.play(data, play, nil, { reason = "map", mapId = mapId }) end + if play then + Music.play(data, play, nil, + { reason = "map", mapId = mapId, fade = fade }) + end end -- toggle the surf override mid-map (starting/ending a surf) @@ -475,8 +468,13 @@ function Music.update(data) f.counter = f.control f.level = f.level - 1 if f.level <= 0 then + -- ..(home/fade_audio.asm ln 36) state.fade = nil + local pending = f.pending Music.stop() + if pending then + Music.play(pending.data, pending.song, pending.loop, pending.ctx) + end return end local vol = f.from * f.level / 7 diff --git a/src/ui/IntroMovie.lua b/src/ui/IntroMovie.lua index 15f14be2..fb66220b 100644 --- a/src/ui/IntroMovie.lua +++ b/src/ui/IntroMovie.lua @@ -1,29 +1,5 @@ --- Boot splash + attract movie, a faithful port of PlayIntro --- (engine/movie/intro.asm) and AnimateShootingStar (engine/movie/splash.asm) --- using the real extracted art (data/generated/field.lua `intro` manifest). --- --- Three frame-counted phases: --- 1. copyright card, 180 frames (intro.asm:311-312). --- 2. shooting star: 64 frames of empty letterbox (intro.asm:323-324), then --- the big star streaks down-left for 40 frames while the studio logo --- sits centered in the letterbox band (the GAME FREAK logo + letter --- row it replaces sat at (72,56)/(40,80); splash.asm:27-60, 211-228), --- the logo flashes 3x10 frames (splash.asm:72-82), 4 waves of small --- stars rain from the logo -- 6x24 frames, +1px every 3 frames, lower --- star blinking (splash.asm:97-146, 163-209) -- and a 40 frame hold --- (intro.asm:329-331). --- 3. the Gengar/Nidorino fight (PlayIntroScene, intro.asm:23-141), played --- from FIGHT_SCRIPT below: Music_IntroBattle starts, Gengar (56x56 BG --- pose from a gengar_N.tilemap, at tile 13,7 = x104,y56) scrolls left --- while Nidorino (48x48 OAM at x-8,y72) walks right, then the scripted --- hip/hop hops, Gengar's raise + slash lunge, Nidorino's dodge leap, --- retreat, crouch and final lunge, ending in a 24-frame fade to white --- (GBFadeOutToWhite, home/fade.asm:26-40). --- --- Any of A/B/START skips the whole movie (CheckForUserInterruption). --- Pops itself and calls onDone() when finished or skipped. All art loads --- through pcall and every missing graphic degrades to a text/rect --- fallback, so the movie stays headless-safe. +-- ..(engine/movie/intro.asm ln 8) +-- ..(engine/movie/splash.asm ln 27) local Font = require("src.render.Font") local Music = require("src.core.Music") @@ -76,15 +52,15 @@ local WAVE_FRAMES = 24 -- 8 substeps x 3 frames (splash.asm:186-209) local WAVES_END = WAVES_START + 6 * WAVE_FRAMES -- 4 waves + 2 empty local SPLASH_FRAMES = WAVES_END + 40 -- ld c, 40 (intro.asm:329-331) --- logo 16x24 at grid (10,9), letters row at grid y=12 cols 6..15 --- (GameFreakLogoOAMData, splash.asm:211-228; screen = grid*8, OAM offsets --- cancel) +-- ..(engine/movie/splash.asm ln 211) local LOGO_X, LOGO_Y = 72, 56 +local TEXT_X, TEXT_Y = 40, 80 + +-- ..(engine/movie/title.asm ln 390) +local COPY_PREFIX = { 0, 1, 2, 1, 3, 1, 4 } +local COPY_NINTENDO = { 5, 6, 7, 8, 9, 10 } +local COPY_CREATURES = { 11, 12, 13, 14, 15, 16, 17, 18 } --- The studio logo (assets/logo/minilogo.png) stands in for both the --- GAME FREAK logo and its letter row, so it gets the whole band between --- the letterbox bars (y 32..112) down to where the star waves spawn --- (y=88): fit it inside this box, centered, aspect preserved. local STUDIO_BOX = { w = 128, h = 52, cx = 80, cy = 60 } -- the 4 waves of small stars: screen X positions, all spawning at y=88 @@ -155,10 +131,19 @@ function IntroMovie.new(game, onDone) self.studio = intro.studio or {} self.skipAll = intro.skip and true or false local function img(e) return tryImage(e and e.path) end - self.copyright = tryImage("assets/generated/title/copyright.png") - -- studio mark replaces the GAME FREAK logo + splash text entirely; the - -- extracted logo stays as the fallback if the asset is missing - self.studioLogo = tryImage(self.studio.logo or "assets/logo/minilogo.png") + local titleCfg = game.data.field and game.data.field.title or {} + self.copyright = img(titleCfg.copyright) + or tryImage("assets/generated/title/copyright.png") + self.copyQuads = {} + if self.copyright then + local iw, ih = self.copyright:getDimensions() + for t = 0, 18 do + self.copyQuads[t] = love.graphics.newQuad(t * 8, 0, 8, 8, iw, ih) + end + end + self.gfInc = img(titleCfg.gamefreakInc) + or tryImage("assets/generated/title/gamefreak_inc.png") + self.studioLogo = tryImage(self.studio.logo) if self.studioLogo then self.studioLogo:setFilter("nearest", "nearest") local iw, ih = self.studioLogo:getDimensions() @@ -306,24 +291,12 @@ function IntroMovie:drawSplash() if self.studioLogo then love.graphics.draw(self.studioLogo, self.studioX, self.studioY, 0, self.studioScale, self.studioScale) - elseif self.logo then - love.graphics.draw(self.logo, LOGO_X, LOGO_Y) + else + if self.logo then love.graphics.draw(self.logo, LOGO_X, LOGO_Y) end + if self.gfText then love.graphics.draw(self.gfText, TEXT_X, TEXT_Y) end end love.graphics.setColor(1, 1, 1, 1) end - if t >= STAR_START and t < FLASH_START then - -- big star: from OAM (160,0) moving +4Y/-4X per frame - -- (GameFreakShootingStarOAMData + .bigStarLoop, splash.asm:32-60) - local n = t - STAR_START + 1 - local sx, sy = 152 - 4 * n, -16 + 4 * n - if self.bigStar then - love.graphics.draw(self.bigStar, sx, sy) - else - love.graphics.setColor(0, 0, 0, 1) - love.graphics.rectangle("fill", sx + 6, sy + 6, 4, 4) - love.graphics.setColor(1, 1, 1, 1) - end - end if t >= WAVES_START then -- small stars: wave w spawns at y=88 every 24 frames, everything falls -- +1px per 3-frame substep until the wave loop ends; the lower star in @@ -351,6 +324,18 @@ function IntroMovie:drawSplash() end end drawBars() + if t >= STAR_START and t < FLASH_START then + -- ..(engine/movie/splash.asm ln 32) + local n = t - STAR_START + 1 + local sx, sy = 152 - 4 * n, -16 + 4 * n + if self.bigStar then + love.graphics.draw(self.bigStar, sx, sy) + else + love.graphics.setColor(0, 0, 0, 1) + love.graphics.rectangle("fill", sx + 6, sy + 6, 4, 4) + love.graphics.setColor(1, 1, 1, 1) + end + end end function IntroMovie:drawFight() @@ -379,17 +364,38 @@ function IntroMovie:drawFight() end end +function IntroMovie:drawCopyright() + if self.studio.card or self.studio.credit then + local card = self.studio.card or "" + local credit = self.studio.credit or "" + love.graphics.setColor(0, 0, 0, 1) + Font.draw(card, (160 - #card * 8) / 2, 64) + Font.draw(credit, (160 - #credit * 8) / 2, 80) + elseif self.copyright and self.gfInc then + local function row(seq, x, y) + for _, t in ipairs(seq) do + love.graphics.draw(self.copyright, self.copyQuads[t], x, y) + x = x + 8 + end + end + for _, y in ipairs({ 56, 72, 88 }) do row(COPY_PREFIX, 16, y) end + row(COPY_NINTENDO, 80, 56) + row(COPY_CREATURES, 80, 72) + love.graphics.draw(self.gfInc, 80, 88) + else + love.graphics.setColor(0, 0, 0, 1) + Font.draw(Strings("Nintendo"), 80, 56) + Font.draw(Strings("Creatures inc."), 80, 72) + Font.draw(Strings("GAME FREAK inc."), 16, 88) + end + love.graphics.setColor(1, 1, 1, 1) +end + function IntroMovie:draw() love.graphics.setColor(1, 1, 1, 1) love.graphics.rectangle("fill", 0, 0, 160, 144) if self.phase == 1 then - -- custom boot card (replaces the Nintendo / GAME FREAK copyright - -- card; no (c) glyph in the charmap, keep it ASCII-safe) - love.graphics.setColor(0, 0, 0, 1) - local credit = self.studio.credit or Strings("bois club") - Font.draw("2026", (160 - 4 * 8) / 2, 48) - Font.draw(credit, (160 - #credit * 8) / 2, 64) - Font.draw("bryanthaboi", (160 - 11 * 8) / 2, 80) + self:drawCopyright() elseif self.phase == 2 then self:drawSplash() else diff --git a/src/ui/TitleState.lua b/src/ui/TitleState.lua index aec5c71a..0c366afd 100644 --- a/src/ui/TitleState.lua +++ b/src/ui/TitleState.lua @@ -103,7 +103,31 @@ local YELLOW_CYCLE_SPECIES = { "JIGGLYPUFF", "MEOWTH", "PSYDUCK", "VULPIX", "ABRA", "GROWLITHE", "CUBONE", "GASTLY", "HITMONLEE", "SNORLAX", "DRAGONITE", } -local CYCLE_FRAMES = 240 -- the original waits ~4s between picks +-- ..(engine/movie/title.asm ln 227) +local HOLD_FRAMES = 200 +local STARTERS = { CHARMANDER = true, SQUIRTLE = true, BULBASAUR = true } + +-- ..(engine/movie/title2.asm ln 13) +local function scrollFrames(steps, offset) + local frames = {} + for _, step in ipairs(steps) do + for _ = 1, step[2] do + frames[#frames + 1] = offset + offset = offset - step[1] + end + end + return frames +end +local OUT_FRAMES = scrollFrames( + { { 1, 2 }, { 2, 2 }, { 3, 2 }, { 4, 2 }, { 5, 2 }, { 6, 2 }, + { 8, 3 }, { 9, 3 } }, 0) +local IN_FRAMES = scrollFrames( + { { 10, 2 }, { 9, 4 }, { 8, 4 }, { 6, 3 }, { 5, 2 }, { 3, 1 }, + { 1, 1 } }, 120) + +-- ..(engine/movie/title2.asm ln 85) +local BALL_FRAMES = { 97, 95, 94, 93, 92, 93, 94, 95, 97, 100 } +local BALL_REST = 100 local function tryImage(path) if not path then return nil end @@ -178,6 +202,27 @@ function TitleState.new(game, opts) or "assets/generated/title/red_version.png") self.player = tryImage(imagePath(title.player) or "assets/generated/title/player.png") + -- ..(engine/movie/title2.asm ln 85) + if self.player then + local pw, ph = self.player:getDimensions() + self.ballQuad = love.graphics.newQuad(0, 16, 8, 8, pw, ph) + self.playerQuads = { + { love.graphics.newQuad(0, 0, pw, 16, pw, ph), 0, 0 }, + { love.graphics.newQuad(8, 16, pw - 8, 8, pw, ph), 8, 16 }, + { love.graphics.newQuad(0, 24, pw, ph - 24, pw, ph), 0, 24 }, + } + end + self.copyImg = tryImage(imagePath(title.copyright) + or "assets/generated/title/copyright.png") + self.copyQuads = {} + if self.copyImg then + local iw, ih = self.copyImg:getDimensions() + for t = 0, 18 do + self.copyQuads[t] = love.graphics.newQuad(t * 8, 0, 8, 8, iw, ih) + end + end + self.gfInc = tryImage(imagePath(title.gamefreakInc) + or "assets/generated/title/gamefreak_inc.png") self.blue = GameVersion.isBlue() self.yellow = GameVersion.isYellow() or title.layout == "yellow_pikachu" @@ -203,7 +248,10 @@ function TitleState.new(game, opts) self.blinkTimer = 0 self.blinkAt = nil else - self.phase = "loop" + -- ..(engine/movie/title.asm ln 28) + self.scy = 0x40 + self.phase = "drop" + self.dropStep, self.dropLeft = 1, nil self.showBubble = true end local defaultCycle = self.yellowLayout and { "PIKACHU" } @@ -216,14 +264,15 @@ function TitleState.new(game, opts) self.cycleIndex = 1 self.timer = 0 self.blink = 0 + self.scrollPhase = "hold" + self.scrollFrame = 1 + self.monOffset = 0 + self.ballY = BALL_REST return self end function TitleState:enter() - -- Yellow defers the title theme until after the logo drop and - -- Pikachu's cry (title.asm plays MUSIC_TITLE_SCREEN only after - -- WaitForSoundToFinish on PikachuCry1) - if self.yellowLayout then return end + if self.phase ~= "loop" then return end self:startMusic() end @@ -240,6 +289,11 @@ end local DROP_STEPS = { { -4, 16 }, { 3, 4 }, { -3, 4 }, { 2, 2 }, { -2, 2 }, { 1, 2 }, { -1, 2 }, } +local SETTLE_FRAMES = 36 + +-- ..(engine/movie/title.asm ln 201) +local RIBBON_FRAMES = {} +for offset = 112, 4, -4 do RIBBON_FRAMES[#RIBBON_FRAMES + 1] = offset end -- the boot cinematic up to the interactive loop; one call per frame function TitleState:updateSequence() @@ -263,12 +317,23 @@ function TitleState:updateSequence() self.dropLeft = nil end elseif self.phase == "settle" then - -- ld c, 36 / DelayFrames, then the whoosh and the bubble self.timer = self.timer + 1 - if self.timer >= 36 then + if self.timer >= SETTLE_FRAMES then Sound.play(data, "Intro_Whoosh") self.showBubble = true - self.phase = "bubble" + self.phase = self.yellowLayout and "bubble" or "ribbon" + self.ribbonOffset = RIBBON_FRAMES[1] + self.timer = 0 + end + elseif self.phase == "ribbon" then + self.timer = self.timer + 1 + local offset = RIBBON_FRAMES[self.timer + 1] + if offset then + self.ribbonOffset = offset + else + self.ribbonOffset = nil + self:startMusic() + self.phase = "loop" self.timer = 0 end elseif self.phase == "bubble" then @@ -432,12 +497,63 @@ function TitleState:openMenu() game.stack:push(menu) end +-- ..(engine/movie/title.asm ln 271) +function TitleState:pickNewMon() + if #self.cycleSpecies < 2 then return end + local pick = self.cycleIndex + while pick == self.cycleIndex do + pick = love.math.random(1, #self.cycleSpecies) + end + self.cycleIndex = pick +end + +function TitleState:setCyclePhase(phase) + self.scrollPhase = phase + self.scrollFrame = 1 + self.timer = 0 + if phase == "in" then + self:pickNewMon() + self.monOffset = IN_FRAMES[1] + elseif phase == "out" then + self.monOffset = OUT_FRAMES[1] + elseif phase == "ball" then + self.ballY = BALL_FRAMES[1] + else + self.monOffset = 0 + end +end + +function TitleState:updateCycle() + local phase = self.scrollPhase + if phase == "hold" then + if self.timer >= HOLD_FRAMES then self:setCyclePhase("out") end + return + end + local frames = phase == "out" and OUT_FRAMES + or phase == "ball" and BALL_FRAMES or IN_FRAMES + self.scrollFrame = self.scrollFrame + 1 + local value = frames[self.scrollFrame] + if value then + if phase == "ball" then self.ballY = value else self.monOffset = value end + return + end + if phase == "out" then + -- ..(engine/movie/title.asm ln 235) + self:setCyclePhase( + STARTERS[self.cycleSpecies[self.cycleIndex]] and "ball" or "in") + elseif phase == "ball" then + self:setCyclePhase("in") + else + self:setCyclePhase("hold") + end +end + function TitleState:update(dt) + if self.phase ~= "loop" then + self:updateSequence() + return + end if self.yellowLayout then - if self.phase ~= "loop" then - self:updateSequence() - return -- input is ignored until the cinematic lands (title.asm) - end self:updateBlink() local input = self.game.input if input:wasPressed("start") or input:wasPressed("a") then @@ -452,21 +568,7 @@ function TitleState:update(dt) end self.timer = self.timer + 1 self.blink = (self.blink + 1) % 60 - if not self.yellowLayout and self.timer >= CYCLE_FRAMES then - self.timer = 0 - -- random pick that never repeats the current one - if #self.cycleSpecies > 1 then - local pick = self.cycleIndex - while pick == self.cycleIndex do - pick = love.math.random(1, #self.cycleSpecies) - end - self.cycleIndex = pick - end - self.slideIn = 20 -- TitleScreenScrollInMon slides the pic in - end - if self.slideIn and self.slideIn > 0 then - self.slideIn = self.slideIn - 1 - end + self:updateCycle() local input = self.game.input if input:wasPressed("start") or input:wasPressed("a") then -- the title mon cries when you leave the title (.finishedWaiting); @@ -478,15 +580,14 @@ function TitleState:update(dt) end end --- The original tilemap (engine/movie/title.asm): logo at tile (2,1), --- the version ribbon at (7,8), Red's title art as OAM at px (82,80), --- the title mon in the 7x7 box at tile (5,10), copyright on row 17. --- Yellow (title_yellow.asm): logo (2,1), speech bubble (6,4), Pikachu --- (4,8) 12x9 -- no version ribbon, no cycling mon, no Red OAM. +-- ..(engine/movie/title.asm ln 28) function TitleState:draw() love.graphics.setColor(1, 1, 1, 1) love.graphics.rectangle("fill", 0, 0, 160, 144) - local scrollY = self.yellowLayout and -(self.scy or 0) or 0 + local scrollY = -(self.scy or 0) + -- ..(engine/movie/title.asm ln 28) + local preRibbon = not self.yellowLayout + and (self.phase == "drop" or self.phase == "settle") if self.logo then love.graphics.draw(self.logo, 16, 8 + scrollY) else @@ -514,27 +615,29 @@ function TitleState:draw() -- Yellow's Version_GFX slot holds a leftover "Blue Version" ribbon -- (pokeyellow gfx/title/blue_version.png, unreferenced by title code); -- the Yellow fallback layout draws no ribbon at all. - if self.version and not self.yellow then + if self.version and not self.yellow and not preRibbon then local iw, ih = self.version:getDimensions() + local rx = self.ribbonOffset or 0 if self.versionFull then -- a continuous ribbon (versionRibbon) centers as one piece - love.graphics.draw(self.version, math.floor((160 - iw) / 2), 64) + love.graphics.draw(self.version, math.floor((160 - iw) / 2) + rx, 64) elseif self.blue then love.graphics.draw(self.version, - love.graphics.newQuad(0, 0, 64, 8, iw, ih), 56, 64) + love.graphics.newQuad(0, 0, 64, 8, iw, ih), 56 + rx, 64) else love.graphics.draw(self.version, - love.graphics.newQuad(0, 0, 16, 8, iw, ih), 56, 64) + love.graphics.newQuad(0, 0, 16, 8, iw, ih), 56 + rx, 64) love.graphics.draw(self.version, - love.graphics.newQuad(40, 0, 40, 8, iw, ih), 80, 64) + love.graphics.newQuad(40, 0, 40, 8, iw, ih), 80 + rx, 64) end end - local sprite, spriteTrueColor = self:currentSprite() + local sprite, spriteTrueColor + if self.scrollPhase ~= "ball" then + sprite, spriteTrueColor = self:currentSprite() + end if sprite then local w, h = sprite:getDimensions() - local slide = (self.slideIn or 0) * 8 -- scroll in from the right - -- bottom-aligned and centered in the (5,10)-(11,16) tile box - local x = 40 + math.floor((56 - w) / 2) + slide + local x = 40 + math.floor((56 - w) / 2) + self.monOffset local y = 136 - h love.graphics.draw(sprite, x, y) -- a full-color mon keeps its own palette through the SGB pass, minus @@ -550,13 +653,33 @@ function TitleState:draw() end end -- Red is OAM in the original: he draws over the mon's box edge - if self.player then + if self.playerQuads then + for _, part in ipairs(self.playerQuads) do + love.graphics.draw(self.player, part[1], 82 + part[2], 80 + part[3]) + end + love.graphics.draw(self.player, self.ballQuad, 82, self.ballY) + elseif self.player then love.graphics.draw(self.player, 82, 80) end end + self:drawCopyright(136 + (preRibbon and 0 or scrollY)) +end + +-- ..(engine/movie/title.asm ln 117) +local COPY_PREFIX = { 0, 1, 2, 1, 3, 1, 4 } + +function TitleState:drawCopyright(y) + if not self.title.copyrightText and self.copyImg and self.gfInc then + local x = 16 + for _, t in ipairs(COPY_PREFIX) do + love.graphics.draw(self.copyImg, self.copyQuads[t], x, y) + x = x + 8 + end + love.graphics.draw(self.gfInc, x, y) + return + end love.graphics.setColor(0, 0, 0, 1) - Font.draw(self.title.copyrightText or Strings("2026 bois club games"), - 1, 136 + scrollY) + Font.draw(self.title.copyrightText or Strings("GAME FREAK inc."), 16, y) love.graphics.setColor(1, 1, 1, 1) end diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 1ac89816..fe8ab8d1 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -451,8 +451,10 @@ function OverworldState:setMap(mapId, x, y, facing, opts) local keepMusic = (opts and opts.keepMusic) or self.keepMusicOnce self.keepMusicOnce = nil if not keepMusic then - require("src.core.Music").playMap(Game.data, mapId, Game.save.onBike, - self.player.surfing) + -- ..(home/overworld.asm ln 2346) + local Music = require("src.core.Music") + Music.playMap(Game.data, mapId, Game.save.onBike, self.player.surfing, + Music.MAP_FADE) end -- forced bike/surf tiles fire the moment the player is placed on the @@ -1093,8 +1095,10 @@ function OverworldState:update(dt) local mapId = self.pendingSeamMusic self.pendingSeamMusic = nil if mapId == self.map.id then - require("src.core.Music").playMap(Game.data, mapId, Game.save.onBike, - self.player.surfing) + -- ..(home/overworld.asm ln 677) + local Music = require("src.core.Music") + Music.playMap(Game.data, mapId, Game.save.onBike, self.player.surfing, + Music.MAP_FADE) end end if stepped and not scripted then diff --git a/tests/drivers/title_cycle_test.lua b/tests/drivers/title_cycle_test.lua new file mode 100644 index 00000000..82f68d23 --- /dev/null +++ b/tests/drivers/title_cycle_test.lua @@ -0,0 +1,66 @@ +-- ..(engine/movie/title.asm ln 28) +-- ..(engine/movie/title2.asm ln 13) +-- POKEPORT_DRIVER=tests/drivers/title_cycle_test.lua POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local shot = 0 + local function grab(tag) + shot = shot + 1 + U.shot(game, ("%s/title_%02d_%s.png"):format(DIR, shot, tag)) + end + + U.wait(30) + grab("copyright") + -- ..(engine/movie/splash.asm ln 230) + local movie = game.stack:top() + while movie.phase ~= 2 or movie.timer < 70 do U.wait(1) end + grab("star_topbar") + while movie.timer < 88 do U.wait(1) end + grab("star_middle") + while movie.timer < 100 do U.wait(1) end + grab("star_lowbar") + while movie.timer < 130 do U.wait(1) end + grab("gamefreak") + + U.tap(game, "start") + U.wait(2) + local title = game.stack:top() + U.log("top is", tostring(title and title.screenId)) + if not (title and title.scrollPhase) then + U.log("no TitleState on top; nothing below can run") + while true do coroutine.yield() end + end + + grab("drop_early") + U.wait(14) + grab("drop_late") + while title.phase == "drop" do U.wait(1) end + grab("settle") + while title.phase == "settle" do U.wait(1) end + grab("ribbon_start") + U.wait(10) + U.shot(game, DIR .. "/title_ribbon_mid.png") + while title.phase ~= "loop" do U.wait(1) end + grab("landed") + + title.cycleIndex = 1 + title.scrollPhase, title.scrollFrame, title.timer = "hold", 1, 0 + title.monOffset = 0 + while title.scrollPhase == "hold" do U.wait(1) end + grab("out_a") + U.wait(6) + grab("out_b") + while title.scrollPhase == "out" do U.wait(1) end + U.log("after the scroll out the phase is", title.scrollPhase) + for _ = 1, 5 do + grab("ball") + U.wait(1) + end + while title.scrollPhase == "ball" do U.wait(1) end + grab("in") + U.wait(30) + grab("next_mon") + U.log("captured", DIR) + while true do coroutine.yield() end +end diff --git a/tests/drivers/util.lua b/tests/drivers/util.lua index 52a34344..33983c8a 100644 --- a/tests/drivers/util.lua +++ b/tests/drivers/util.lua @@ -65,8 +65,12 @@ function U.newGame(game) U.wait(5) U.tap(game, "start") -- skip intro movie U.wait(10) - U.tap(game, "a") -- title -> menu - U.wait(5) + local title = game.stack:top() + for _ = 1, 60 do + U.tap(game, "a") + U.wait(5) + if game.stack:top() ~= title then break end + end -- menu: CONTINUE may or may not exist; NEW GAME is first without a save U.tap(game, "a") U.wait(10) diff --git a/tests/engine/chip_analog_path.lua b/tests/engine/chip_analog_path.lua new file mode 100644 index 00000000..e974c266 --- /dev/null +++ b/tests/engine/chip_analog_path.lua @@ -0,0 +1,103 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check = T.check +local eq = T.eq + +love = require("tests.love_stub") + +local ChipAsm = require("src.audio.ChipAsm") +local ChipSynth = require("src.core.ChipSynth") + +local data = { audio = {} } + +local function pulseSong() + return ChipAsm.song{ + channels = { { hw = 1, program = { + { duty = 2 }, + { notetype = { speed = 12, volume = 15, fade = 0 } }, + { octave = 4 }, + { note = "C", len = 15 }, + } } }, + } +end + +local function noiseSong() + return ChipAsm.sfx{ + channels = { { hw = 4, program = { + { noiseNote = { len = 8, volume = 15, fade = 1, parameter = 0x34 } }, + } } }, + } +end + +do + local engine = ChipSynth.newEngine(data, pulseSong(), { allowLoops = false }) + local sawNeg, sawPos = false, false + for _ = 1, 512 do + local v = engine.channels[1]:sample() + if v < -1e-12 then sawNeg = true end + if v > 1e-12 then sawPos = true end + end + check(sawPos and not sawNeg, + "pulse DAC is unipolar (high = volume, low = 0)") +end + +do + local engine = ChipSynth.newEngine(data, noiseSong(), { + sfx = true, allowLoops = false, + }) + local sawNeg, sawPos = false, false + for _ = 1, 2048 do + local v = engine.channels[1]:sample() + if v < -1e-12 then sawNeg = true end + if v > 1e-12 then sawPos = true end + end + check(sawPos and not sawNeg, + "noise DAC is unipolar (LFSR high = volume, low = 0)") +end + +local function crossingsAndSign(engine, frames) + local count, prev = 0, nil + local sawNeg, sawPos = false, false + for _ = 1, frames do + local sample = engine:sample() + if sample < -1e-12 then sawNeg = true end + if sample > 1e-12 then sawPos = true end + if prev and prev * sample < 0 then count = count + 1 end + prev = sample + end + return count, sawNeg, sawPos +end + +do + local engine = ChipSynth.newEngine(data, pulseSong(), { allowLoops = false }) + local count, sawNeg, sawPos = crossingsAndSign(engine, 4000) + check(sawNeg and sawPos, "HPF centers a unipolar pulse around analog 0") + check(count > 20, ("HPF'd pulse crosses zero (%d crossings)"):format(count)) +end + +do + local engine = ChipSynth.newEngine(data, noiseSong(), { + sfx = true, allowLoops = false, + }) + local count, sawNeg, sawPos = crossingsAndSign(engine, 8000) + check(sawNeg and sawPos, "HPF centers noise / drums around analog 0") + check(count > 50, ("HPF'd noise crosses zero (%d crossings)"):format(count)) +end + +do + local song = pulseSong() + ChipSynth.setChannelVolumes({ 1, 1, 1, 1 }) + local a = ChipSynth.newEngine(data, song, { allowLoops = false }) + local base = a.channels[1]:sample() + ChipSynth.setChannelVolume(1, 0.25) + local b = ChipSynth.newEngine(data, song, { allowLoops = false }) + local quarter = b.channels[1]:sample() + ChipSynth.setChannelVolumes({ 1, 1, 1, 1 }) + check(base > 0 and math.abs(quarter - base * 0.25) < 1e-9, + "channelVolume still quarters the unipolar DAC level") +end + +eq(type(ChipSynth.newEngine), "function", "engine factory still exported") + +T.finish("chip analog path") diff --git a/tests/engine/drum_envelope_ring.lua b/tests/engine/drum_envelope_ring.lua new file mode 100644 index 00000000..120f55f6 --- /dev/null +++ b/tests/engine/drum_envelope_ring.lua @@ -0,0 +1,82 @@ +-- ..(audio/engine_1.asm ln 197) +-- ..(audio/sfx/noise_instrument01_1.asm ln 1) +-- luajit tests/engine/drum_envelope_ring.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check = T.check + +love = require("tests.love_stub") + +local ChipAsm = require("src.audio.ChipAsm") +local ChipSynth = require("src.core.ChipSynth") + +local snare = ChipAsm.song{ + channels = { + { hw = 4, program = { + { notetype = { speed = 12 } }, + { drum = 1, len = 2 }, + { rest = 16 }, + } }, + }, + drums = { + [1] = { + { len = 1, volume = 12, fade = 1, parameter = 0x33 }, + }, + }, +} + +local engine = ChipSynth.newEngine({ audio = {} }, snare, { allowLoops = false }) +local segs = engine:noiseInstrument(1) +local last = segs[#segs] +local ringMs = (last.endSample - last.startSample) / ChipSynth.SAMPLE_RATE * 1000 +check(ringMs > 150 and ringMs < 220, + ("snare instrument rings ~188ms, not the 17ms note (%0.1fms)"):format(ringMs)) + +local energyEarly, energyLate, energyEnd = 0, 0, 0 +local total = math.floor(ChipSynth.SAMPLE_RATE * 0.25) +for i = 1, total do + local s = engine:sample() + local e = s * s + local t = i / ChipSynth.SAMPLE_RATE + if t < 0.02 then + energyEarly = energyEarly + e + elseif t > 0.05 and t < 0.12 then + energyLate = energyLate + e + elseif t > 0.20 then + energyEnd = energyEnd + e + end +end +check(energyEarly > 0, "snare attack is audible") +check(energyLate > energyEarly * 0.05, + ("snare body still sounds at 50-120ms (early=%.4f late=%.4f)") + :format(energyEarly, energyLate)) +check(energyEnd < energyLate * 0.1, + "snare has decayed by 200ms") + +local hats = ChipAsm.song{ + channels = { + { hw = 4, program = { + { notetype = { speed = 12 } }, + { drum = 1, len = 2 }, + { drum = 1, len = 2 }, + } }, + }, + drums = { + [1] = { + { len = 1, volume = 8, fade = 1, parameter = 0x10 }, + }, + }, +} +local hatEngine = ChipSynth.newEngine({ audio = {} }, hats, { allowLoops = false }) +local hits = 0 +local prev = 0 +for _ = 1, math.floor(ChipSynth.SAMPLE_RATE * 0.3) do + local s = math.abs(hatEngine:sample()) + if prev < 0.01 and s >= 0.01 then hits = hits + 1 end + prev = s +end +check(hits >= 2, ("two rapid drum_notes both trigger (%d onsets)"):format(hits)) + +T.finish("drum envelope ring") diff --git a/tests/engine/map_music_fade.lua b/tests/engine/map_music_fade.lua new file mode 100644 index 00000000..0ab6bab2 --- /dev/null +++ b/tests/engine/map_music_fade.lua @@ -0,0 +1,94 @@ +-- ..(home/audio.asm ln 9) +-- ..(home/fade_audio.asm ln 36) +-- luajit tests/engine/map_music_fade.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check = T.check +local eq = T.eq + +love = require("tests.love_stub") + +local Source = {} +Source.__index = Source +function Source:play() self.playing = true end +function Source:stop() self.playing = false end +function Source:pause() self.playing = false end +function Source:isPlaying() return self.playing end +function Source:setLooping() end +function Source:setVolume(v) self.volume = v end +function Source:setPitch() end +function Source:setFilter() end +function Source:getDuration() return 1 end + +local made = {} -- file -> the last source built for it +love.audio = { + newSource = function(file, mode) + made[file] = setmetatable({ file = file, mode = mode }, Source) + return made[file] + end, +} + +local Music = require("src.core.Music") + +local data = { audio = { + songs = { + Music_Pallet = { file = "pallet.wav" }, + Music_Routes1 = { file = "routes1.wav" }, + Music_Pewter = { file = "pewter.wav" }, + }, + mapSongs = { + PALLET_TOWN = "Music_Pallet", + ROUTE_1 = "Music_Routes1", + PEWTER_CITY = "Music_Pewter", + }, +} } + +local function frames(n) + for _ = 1, n do Music.update(data) end +end + +local function playing() + for file, src in pairs(made) do + if src.playing then return file end + end + return "(silence)" +end + +local FADE = 7 * Music.MAP_FADE -- 7 volume levels x 10 frames + +Music.stop() +Music.playMap(data, "PALLET_TOWN", false, false, Music.MAP_FADE) +eq(playing(), "pallet.wav", "the first map after boot starts at once") + +local fullVolume = made["pallet.wav"].volume + +Music.playMap(data, "ROUTE_1", false, false, Music.MAP_FADE) +eq(playing(), "pallet.wav", "the new theme waits while the old one fades") +frames(FADE - 1) +eq(playing(), "pallet.wav", "still fading one frame short of silence") +check(made["pallet.wav"].volume < fullVolume, + "the old theme has been ramped down by then") +frames(1) +eq(playing(), "routes1.wav", "the queued theme takes over after 7 * 10 frames") + +eq(made["routes1.wav"].volume, fullVolume, + "the new theme starts at full volume, not where the ramp ended") + +Music.playMap(data, "ROUTE_1", false, false, Music.MAP_FADE) +eq(playing(), "routes1.wav", "the same theme keeps playing") +frames(FADE) +eq(playing(), "routes1.wav", "and no fade was armed for it") + +Music.playMap(data, "PALLET_TOWN", false, false, Music.MAP_FADE) +frames(3 * Music.MAP_FADE) +Music.playMap(data, "PEWTER_CITY", false, false, Music.MAP_FADE) +eq(playing(), "routes1.wav", "the retargeted fade keeps ramping the old theme") +frames(4 * Music.MAP_FADE) +eq(playing(), "pewter.wav", "the ramp lands on the newest map's theme") + +Music.playMap(data, "PALLET_TOWN", false, false) +eq(playing(), "pallet.wav", "a fadeless map cue swaps immediately") + +T.finish("map_music_fade") diff --git a/tests/engine/nx_yellow_boot_test.lua b/tests/engine/nx_yellow_boot_test.lua index 764198ec..61d903e7 100644 --- a/tests/engine/nx_yellow_boot_test.lua +++ b/tests/engine/nx_yellow_boot_test.lua @@ -37,7 +37,7 @@ end local Y_TITLE = { "pikachu.png", "pika_bubble.png", "eyes_half.png", "eyes_closed.png", - "player.png", "copyright.png", "yellow_version.png", + "player.png", "copyright.png", "gamefreak_inc.png", "yellow_version.png", } for _, name in ipairs(Y_TITLE) do seed("yellow/assets/generated/title/" .. name) @@ -266,6 +266,7 @@ GameVersion.set("blue") seed("blue/assets/generated/title/blue_version.png", "blue-version-bytes") seed("blue/assets/generated/title/player.png") seed("blue/assets/generated/title/copyright.png") +seed("blue/assets/generated/title/gamefreak_inc.png") local B_INTRO = { "gf_logo.png", "gf_text.png", "big_star.png", "falling_star.png", "falling_star_blink.png", "studio_logo.png", diff --git a/tests/engine/title_mon_cycle.lua b/tests/engine/title_mon_cycle.lua new file mode 100644 index 00000000..8b9c9039 --- /dev/null +++ b/tests/engine/title_mon_cycle.lua @@ -0,0 +1,109 @@ +-- ..(engine/movie/title.asm ln 227) +-- ..(engine/movie/title2.asm ln 13) +-- luajit tests/engine/title_mon_cycle.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check = T.check +local eq = T.eq + +love = require("tests.love_stub") +love.math = love.math or {} +local nextPick = 1 +love.math.random = function(lo, hi) + nextPick = nextPick % hi + 1 + return math.max(lo, nextPick) +end + +local TitleState = require("src.ui.TitleState") + +local title = TitleState.new( + { data = {}, input = { wasPressed = function() return false end } }, {}) +title.sprites = setmetatable({}, { __index = function() return false end }) + +eq(title.phase, "drop", "Red/Blue boot into the logo drop, not the loop") +local ribbonSeen = {} +for _ = 1, 400 do + if title.phase == "loop" then break end + title:update(1 / 60) + if title.phase == "ribbon" then + ribbonSeen[#ribbonSeen + 1] = title.ribbonOffset + end +end +eq(title.phase, "loop", "the cinematic lands within 400 frames") +eq(ribbonSeen[1], 112, + "the ribbon is parked off the right edge on its first drawn frame") +eq(ribbonSeen[#ribbonSeen], 4, "and walks in 4px a frame to its rest") + +title.cycleIndex = 1 -- CHARMANDER: a starter, so the ball juggle runs +title.scrollPhase, title.scrollFrame, title.timer = "hold", 1, 0 +title.monOffset = 0 + +local frames = {} +for _ = 1, 260 do + title:update(1 / 60) + frames[#frames + 1] = { + phase = title.scrollPhase, offset = title.monOffset, + ball = title.ballY, mon = title.cycleSpecies[title.cycleIndex], + } +end + +local HOLD_FRAMES = 200 + +local function span(phase) + local first, count = nil, 0 + for i, f in ipairs(frames) do + if f.phase == phase then + if not first then first = i end + if first + count == i then count = count + 1 end + end + end + return first, count +end + +local holdAt, holdLen = span("hold") +local outAt, outLen = span("out") +local ballAt, ballLen = span("ball") +local inAt, inLen = span("in") +eq(holdAt, 1, "the cycle opens on the hold") +eq(holdLen, HOLD_FRAMES - 1, "ld c, 200 / CheckForUserInterruption") +eq(outAt, HOLD_FRAMES, "the scroll out begins as the 200th hold frame ends") +eq(outLen, 18, "TitleScroll_Out is 2+2+2+2+2+2+3+3 frames") +eq(ballLen, 10, "TitleScroll_WaitBall is two runs of 5") +eq(inLen, 17, "TitleScroll_In is 2+4+4+3+2+1+1 frames") +check(outAt < ballAt and ballAt < inAt, "out, then the ball, then in") + +local OUT = { 0, -1, -2, -4, -6, -9, -12, -16, -20, -25, -30, -36, -42, + -50, -58, -66, -75, -84 } +for i, want in ipairs(OUT) do + eq(frames[outAt + i - 1].offset, want, + "TitleScroll_Out offset at frame " .. i) +end + +local IN = { 120, 110, 100, 91, 82, 73, 64, 56, 48, 40, 32, 26, 20, 14, 9, + 4, 1 } +for i, want in ipairs(IN) do + eq(frames[inAt + i - 1].offset, want, "TitleScroll_In offset at frame " .. i) +end + +local BALL = { 97, 95, 94, 93, 92, 93, 94, 95, 97, 100 } +for i, want in ipairs(BALL) do + eq(frames[ballAt + i - 1].ball, want, "TitleBallYTable entry " .. i) +end + +local outgoing = frames[outAt].mon +eq(outgoing, "CHARMANDER", "the starter is the one that scrolls out") +for i = outAt, inAt - 1 do + eq(frames[i].mon, outgoing, + "the pick does not change before the scroll in, at frame " .. i) +end +local incoming = frames[inAt].mon +check(incoming ~= outgoing, "TitleScreenPickNewMon never repeats the pick") +for i = inAt, inAt + inLen - 1 do + check(frames[i].offset > 0, + "the incoming mon is only ever drawn right of rest, at frame " .. i) +end +eq(frames[inAt + inLen].offset, 0, "and settles at its resting column") + +T.finish("title_mon_cycle") diff --git a/tests/parity_true_color_ui.lua b/tests/parity_true_color_ui.lua index 415f470a..0bfa5015 100644 --- a/tests/parity_true_color_ui.lua +++ b/tests/parity_true_color_ui.lua @@ -93,6 +93,7 @@ local titleGame = { field = { title = { cycleSpecies = { "PIKACHU" } } } }, } local title = TitleState.new(titleGame, {}) +title.phase, title.scy = "loop", 0 local titleSprite, titleTrueColor = title:currentSprite() check(titleSprite and titleTrueColor, "title cache keeps a Pokemon sprite's trueColor flag") From 9d73ff4110ffe0f86676846949e365a6d7a96522 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Mon, 10 Aug 2026 14:27:00 -0400 Subject: [PATCH 62/63] yellow copyright diff --- src/import/RomExtractor.lua | 12 +++++++++- src/ui/Credits.lua | 35 +++++++++++++++++++++------- src/ui/IntroMovie.lua | 28 +++++++++++++++++++--- src/ui/TitleState.lua | 14 +++++++---- tests/engine/nx_yellow_boot_test.lua | 8 ++++++- tools/build_rom_data.py | 11 +++++++++ tools/extract/field.py | 6 +++-- tools/extract/gfx.py | 19 +++++++++++++++ 8 files changed, 114 insertions(+), 19 deletions(-) diff --git a/src/import/RomExtractor.lua b/src/import/RomExtractor.lua index d9b25c77..6c4319cc 100644 --- a/src/import/RomExtractor.lua +++ b/src/import/RomExtractor.lua @@ -1756,7 +1756,7 @@ end function RomExtractor:extractField() self:beginStage("Interface artwork") - local done, total = 0, 50 + local done, total = 0, 51 local function tick() done = done + 1 self:tick("Interface artwork", math.min(done, total), total) @@ -1772,6 +1772,16 @@ function RomExtractor:extractField() "title/copyright.png"); tick() self:raw2bpp("GameFreakLogoGraphics", 72, 8, "title/gamefreak_inc.png"); tick() + + do + local gf = self.symbols["GameFreakLogoGraphics"] + local tb = self.symbols["TextBoxGraphics"] + if gf and tb and tb[2] == gf[2] + 9 * 16 + 16 then + local raw = self.rom:bytes(gf[1], gf[2] + 9 * 16, 16) + self:save(ImageWriter.decode2bpp(raw, 8, 8, false), "title/nine.png") + end + end + tick() -- Yellow fixed Pikachu title art (no-op on Red/Blue manifests). self:extractYellowTitleArt(); tick() diff --git a/src/ui/Credits.lua b/src/ui/Credits.lua index 580381f0..b12db5fb 100644 --- a/src/ui/Credits.lua +++ b/src/ui/Credits.lua @@ -31,6 +31,7 @@ -- to just THE END. local Font = require("src.render.Font") +local GameVersion = require("src.core.GameVersion") local Music = require("src.core.Music") local Strings = require("src.core.Strings") @@ -64,11 +65,12 @@ local MON_PREP_FRAMES = 9 -- LoadCopyrightTiles (engine/movie/title.asm CopyrightTextString): tile -- sequences into the extracted title/copyright.png strip (tiles $60-$72: --- (c)'95.'96.'98 + Nintendo + Creatures inc.); the GAME FREAK inc. row is --- the title/gamefreak_inc.png strip (GameFreakLogoGraphics, tiles --- $73-$7B), with the intro's composed gamefreak_text.png as a fallback --- for pre-regeneration data. -local COPY_PREFIX = { 0, 1, 2, 1, 3, 1, 4 } -- (c)'95.'96.'98 +-- Red/Blue (c)'95.'96.'98, Yellow (c)1995-1999 + NineTile) + Nintendo + +-- Creatures inc.; the GAME FREAK inc. row is title/gamefreak_inc.png +-- (GameFreakLogoGraphics, tiles $73-$7B), with the intro's composed +-- gamefreak_text.png as a fallback for pre-regeneration data. +local COPY_PREFIX_RB = { 0, 1, 2, 1, 3, 1, 4 } -- (c)'95.'96.'98 +local COPY_PREFIX_YELLOW = { 0, 1, 2, 3, 1, 2 } -- (c)1995-199 local COPY_NINTENDO = { 5, 6, 7, 8, 9, 10 } -- Nintendo local COPY_CREATURES = { 11, 12, 13, 14, 15, 16, 17, 18 } -- Creatures inc. @@ -140,6 +142,12 @@ function Credits.new(game, onDone, onTheEnd) and title.gamefreakInc.path) or tryImage(intro and intro.gamefreakText and intro.gamefreakText.path) + self.yellowCopy = GameVersion.isYellow() + or (title and title.layout == "yellow_pikachu") + self.copyPrefix = self.yellowCopy and COPY_PREFIX_YELLOW or COPY_PREFIX_RB + self.nineImg = self.yellowCopy and tryImage( + title and title.nine and title.nine.path + or "assets/generated/title/nine.png") or nil return self end @@ -255,6 +263,17 @@ function Credits:drawPage(screen, xoff, shade) if screen.copyright then self:drawCopyright(xoff) end end +function Credits:drawCopyPrefix(x, y) + local img = self.copyImg + for _, t in ipairs(self.copyPrefix) do + love.graphics.draw(img, self.copyQuads[t], x, y) + x = x + 8 + end + if self.nineImg then + love.graphics.draw(self.nineImg, x, y) + end +end + function Credits:drawCopyright(xoff) local img = self.copyImg if img then @@ -267,11 +286,11 @@ function Credits:drawCopyright(xoff) end return x end - row(COPY_PREFIX, xoff + 16, 56) + self:drawCopyPrefix(xoff + 16, 56) row(COPY_NINTENDO, xoff + 80, 56) - row(COPY_PREFIX, xoff + 16, 72) + self:drawCopyPrefix(xoff + 16, 72) row(COPY_CREATURES, xoff + 80, 72) - row(COPY_PREFIX, xoff + 16, 88) + self:drawCopyPrefix(xoff + 16, 88) if self.gfImg then love.graphics.draw(self.gfImg, xoff + 80, 88) else diff --git a/src/ui/IntroMovie.lua b/src/ui/IntroMovie.lua index fb66220b..a7a0e197 100644 --- a/src/ui/IntroMovie.lua +++ b/src/ui/IntroMovie.lua @@ -2,6 +2,7 @@ -- ..(engine/movie/splash.asm ln 27) local Font = require("src.render.Font") +local GameVersion = require("src.core.GameVersion") local Music = require("src.core.Music") local Sound = require("src.core.Sound") local Strings = require("src.core.Strings") @@ -56,8 +57,13 @@ local SPLASH_FRAMES = WAVES_END + 40 -- ld c, 40 (intro.asm:329-331) local LOGO_X, LOGO_Y = 72, 56 local TEXT_X, TEXT_Y = 40, 80 --- ..(engine/movie/title.asm ln 390) -local COPY_PREFIX = { 0, 1, 2, 1, 3, 1, 4 } +-- CopyrightTextString (engine/movie/title.asm). Red/Blue years are +-- (c)'95.'96.'98; Yellow's sheet spells (c)1995-1999 and finishes the +-- last digit with NineTile (title screen). Intro originally overflows +-- into the font "A" tile for that digit; we draw NineTile so both +-- screens read 1999. +local COPY_PREFIX_RB = { 0, 1, 2, 1, 3, 1, 4 } +local COPY_PREFIX_YELLOW = { 0, 1, 2, 3, 1, 2 } local COPY_NINTENDO = { 5, 6, 7, 8, 9, 10 } local COPY_CREATURES = { 11, 12, 13, 14, 15, 16, 17, 18 } @@ -143,6 +149,12 @@ function IntroMovie.new(game, onDone) end self.gfInc = img(titleCfg.gamefreakInc) or tryImage("assets/generated/title/gamefreak_inc.png") + -- ..(pokeyellow engine/movie/title.asm CopyrightTextString / NineTile) + self.yellowCopy = GameVersion.isYellow() + or titleCfg.layout == "yellow_pikachu" + self.copyPrefix = self.yellowCopy and COPY_PREFIX_YELLOW or COPY_PREFIX_RB + self.nineImg = self.yellowCopy and ( + img(titleCfg.nine) or tryImage("assets/generated/title/nine.png")) or nil self.studioLogo = tryImage(self.studio.logo) if self.studioLogo then self.studioLogo:setFilter("nearest", "nearest") @@ -364,6 +376,16 @@ function IntroMovie:drawFight() end end +function IntroMovie:drawCopyPrefix(x, y) + for _, t in ipairs(self.copyPrefix) do + love.graphics.draw(self.copyright, self.copyQuads[t], x, y) + x = x + 8 + end + if self.nineImg then + love.graphics.draw(self.nineImg, x, y) + end +end + function IntroMovie:drawCopyright() if self.studio.card or self.studio.credit then local card = self.studio.card or "" @@ -378,7 +400,7 @@ function IntroMovie:drawCopyright() x = x + 8 end end - for _, y in ipairs({ 56, 72, 88 }) do row(COPY_PREFIX, 16, y) end + for _, y in ipairs({ 56, 72, 88 }) do self:drawCopyPrefix(16, y) end row(COPY_NINTENDO, 80, 56) row(COPY_CREATURES, 80, 72) love.graphics.draw(self.gfInc, 80, 88) diff --git a/src/ui/TitleState.lua b/src/ui/TitleState.lua index 0c366afd..f628c1db 100644 --- a/src/ui/TitleState.lua +++ b/src/ui/TitleState.lua @@ -226,6 +226,11 @@ function TitleState.new(game, opts) self.blue = GameVersion.isBlue() self.yellow = GameVersion.isYellow() or title.layout == "yellow_pikachu" + -- ..(pokeyellow engine/movie/title.asm .tileScreenCopyrightTiles / NineTile) + self.nineImg = self.yellow and tryImage(imagePath(title.nine) + or "assets/generated/title/nine.png") or nil + self.copyPrefix = self.yellow + and { 0, 1, 2, 3, 1, 2 } or { 0, 1, 2, 1, 3, 1, 4 } -- Yellow title is a fixed Pikachu composition (title_yellow.asm), not -- TitleMons cycling. Prefer composed pikachu.png from the Yellow import. self.yellowPikachu = self.yellow and tryImage(imagePath(title.pikachu) @@ -665,16 +670,17 @@ function TitleState:draw() self:drawCopyright(136 + (preRibbon and 0 or scrollY)) end --- ..(engine/movie/title.asm ln 117) -local COPY_PREFIX = { 0, 1, 2, 1, 3, 1, 4 } - function TitleState:drawCopyright(y) if not self.title.copyrightText and self.copyImg and self.gfInc then local x = 16 - for _, t in ipairs(COPY_PREFIX) do + for _, t in ipairs(self.copyPrefix) do love.graphics.draw(self.copyImg, self.copyQuads[t], x, y) x = x + 8 end + if self.nineImg then + love.graphics.draw(self.nineImg, x, y) + x = x + 8 + end love.graphics.draw(self.gfInc, x, y) return end diff --git a/tests/engine/nx_yellow_boot_test.lua b/tests/engine/nx_yellow_boot_test.lua index 61d903e7..a0ac0bf5 100644 --- a/tests/engine/nx_yellow_boot_test.lua +++ b/tests/engine/nx_yellow_boot_test.lua @@ -37,7 +37,8 @@ end local Y_TITLE = { "pikachu.png", "pika_bubble.png", "eyes_half.png", "eyes_closed.png", - "player.png", "copyright.png", "gamefreak_inc.png", "yellow_version.png", + "player.png", "copyright.png", "gamefreak_inc.png", "nine.png", + "yellow_version.png", } for _, name in ipairs(Y_TITLE) do seed("yellow/assets/generated/title/" .. name) @@ -207,6 +208,11 @@ check(pre ~= nil, "the IntroMovie pre-roll was constructed") eq(pre and pre.copyright and pre.copyright.path, "yellow/assets/generated/title/copyright.png", "copyright card resolves to the yellow/ copy") +eq(pre and pre.nineImg and pre.nineImg.path, + "yellow/assets/generated/title/nine.png", + "copyright NineTile resolves to the yellow/ copy") +check(pre and pre.yellowCopy == true, + "IntroMovie selects the Yellow (c)1995-1999 tile sequence") eq(pre and pre.studioLogo and pre.studioLogo.path, "yellow/assets/generated/intro/studio_logo.png", "data-driven studio logo resolves to the yellow/ copy") diff --git a/tools/build_rom_data.py b/tools/build_rom_data.py index c4020d30..8c4a9325 100755 --- a/tools/build_rom_data.py +++ b/tools/build_rom_data.py @@ -1747,6 +1747,17 @@ def extract_field(rom, symbols, manifest, out_dir, assets_dir): "title/copyright.png") raw_2bpp( "GameFreakLogoGraphics", 72, 8, "title/gamefreak_inc.png") + # Yellow NineTile (pokeyellow gfx/font.asm): final "9" of (c)1995-1999, + # parked in the 16 bytes between GameFreakLogoGraphics and TextBoxGraphics. + if _has_symbol(symbols, "GameFreakLogoGraphics") \ + and _has_symbol(symbols, "TextBoxGraphics"): + gf = _symbol(symbols, "GameFreakLogoGraphics") + tb = _symbol(symbols, "TextBoxGraphics") + if tb.address == gf.address + 9 * 16 + 16: + nine_raw = rom.bytes(gf.bank, gf.address + 9 * 16, 16) + _save_png( + _decode_2bpp(nine_raw, 8, 8, False), + os.path.join(assets_dir, "title/nine.png")) # Yellow fixed Pikachu title (pret/pokeyellow title_yellow.asm): tilemap # composition over both tile banks -- PokemonLogoGraphics in vChars2 diff --git a/tools/extract/field.py b/tools/extract/field.py index 36740642..839be555 100644 --- a/tools/extract/field.py +++ b/tools/extract/field.py @@ -1471,9 +1471,11 @@ def extract(pokered, out_dir): util.die("bike riding tileset extraction sanity check failed") if indoor_encounters["firstIndoorMap"] != 0x25: util.die("indoor encounter boundary sanity check failed") - if len(title) != 5 or any(not v["width"] for v in title.values()) \ + if len(title) not in (5, 6) or any(not v["width"] for v in title.values()) \ or (title["gamefreakInc"]["width"], - title["gamefreakInc"]["height"]) != (72, 8): + title["gamefreakInc"]["height"]) != (72, 8) \ + or ("nine" in title and (title["nine"]["width"], + title["nine"]["height"]) != (8, 8)): util.die("title asset extraction sanity check failed") if any((intro["gengar"][f]["width"], intro["gengar"][f]["height"]) != (56, 56) for f in ("frame1", "frame2", "frame3")) \ diff --git a/tools/extract/gfx.py b/tools/extract/gfx.py index 6a75483b..4dcbbf5b 100644 --- a/tools/extract/gfx.py +++ b/tools/extract/gfx.py @@ -130,6 +130,12 @@ TITLE_GRAPHICS = [ ("gamefreakInc", "gfx/title/gamefreak_inc.png", False), ] +# Yellow only (pokeyellow gfx/font.asm NineTile): final "9" of (c)1995-1999 +# on the title copyright line and intro/credits copyright card. +OPTIONAL_TITLE_GRAPHICS = [ + ("nine", "gfx/title/nine.png", False), +] + def extract_title(pokered, assets_dir): """Convert the title-screen graphics to assets/generated/title/. @@ -150,6 +156,19 @@ def extract_title(pokered, assets_dir): "height": size[1], "source": src_rel, } + for key, src_rel, matte in OPTIONAL_TITLE_GRAPHICS: + src = os.path.join(pokered, src_rel) + if not os.path.isfile(src): + continue + base = os.path.basename(src_rel) + size = convert_png(src, os.path.join(assets_dir, "title", base), + transparent_matte=matte) + out[key] = { + "path": f"assets/generated/title/{base}", + "width": size[0], + "height": size[1], + "source": src_rel, + } return out From bb979911c88a4c6afb44278425f2777da35dfa02 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Mon, 10 Aug 2026 14:59:04 -0400 Subject: [PATCH 63/63] Update common.sh --- scripts/linux-arm64/common.sh | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/linux-arm64/common.sh b/scripts/linux-arm64/common.sh index 39e196b4..292f1416 100755 --- a/scripts/linux-arm64/common.sh +++ b/scripts/linux-arm64/common.sh @@ -142,7 +142,10 @@ download_pinned() { rm -f "$dest" fi say "downloading $(basename "$dest")" - curl -fL --progress-bar "$url" -o "$dest.tmp" || fail "download failed: $url" + # --retry-all-errors because plain --retry skips TLS handshake failures, + # which is how xiph.org drops these tarballs; the pin below still gates it. + curl -fL --retry 5 --retry-delay 2 --retry-all-errors --progress-bar \ + "$url" -o "$dest.tmp" || fail "download failed: $url" got="$(sha256_file "$dest.tmp")" [ "$got" = "$want" ] || fail "$(printf '%s\n expected %s\n got %s' \ "checksum mismatch for $(basename "$dest")" "$want" "$got")"