From 0ae29744c87b74a1372e991089db2141783968f1 Mon Sep 17 00:00:00 2001 From: Martin Date: Thu, 6 Aug 2026 14:06:55 +0200 Subject: [PATCH 01/41] 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 bd6c60630392d4cb90c23dea851db5a2f7796058 Mon Sep 17 00:00:00 2001 From: Andrew Barnes Date: Thu, 6 Aug 2026 15:24:31 -0400 Subject: [PATCH 02/41] 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 03/41] 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 04/41] 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 05/41] 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 06/41] 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 07/41] 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 08/41] 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 09/41] 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 10/41] 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 11/41] 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 12/41] 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 13/41] 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 14/41] 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 15/41] 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 16/41] 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 17/41] 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 18/41] 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 19/41] 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 20/41] 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 21/41] 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 22/41] 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 23/41] 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 24/41] 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 25/41] 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 26/41] 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 27/41] 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 1e6f504a84e8b1507ffa8dff4aae03a038b187c8 Mon Sep 17 00:00:00 2001 From: hernan Date: Fri, 7 Aug 2026 22:13:50 -0400 Subject: [PATCH 28/41] 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 29/41] 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 30/41] 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 31/41] 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 32/41] 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 33/41] 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 34/41] 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 35/41] 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 36/41] 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 37/41] 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 38/41] 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 39/41] 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 40/41] 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 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 41/41] 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")