From b286e6584f6867235e6c3aa2bd3ac53fa82b87f2 Mon Sep 17 00:00:00 2001 From: MaxTomahawk Date: Fri, 21 Aug 2026 15:00:45 +0200 Subject: [PATCH 01/16] feat(mod-api): add imported dataset views --- docs/modding.md | 36 ++++ docs/rfcs/0012-imported-dataset-views.md | 113 ++++++++++ src/import/CacheFormat.lua | 22 ++ src/import/RomImporter.lua | 4 +- src/mods/DatasetViews.lua | 228 ++++++++++++++++++++ src/mods/Loader.lua | 9 + tests/modkit/cases/dataset_views.lua | 251 +++++++++++++++++++++++ 7 files changed, 661 insertions(+), 2 deletions(-) create mode 100644 docs/rfcs/0012-imported-dataset-views.md create mode 100644 src/import/CacheFormat.lua create mode 100644 src/mods/DatasetViews.lua create mode 100644 tests/modkit/cases/dataset_views.lua diff --git a/docs/modding.md b/docs/modding.md index 7c724404..f68ce215 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -200,6 +200,42 @@ the adapter's own coverage table: python3 tools/modkit.py gen2check mods/my_mod ``` +## Imported version datasets + +A mod can inspect semantic content from another game the player has already +imported without switching the active game or reaching into engine cache +internals: + +```lua +local gold, reason = mod.datasets:open("gold") +if not gold then + -- reason is "unknown_version" or "not_imported" + return +end + +local chikorita = gold.content.pokemon:get("CHIKORITA") +local normalVsGhost = gold.content.type_chart:get("NORMAL>GHOST") +local spritePath = gold.assets:path(chikorita.spriteFront) + +for id, record in gold.content.pokemon:each() do + -- ids are returned in deterministic lexical order +end +``` + +`view.version` and `view.generation` identify the selected dataset. +`view.content` exposes the same registry names and generation-specific record +shapes as `mod.content`, but only `get`, `has`, and `each`; returned +records are detached copies and cannot mutate either dataset. Each open call +receives an independent facade, so one mod cannot replace another mod view method. Engine-provided +semantic records, such as `NORMAL>GHOST` type matchups, are included. + +Only a cache carrying the current engine completion marker is opened. Missing +and stale imports both return `nil, "not_imported"`; no raw ROM bytes or +generated Lua modules are exposed. `view.assets:path(relative)` and +`view.assets:info(relative)` accept only `assets/generated/...` paths and +keep them under the selected version cache prefix. The API never changes +`mod.game`, the active `Data` table, `GameVersion`, or cache mount state. + ## Editing maps in Tiled Maps are data, not assets, so they can be authored in a real map editor and diff --git a/docs/rfcs/0012-imported-dataset-views.md b/docs/rfcs/0012-imported-dataset-views.md new file mode 100644 index 00000000..dc854e31 --- /dev/null +++ b/docs/rfcs/0012-imported-dataset-views.md @@ -0,0 +1,113 @@ +# RFC 0012: Read-only imported dataset views + +## Status + +Proposed. + +## Motivation + +A cross-version content mod can read only the active merged dataset through +`mod.content`. Even when the player has already imported another supported +game, a mod cannot inspect that version's semantic species, moves, items, type +chart, or generated asset namespace. The available alternatives are private: +mutating `CacheFs.prefix`, mounting another cache over the active one, loading +generated Lua directly, or asking for a second raw-ROM import. They compose +poorly with the active game, expose unstable import layout, and make safe +read-only use impossible from the sandbox. + +The immediate example is an optional content pack derived from an already +verified Gold import while Red, Blue, or Yellow remains active. The capability +is generic and useful to randomizers, compatibility inspectors, dex tools, and +other cross-version content mods. + +## Decision and plan extended + +This implements **D-AT-004: optional Kanto+ content consumes an +active-independent semantic dataset view**. The consuming design is tracked in +the Adaptive Trainers implementation plan, +[`docs/superpowers/plans/2026-08-14-adaptive-trainers.md`](https://github.com/MaxTomahawk/gen1recomp-adaptive-trainers/blob/main/docs/superpowers/plans/2026-08-14-adaptive-trainers.md), +Task 8. The delta is a generic, additive public API and contains no trainer +pools, scaling, boss identities, version-mixing rules, or Adaptive Trainers +policy. + +## Exact API delta + +Every sandboxed mod receives: + +```lua +local view, reason = mod.datasets:open("gold") +``` + +A known version with the current completed import marker returns a read-only view: + +```lua +view = { + version = "gold", + generation = 2, + content = { + pokemon = { + get = function(self, id) end, + has = function(self, id) end, + each = function(self) end, + }, + -- every public registry name and alias + }, + assets = { + path = function(self, generatedPath) end, + info = function(self, generatedPath) end, + }, +} +``` + +`get` returns a detached copy or nil. `has` reports semantic presence. +`each` returns ids in lexical order and detached values. The registries use +the selected version's generation routing and the engine's existing +`Schemas`, `Registry`, and `Builtins` normalization, so structured sources +such as type matchups retain the same public ids used by the active +`mod.content` facade. No register, patch, override, or remove verb is exposed. Each call returns an +independent facade over the cached internal dataset, so facade mutation cannot cross +mod boundaries. + +`assets:path` returns the selected cache-prefixed virtual path. +`assets:info` returns sanitized `type` and optional `size` metadata. Both +accept only relative paths below `assets/generated/`, reject control +characters, absolute paths, backslashes, and traversal, and expose no byte +reader. + +An unknown version returns `nil, "unknown_version"`. A missing or stale +completion marker returns `nil, "not_imported"`. Generated tables are parsed +under an empty environment and cached per version; the API never exposes raw +ROM bytes, generated source, host paths, or a cache mount. + +## Migration and compatibility + +Existing mods change nothing. `mod.datasets` is additive and requires no +permission. The service is allocated lazily on the first explicit +`mod.datasets:open` call. A boot with no mods, or with mods that do not call +it, performs no cross-version cache reads. + +Opening a view does not change `GameVersion`, `CacheFs.prefix`, the active +`Data` table, PhysFS mounts, save state, or the selected game's behavior. +Red, Blue, Yellow, Gold, and Silver keep their existing active data paths. + +The completion-marker format moves to the pure `CacheFormat` helper shared by +the importer and dataset service. The literal marker and import readiness +behavior are unchanged. + +## Verification + +- `tests/modkit/cases/dataset_views.lua` loads a sandboxed fixture mod through + the public API and covers Red, Blue, Yellow, and Gold independently. +- The test proves semantic registry normalization, deterministic iteration, + detached records, read-only facades, cross-mod facade isolation, + version-prefixed generated assets, + traversal rejection, stable failure reasons, and stale-marker rejection. +- The same test proves that cross-version reads do not change the active data, + active game, or cache prefix, and that a no-mod boot performs zero + cross-version cache reads. +- The existing importer and engine/modkit suites prove unchanged marker + readiness and no-mod behavior. + +## Deprecation etiquette + +Nothing is removed, renamed, superseded, or deprecated. diff --git a/src/import/CacheFormat.lua b/src/import/CacheFormat.lua new file mode 100644 index 00000000..54d3b926 --- /dev/null +++ b/src/import/CacheFormat.lua @@ -0,0 +1,22 @@ +-- Current completion-marker contract for generated ROM caches. +-- Pure and version-aware so read-only consumers can verify an import without +-- loading the launcher or changing CacheFs.prefix. + +local GameVersion = require("src.core.GameVersion") + +local CacheFormat = {} + +CacheFormat.PREFIX = "rom-cache-v10:" + +function CacheFormat.markerFor(version) + local info = GameVersion.VERSIONS[version] + if not info then return nil end + return CacheFormat.PREFIX .. info.sha1 +end + +function CacheFormat.matches(version, marker) + local expected = CacheFormat.markerFor(version) + return expected ~= nil and marker == expected +end + +return CacheFormat diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index aa6ea4cf..9d5d2f39 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -1,4 +1,5 @@ local GameVersion = require("src.core.GameVersion") +local CacheFormat = require("src.import.CacheFormat") local GamepadMap = require("src.core.GamepadMap") local Logger = require("src.core.Logger") local Strings = require("src.core.Strings") @@ -41,7 +42,6 @@ end -- the stage, which is what the Yellow markers below already do for #439/#557. -- Reach for a bump when the change spans versions or has no single file to -- point at. -local CACHE_FORMAT = "rom-cache-v10:" -- The completion marker is written under each version's cache prefix -- (red/rom-cache.complete, blue/rom-cache.complete, ...). local MARKER_PATH = "rom-cache.complete" @@ -49,7 +49,7 @@ local MARKER_PATH = "rom-cache.complete" -- The marker a finished import writes for a version: the generation tag plus -- that version's ROM hash, so both a format bump and a swapped ROM invalidate. local function markerFor(version) - return CACHE_FORMAT .. GameVersion.info(version).sha1 + return CacheFormat.markerFor(version) end local COMMUNITY_URL = "https://bois.icu" local TRUST_WARNING = "if you did not get this from bryanthaboi's github " .. diff --git a/src/mods/DatasetViews.lua b/src/mods/DatasetViews.lua new file mode 100644 index 00000000..8576df96 --- /dev/null +++ b/src/mods/DatasetViews.lua @@ -0,0 +1,228 @@ +-- Read-only views over verified, version-scoped generated datasets. +-- +-- A view reads semantic registry records from another imported version without +-- changing GameVersion, CacheFs.prefix, the active Data table, or any PhysFS +-- mount. Records are detached copies and generated assets stay under the +-- selected version's virtual cache prefix. + +local CacheFormat = require("src.import.CacheFormat") +local GameVersion = require("src.core.GameVersion") +local Builtins = require("src.mods.Builtins") +local Merge = require("src.mods.Merge") +local Registry = require("src.mods.Registry") +local Schemas = require("src.mods.Schemas") + +local DatasetViews = {} +DatasetViews.__index = DatasetViews + +local GEN1_MODULES = { + constants = "constants", maps = "maps", tilesets = "tilesets", + text = "text", text_pointers = "text_pointers", + trainer_headers = "trainer_headers", font = "font", sprites = "sprites", + pokemon = "pokemon", moves = "moves", items = "items", + type_chart = "type_chart", trainers = "trainers", + encounters = "encounters", field = "field", + battle_anims = "battle_anims", audio = "audio", + palettes = "palettes", icons = "icons", +} + +local GEN2_MODULES = { + pokemon = "pokemon", moves = "moves", items = "items", + type_chart = "type_chart", audio = "audio", font = "font", + gen2Maps = "maps", gen2Tilesets = "tilesets", gen2Text = "text", + gen2Trainers = "trainers", gen2Encounters = "encounters", + gen2Sprites = "sprites", gen2Palettes = "palettes", + gen2Icons = "icons", gen2BattleAnims = "battle_anims", + gen2Constants = "constants", gen2Landmarks = "landmarks", +} + +local function compileTable(source, chunkname) + if type(source) ~= "string" or source:byte(1) == 27 then return nil end + local env = {} + local chunk, err + if setfenv then + chunk, err = loadstring(source, chunkname) + if chunk then setfenv(chunk, env) end + else + chunk, err = load(source, chunkname, "t", env) + end + if not chunk then return nil, err end + local ok, value = pcall(chunk) + if not ok or type(value) ~= "table" then + return nil, ok and "generated module did not return a table" or value + end + return value +end + +local function resolvePath(root, suffix) + local node = root + for key in suffix:gmatch("[^.]+") do + if type(node) ~= "table" then return nil end + node = node[key] + end + return node +end + +local function assetRelative(path) + if type(path) ~= "string" or path == "" then + error("dataset asset path is required", 3) + end + if path:find("\\", 1, true) or path:sub(1, 1) == "/" + or path:find("[%z\1-\31]") then + error("dataset asset path must be a generated relative path", 3) + end + if path:sub(1, 17) ~= "assets/generated/" then + error("dataset assets are limited to assets/generated/", 3) + end + for segment in path:gmatch("[^/]+") do + if segment == "." or segment == ".." then + error("dataset asset path may not traverse directories", 3) + end + end + return path +end + +function DatasetViews.new(fs) + assert(fs and fs.read, "DatasetViews.new requires a readable filesystem") + return setmetatable({ fs = fs, datasets = {} }, DatasetViews) +end + +function DatasetViews:_module(view, root) + local moduleName = view.modules[root] + if not moduleName then return nil end + local cached = view.moduleCache[moduleName] + if cached ~= nil then return cached or nil end + local path = view.prefix .. "data/generated/" .. moduleName .. ".lua" + local source = self.fs.read(path) + local value = compileTable(source, "@" .. path) + view.moduleCache[moduleName] = value or false + return value +end + +function DatasetViews:_data(view) + if view.data then return view.data end + local data = {} + for root in pairs(view.modules) do + local value = self:_module(view, root) + if value ~= nil then data[root] = value end + end + view.data = data + return data +end + +local function registryBase(data, target) + return function() + return resolvePath(data, target) + end +end + +function DatasetViews:_registries(view) + if view.registries then return view.registries end + local data = self:_data(view) + local registries = {} + for name, catalogSpec in pairs(Schemas.REGISTRIES) do + local spec = Schemas.shapeFor(name, catalogSpec, view.generation) + local registry = Registry.new(name, spec) + if spec.target then registry.base = registryBase(data, spec.target) end + registries[name] = registry + end + Builtins.install(registries, data, view.generation) + for _, registry in pairs(registries) do registry:freeze() end + view.registries = registries + return registries +end + +function DatasetViews:_registry(view, name) + local registry = self:_registries(view)[name] + + local function valueAt(id) + local value = registry and registry:get(id) + if value == nil then return nil end + return Merge.deepCopy(value) + end + + return { + get = function(_, id) + if type(id) ~= "string" or id == "" then return nil end + return valueAt(id) + end, + has = function(_, id) + return valueAt(id) ~= nil + end, + each = function() + local ids = {} + if registry then + for id in registry:each() do + if type(id) == "string" then ids[#ids + 1] = id end + end + end + table.sort(ids) + local index = 0 + return function() + index = index + 1 + local id = ids[index] + if id == nil then return nil end + return id, valueAt(id) + end + end, + } +end + +function DatasetViews:_assets(view) + local service = self + local assets = {} + + function assets:path(path) + return view.prefix .. assetRelative(path) + end + + function assets:info(path) + local full = self:path(path) + local info = service.fs.getInfo and service.fs.getInfo(full, "file") + if not info then return nil end + local out = { type = info.type } + if info.size ~= nil then out.size = info.size end + return out + end + + return assets +end + +function DatasetViews:open(version) + if type(version) ~= "string" or not GameVersion.VERSIONS[version] then + return nil, "unknown_version" + end + local internal = self.datasets[version] + if not internal then + local prefix = GameVersion.cachePrefix(version) + local marker = self.fs.read(prefix .. "rom-cache.complete") + if not CacheFormat.matches(version, marker) then + return nil, "not_imported" + end + + local generation = GameVersion.generation(version) + internal = { + version = version, generation = generation, prefix = prefix, + modules = generation == 2 and GEN2_MODULES or GEN1_MODULES, + moduleCache = {}, + } + internal.registries = self:_registries(internal) + self.datasets[version] = internal + end + local view = { + version = version, + generation = internal.generation, + content = {}, + } + for name in pairs(Schemas.REGISTRIES) do + view.content[name] = self:_registry(internal, name) + end + for alias, canonical in pairs(Schemas.ALIASES) do + view.content[alias] = view.content[canonical] + end + view.assets = self:_assets(internal) + + return view +end + +return DatasetViews diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 84b087cd..3dbba98e 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -983,6 +983,15 @@ function Loader:_api(mod) -- a deep copy: what a mod does to its own view never reaches the loader manifest = Merge.deepCopy(mod.manifest), content = {}, + datasets = { + open = function(_, version) + if not loader.datasetViews then + local DatasetViews = engineRequire("src.mods.DatasetViews") + loader.datasetViews = DatasetViews.new(loader.fs) + end + return loader.datasetViews:open(version) + end, + }, exports = {}, DELETE = Registry.DELETE, events = { diff --git a/tests/modkit/cases/dataset_views.lua b/tests/modkit/cases/dataset_views.lua new file mode 100644 index 00000000..59edb614 --- /dev/null +++ b/tests/modkit/cases/dataset_views.lua @@ -0,0 +1,251 @@ +-- Cross-version imported datasets through the public sandboxed mod API. +-- The view is semantic and read-only: registry-shaped records in, detached +-- copies out, with generated assets kept inside the selected version namespace. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local GameVersion = require("src.core.GameVersion") +local CacheFs = require("src.import.CacheFs") +local CacheFormat = require("src.import.CacheFormat") + +local function serialized(value) + local function encode(v) + if type(v) == "string" then return string.format("%q", v) end + if type(v) == "number" or type(v) == "boolean" then return tostring(v) end + local keys = {} + for key in pairs(v) do keys[#keys + 1] = key end + table.sort(keys, function(a, b) return tostring(a) < tostring(b) end) + local out = { "{" } + for _, key in ipairs(keys) do + out[#out + 1] = "[" .. encode(key) .. "]=" .. encode(v[key]) .. "," + end + out[#out + 1] = "}" + return table.concat(out) + end + return "return " .. encode(value) +end + +local files = { + ["mods/dataset_probe/manifest.json"] = [[{ + "id": "dataset_probe", + "name": "Dataset Probe", + "version": "1.0.0", + "entry": "main.lua", + "api": 2, + "games": ["all"] + }]], + ["mods/dataset_probe/main.lua"] = [[ +local mod = ... +local out = {} +for _, version in ipairs({ "red", "blue", "yellow", "gold" }) do + local view, reason = mod.datasets:open(version) + if not view then + out[version] = { reason = reason } + else + local registry = view.content.pokemon + local first = registry:get("FIXMON") + first.name = "MUTATED" + local ids = {} + for id in registry:each() do ids[#ids + 1] = id end + local immune = view.content.type_chart:get("NORMAL>GHOST") + local steel = view.content.type_chart:get("STEEL") + out[version] = { + version = view.version, + generation = view.generation, + name = registry:get("FIXMON").name, + has = registry:has("FIXMON"), + ids = ids, + writable = registry.register ~= nil or registry.patch ~= nil + or registry.override ~= nil or registry.remove ~= nil, + sprite = view.assets:path(registry:get("FIXMON").spriteFront), + spriteInfo = view.assets:info(registry:get("FIXMON").spriteFront), + normalGhost = immune and immune.multiplier, + steelCategory = steel and steel.category, + } + end +end +local missing, missingReason = mod.datasets:open("silver") +local unknown, unknownReason = mod.datasets:open("crystal") +out.silver = { present = missing ~= nil, reason = missingReason } +out.crystal = { present = unknown ~= nil, reason = unknownReason } +local gold = mod.datasets:open("gold") +out.assetEscape = pcall(function() gold.assets:path("../save.lua") end) +out.rawAssetRead = gold.assets.read ~= nil +mod.exports.result = out + ]], +} + +local isolationMods = { + ["mods/dataset_mutator/manifest.json"] = [[{ + "id": "dataset_mutator", + "name": "Dataset Mutator", + "version": "1.0.0", + "entry": "main.lua", + "api": 2, + "games": ["all"] + }]], + ["mods/dataset_mutator/main.lua"] = [[ +local mod = ... +local view = assert(mod.datasets:open("red")) +view.content.pokemon.get = function() return { name = "POISONED" } end + ]], + ["mods/dataset_observer/manifest.json"] = [[{ + "id": "dataset_observer", + "name": "Dataset Observer", + "version": "1.0.0", + "entry": "main.lua", + "api": 2, + "games": ["all"], + "dependencies": ["dataset_mutator"] + }]], + ["mods/dataset_observer/main.lua"] = [[ +local mod = ... +local view = assert(mod.datasets:open("red")) +mod.exports.name = view.content.pokemon:get("FIXMON").name + ]], +} + +local labels = { + red = "RED SOURCE", blue = "BLUE SOURCE", + yellow = "YELLOW SOURCE", gold = "GOLD SOURCE", +} + +for _, version in ipairs({ "red", "blue", "yellow", "gold" }) do + local prefix = GameVersion.cachePrefix(version) + files[prefix .. "rom-cache.complete"] = + "rom-cache-v10:" .. GameVersion.info(version).sha1 + files[prefix .. "data/generated/pokemon.lua"] = serialized({ + FIXMON = { + id = "FIXMON", name = labels[version], dex = version == "gold" and 252 or 152, + spriteFront = "assets/generated/battle/front/fixmon.png", + }, + ALPHA = { id = "ALPHA", name = "ALPHA", dex = 1, + spriteFront = "assets/generated/battle/front/alpha.png" }, + }) + files[prefix .. "data/generated/type_chart.lua"] = serialized({ + matchups = { { attacker = "NORMAL", defender = "GHOST", multiplier = 0 } }, + types = version == "gold" and { + STEEL = { name = "STEEL", category = "physical", index = 9 }, + } or {}, + }) + files[prefix .. "assets/generated/battle/front/fixmon.png"] = "png-" .. version +end + +-- An otherwise plausible Silver tree has no completion marker. A view must +-- not execute or expose it. +files[GameVersion.cachePrefix("silver") .. "data/generated/pokemon.lua"] = + serialized({ FIXMON = { id = "FIXMON", name = "UNVERIFIED" } }) + +T.eq(CacheFormat.markerFor("red"), + "rom-cache-v10:" .. GameVersion.info("red").sha1, + "cache marker follows the importer format and version hash") +T.eq(CacheFormat.matches("silver", "rom-cache-v9:stale"), false, + "stale cache formats are rejected") +T.eq(CacheFormat.markerFor("crystal"), nil, + "unknown versions have no valid cache marker") + +local originalVersion = GameVersion.get() +local originalPrefix = CacheFs.prefix +GameVersion.set("red") + +local function isVersionCachePath(path) + for _, version in ipairs(GameVersion.ORDER) do + local prefix = GameVersion.cachePrefix(version) + if path:sub(1, #prefix) == prefix then return true end + end + return false +end + +-- No mod means the new service is completely cold. Count only cross-version +-- cache reads so the loader's ordinary discovery/state reads do not matter. +local vanillaFiles = {} +for path, body in pairs(files) do + if not path:match("^mods/") then + vanillaFiles[path] = body + end +end +local vanillaFs = T.sdk.memfs(vanillaFiles) +local vanillaReads = 0 +local vanillaRead = vanillaFs.read +vanillaFs.read = function(path) + if isVersionCachePath(path) then + vanillaReads = vanillaReads + 1 + end + return vanillaRead(path) +end +local vanillaData = { pokemon = { ACTIVE = { id = "ACTIVE", name = "ACTIVE" } } } +local vanilla = T.sdk.loadNone({ fs = vanillaFs, data = vanillaData }) +T.eq(#vanilla.errors, 0, "no-mod loader remains clean") +T.eq(vanillaReads, 0, "no mod performs no imported-dataset reads") +T.eq(vanillaData.pokemon.ACTIVE.name, "ACTIVE", + "no mod leaves the active dataset unchanged") +vanilla.release() + +local activeData = { pokemon = { ACTIVE = { id = "ACTIVE", name = "ACTIVE" } } } +local run = T.sdk.loadMods({ "mods/dataset_probe" }, { + fs = T.sdk.memfs(files), data = activeData, generation = 1, +}) +T.eq(#run.errors, 0, + "sandboxed dataset probe loads clean: " .. tostring(run.errors[1])) +local out = run.loader.exports.dataset_probe + and run.loader.exports.dataset_probe.result or {} + +for _, version in ipairs({ "red", "blue", "yellow", "gold" }) do + local got = out[version] or {} + T.eq(got.version, version, version .. " view reports its selected version") + T.eq(got.generation, version == "gold" and 2 or 1, + version .. " view reports the selected generation") + T.eq(got.name, labels[version], + version .. " returns its own semantic species record") + T.eq(got.has, true, version .. " registry has() reads the imported view") + T.same(got.ids, { "ALPHA", "FIXMON" }, + version .. " registry each() is deterministic") + T.eq(got.writable, false, version .. " registry exposes no write verbs") + T.eq(got.sprite, + GameVersion.cachePrefix(version) .. "assets/generated/battle/front/fixmon.png", + version .. " asset path stays in its cache namespace") + T.same(got.spriteInfo, { type = "file" }, + version .. " asset info is sanitized metadata") + T.eq(got.normalGhost, 0, + version .. " exposes structured matchup ids semantically") +end +T.eq(out.gold and out.gold.steelCategory, "physical", + "Gold exposes imported Steel as a semantic type record") + +T.same(out.silver, { present = false, reason = "not_imported" }, + "an unverified cache is unavailable") +T.same(out.crystal, { present = false, reason = "unknown_version" }, + "an unknown version fails with a stable reason") +T.eq(out.assetEscape, false, "dataset asset paths reject traversal") +T.eq(out.rawAssetRead, false, "dataset assets expose no raw byte reader") +T.eq(activeData.pokemon.ACTIVE.name, "ACTIVE", + "cross-version reads do not mutate the active game dataset") +T.eq(activeData.pokemon.FIXMON, nil, + "cross-version records are not merged into active game data") +T.eq(GameVersion.get(), "red", "dataset reads do not switch the active game") +T.eq(CacheFs.prefix, originalPrefix, "dataset reads do not change cache prefix state") + +run.release() + +local isolationFiles = {} +for path, body in pairs(files) do + if not path:match("^mods/") then + isolationFiles[path] = body + end +end +for path, body in pairs(isolationMods) do isolationFiles[path] = body end +local isolationRun = T.sdk.loadMods({ + "mods/dataset_mutator", "mods/dataset_observer", +}, { fs = T.sdk.memfs(isolationFiles), + data = { pokemon = {} }, generation = 1 }) +T.eq(#isolationRun.errors, 0, "cross-mod isolation fixture loads cleanly") +T.eq(isolationRun.loader.exports.dataset_observer + and isolationRun.loader.exports.dataset_observer.name, + labels.red, "one mod cannot mutate another mod dataset facade") +isolationRun.release() + +GameVersion.set(originalVersion) +CacheFs.prefix = originalPrefix + +T.finish("dataset_views") From ca700c44d7047dc089d8ff77cec0b803b0b00489 Mon Sep 17 00:00:00 2001 From: MaxTomahawk Date: Mon, 24 Aug 2026 09:33:51 +0200 Subject: [PATCH 02/16] fix(mod-api): harden imported dataset views --- docs/modding.md | 24 +- docs/modding/reference/registries.md | 3 +- ...iews.md => 0015-imported-dataset-views.md} | 57 +- src/core/Data.lua | 16 +- src/core/DatasetHydration.lua | 29 + src/core/Game2.lua | 6 +- src/core/SaveSerializer.lua | 85 ++- src/import/CacheContract.lua | 160 ++++++ src/import/CacheFs.lua | 19 +- src/import/RomImporter.lua | 183 +------ src/mods/Builtins.lua | 10 +- src/mods/DatasetViews.lua | 270 +++++++--- src/mods/Loader.lua | 2 +- tests/engine/dataset_views_no_mod_parity.lua | 22 + tests/engine/generated_data_decoder_test.lua | 37 ++ .../engine/rom_importer_source_tree_test.lua | 50 +- tests/engine/trade_art_import.lua | 18 +- tests/modkit/cases/dataset_views.lua | 497 ++++++++++-------- .../cases/dataset_views_nontermination.lua | 21 + tests/modkit/dataset_view_fixture.lua | 121 +++++ 20 files changed, 1051 insertions(+), 579 deletions(-) rename docs/rfcs/{0012-imported-dataset-views.md => 0015-imported-dataset-views.md} (56%) create mode 100644 src/core/DatasetHydration.lua create mode 100644 src/import/CacheContract.lua create mode 100644 tests/engine/dataset_views_no_mod_parity.lua create mode 100644 tests/engine/generated_data_decoder_test.lua create mode 100644 tests/modkit/cases/dataset_views_nontermination.lua create mode 100644 tests/modkit/dataset_view_fixture.lua diff --git a/docs/modding.md b/docs/modding.md index f68ce215..16e28741 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -209,7 +209,7 @@ internals: ```lua local gold, reason = mod.datasets:open("gold") if not gold then - -- reason is "unknown_version" or "not_imported" + -- reason is "unknown_version", "not_imported", or "invalid_cache" return end @@ -223,15 +223,21 @@ end ``` `view.version` and `view.generation` identify the selected dataset. -`view.content` exposes the same registry names and generation-specific record -shapes as `mod.content`, but only `get`, `has`, and `each`; returned -records are detached copies and cannot mutate either dataset. Each open call -receives an independent facade, so one mod cannot replace another mod view method. Engine-provided -semantic records, such as `NORMAL>GHOST` type matchups, are included. +`view.content` exposes the same registry names, aliases, generation routing, +and data-only record shapes as `mod.content`, but only `get`, `has`, and +`each`. Returned records are detached copies and cannot mutate either dataset. +Records containing functions, userdata, threads, metatables, or cycles are not +exposed. Each open call receives an independent facade, so one mod cannot +replace another mod view method. Canonical boot shaping is included, such as +Gen 1 defaults and Yellow corrections, and Gold's Foresight matchup rows and +derived `held_items`. -Only a cache carrying the current engine completion marker is opened. Missing -and stale imports both return `nil, "not_imported"`; no raw ROM bytes or -generated Lua modules are exposed. `view.assets:path(relative)` and +The marker and every required generated module for the selected version are +rechecked on each open. Missing, partial, and stale imports return +`nil, "not_imported"`; malformed or resource-limit-breaking generated data +returns `nil, "invalid_cache"`. Generated modules are decoded with a bounded +literal-only grammar and are never executed. No raw ROM bytes or generated +source are exposed. `view.assets:path(relative)` and `view.assets:info(relative)` accept only `assets/generated/...` paths and keep them under the selected version cache prefix. The API never changes `mod.game`, the active `Data` table, `GameVersion`, or cache mount state. diff --git a/docs/modding/reference/registries.md b/docs/modding/reference/registries.md index 929299f7..be3ed622 100644 --- a/docs/modding/reference/registries.md +++ b/docs/modding/reference/registries.md @@ -342,6 +342,7 @@ accepted and merged as-is. | `source` | string | | `swarmGrass` | map of string -> {map?, rates, slots} | | `swarmWater` | map of string -> {map?, rate, slots} | +| `timeFishGroups` | map of string | integer 0..255 -> {day, nite} | | `treeSets` | map of string -> {common, rare} | | `trees` | map of string -> string | | `water` | map of string -> {map?, rate, slots} | @@ -598,7 +599,7 @@ mod.content.map_songs:override("PALLET_TOWN", "Music_Routes1") | `id` | string | yes | | `index` | integer >= 0 | no | | `label` | string | no | -| `objects` | list of any value | no | +| `objects` | list of {pokemon?, ...} | no | | `palette` | string | no | | `signs` | list of any value | no | | `tileset` | tilesets id | yes | diff --git a/docs/rfcs/0012-imported-dataset-views.md b/docs/rfcs/0015-imported-dataset-views.md similarity index 56% rename from docs/rfcs/0012-imported-dataset-views.md rename to docs/rfcs/0015-imported-dataset-views.md index dc854e31..b1aa7c88 100644 --- a/docs/rfcs/0012-imported-dataset-views.md +++ b/docs/rfcs/0015-imported-dataset-views.md @@ -1,4 +1,4 @@ -# RFC 0012: Read-only imported dataset views +# RFC 0015: Read-only imported dataset views ## Status @@ -15,10 +15,15 @@ generated Lua directly, or asking for a second raw-ROM import. They compose poorly with the active game, expose unstable import layout, and make safe read-only use impossible from the sandbox. -The immediate example is an optional content pack derived from an already -verified Gold import while Red, Blue, or Yellow remains active. The capability -is generic and useful to randomizers, compatibility inspectors, dex tools, and -other cross-version content mods. +The concrete consumer is **Adaptive Trainers**, whose approved Phase G Kanto+ +sidecar must derive nine Kanto-line continuations, Steel/type and move +definitions, and generated sprites from the player's existing verified Gold +cache while Red, Blue, or Yellow remains active. `mod.content` exposes only the +active R/B/Y dataset; `mod.imports` can read only separately declared raw mod +imports; and `mod.cache` is mod-private generated output. None can inspect the +launcher-owned Gold semantic dataset without a second ROM import and mod-side +ROM interpretation. The engine API remains generic and contains no Adaptive +Trainers policy. ## Decision and plan extended @@ -59,7 +64,8 @@ view = { } ``` -`get` returns a detached copy or nil. `has` reports semantic presence. +`get` returns a bounded detached data-only copy or nil. `has` reports data-only +semantic presence. `each` returns ids in lexical order and detached values. The registries use the selected version's generation routing and the engine's existing `Schemas`, `Registry`, and `Builtins` normalization, so structured sources @@ -74,10 +80,16 @@ accept only relative paths below `assets/generated/`, reject control characters, absolute paths, backslashes, and traversal, and expose no byte reader. -An unknown version returns `nil, "unknown_version"`. A missing or stale -completion marker returns `nil, "not_imported"`. Generated tables are parsed -under an empty environment and cached per version; the API never exposes raw -ROM bytes, generated source, host paths, or a cache mount. +An unknown version returns `nil, "unknown_version"`. A missing, partial, or +stale cache returns `nil, "not_imported"`. A required module that is malformed +or exceeds the limits (8 MiB per module, 48 MiB aggregate, depth 64, 500,000 +values, 2 MiB per string, or 250,000 entries per table) returns +`nil, "invalid_cache"`; actionable detail is engine-logged but not exposed to +the mod. Generated Lua is decoded with the existing restricted +literal grammar and never executed. Functions, userdata, threads, metatables, +cycles, non-table roots, binary chunks, and trailing syntax cannot cross the +facade. Successful roots are decoded lazily and cached per selected version. +The API never exposes raw ROM bytes, generated source, host paths, or a mount. ## Migration and compatibility @@ -90,23 +102,28 @@ Opening a view does not change `GameVersion`, `CacheFs.prefix`, the active `Data` table, PhysFS mounts, save state, or the selected game's behavior. Red, Blue, Yellow, Gold, and Silver keep their existing active data paths. -The completion-marker format moves to the pure `CacheFormat` helper shared by -the importer and dataset service. The literal marker and import readiness -behavior are unchanged. +The completion marker and per-version required-file rules live in the pure, +injected `CacheContract` shared by the importer and dataset service. It also +defines source-tree behavior. Neither consumer mutates `CacheFs.prefix` while +checking readiness. Every `open` revalidates the contract and required module +shapes; a stale/remove/reimport transition evicts the previous semantic view. ## Verification -- `tests/modkit/cases/dataset_views.lua` loads a sandboxed fixture mod through - the public API and covers Red, Blue, Yellow, and Gold independently. +- `tests/modkit/cases/dataset_views.lua` loads sandboxed fixture mods through + the public API and covers Red, Blue, Yellow, Gold, and Silver independently. - The test proves semantic registry normalization, deterministic iteration, detached records, read-only facades, cross-mod facade isolation, version-prefixed generated assets, traversal rejection, stable failure reasons, and stale-marker rejection. -- The same test proves that cross-version reads do not change the active data, - active game, or cache prefix, and that a no-mod boot performs zero - cross-version cache reads. -- The existing importer and engine/modkit suites prove unchanged marker - readiness and no-mod behavior. +- It also proves missing/empty/partial/stale/remove/reimport behavior, hostile + generated-source rejection, canonical Gen 1/Yellow/Gold hydration, and the + approved Kanto+ Gold records and assets. +- `tests/modkit/cases/dataset_views_nontermination.lua` proves generated code + is rejected rather than executed; `tests/engine/generated_data_decoder_test.lua` + proves every decoder resource bound. +- `tests/engine/dataset_views_no_mod_parity.lua` is the separate guarded no-mod + parity suite and proves the service stays unallocated with zero cache reads. ## Deprecation etiquette diff --git a/src/core/Data.lua b/src/core/Data.lua index 31d7932a..935739a9 100644 --- a/src/core/Data.lua +++ b/src/core/Data.lua @@ -82,8 +82,9 @@ local function copy(value) return out end -function Data:applyVersionedFieldData() - if require("src.core.GameVersion").isYellow() then +function Data:applyVersionedFieldData(version) + version = version or require("src.core.GameVersion").get() + if version == "yellow" then self.field.trades = copy(YELLOW_TRADES) -- The old man's catch demo is a RATTATA in Yellow -- (scripts/ViridianCity.asm ViridianCityOldManStartCatchTrainingScript @@ -106,7 +107,8 @@ end -- Fills only what the cache is missing, so an importer that learns to -- stamp one of these keys silently takes over from the engine. -function Data:seedDefaults() +function Data:seedDefaults(version) + version = version or require("src.core.GameVersion").get() local constants = self.constants or {} self.constants = constants self.field = self.field or {} @@ -131,7 +133,7 @@ function Data:seedDefaults() if constants.dexDigits == nil then constants.dexDigits = math.max(3, #tostring(constants.dexSize)) end - self:applyVersionedFieldData() + Data.applyVersionedFieldData(self, version) local boot = self.field.boot if boot == nil then boot = {} @@ -144,7 +146,7 @@ function Data:seedDefaults() -- only the un-overridden default flips, so a total conversion that set -- field.boot.screens.splash keeps its choice on any version. if boot.screens.splash == BOOT_DEFAULTS.screens.splash - and require("src.core.GameVersion").isYellow() then + and version == "yellow" then boot.screens.splash = "YellowIntro" end -- the naming screen presets the importer already extracts but nothing @@ -163,11 +165,11 @@ function Data:seedDefaults() -- extractor never writes headers for them. Seed the EVENT_BEAT_* / -- after-battle rows so Blaine's SetEventRange deactivation and talk -- after-text work like the other gyms (scripts/CinnabarGym.asm). - self:seedCinnabarGymTrainerHeaders() + Data.seedCinnabarGymTrainerHeaders(self) -- #197: the Fighting Dojo Karate Master is text_asm, so the extractor -- writes no header for him -- seed one so he engages on sight and has -- his defeat / re-talk lines (same idea as the Cinnabar seed above). - self:seedFightingDojoKarateMaster() + Data.seedFightingDojoKarateMaster(self) -- #189: 1F cabin door order vs rooms map (survey zoom) require("src.world.SsAnneLayout").apply(self.maps) end diff --git a/src/core/DatasetHydration.lua b/src/core/DatasetHydration.lua new file mode 100644 index 00000000..003cc3bf --- /dev/null +++ b/src/core/DatasetHydration.lua @@ -0,0 +1,29 @@ +-- Canonical pure shaping shared by the active boot and inactive dataset views. +-- The caller supplies both the data table and selected version; no global +-- GameVersion, cache prefix, mount, or active Data table is changed. + +local GameVersion = require("src.core.GameVersion") + +local DatasetHydration = {} + +function DatasetHydration.applyGen2(data, moduleLoader) + local chart = data.type_chart or {} + chart.matchups = chart.matchups or {} + for _, row in ipairs(chart.foresightMatchups or {}) do + chart.matchups[#chart.matchups + 1] = row + end + data.type_chart = chart + data.gen2HeldItems = + (moduleLoader or require)("src.core.gen2.ItemEffects").heldItemsFrom(data.items) + return data +end + +function DatasetHydration.apply(data, version, moduleLoader) + if GameVersion.generation(version) == 2 then + return DatasetHydration.applyGen2(data, moduleLoader) + end + require("src.core.Data").seedDefaults(data, version) + return data +end + +return DatasetHydration diff --git a/src/core/Game2.lua b/src/core/Game2.lua index 4ab8a479..c0821818 100644 --- a/src/core/Game2.lua +++ b/src/core/Game2.lua @@ -894,10 +894,7 @@ function Game2:load() self.data.type_chart = loadGenerated("data/generated/type_chart.lua") or {} -- data/types/type_matchups.asm:112-116: the rows after the `db -2` marker -- apply by default; Foresight is what cuts the table short at it. - local chart = self.data.type_chart - for _, row in ipairs(chart.foresightMatchups or {}) do - chart.matchups[#chart.matchups + 1] = row - end + require("src.core.DatasetHydration").applyGen2(self.data) -- The `held_items` registry's merge target: ItemAttributes' last two columns -- as their own table, so a mod can give an item a held behaviour without -- owning the whole item record. Built BEFORE mods:load so the registry @@ -906,7 +903,6 @@ function Game2:load() -- changed from what a mod reached through the shared `items` registry -- instead. Both halves live in src/core/gen2/ItemEffects.lua. local ItemEffects = require("src.core.gen2.ItemEffects") - self.data.gen2HeldItems = ItemEffects.heldItemsFrom(self.data.items) local heldBefore = ItemEffects.heldSnapshot(self.data.gen2HeldItems) -- Gen 2-only tables the menus read. Namespaced so nothing collides with the -- Gen 1 keys of the same idea (data.palettes, data.icons). diff --git a/src/core/SaveSerializer.lua b/src/core/SaveSerializer.lua index 9f821f67..7adbae86 100644 --- a/src/core/SaveSerializer.lua +++ b/src/core/SaveSerializer.lua @@ -70,9 +70,27 @@ local function fail(state, why) error(("parse error at byte %d: %s"):format(state.pos, why), 0) end +local function limit(state, name) + return state.limits and state.limits[name] +end + +local function bumpNode(state) + state.nodes = state.nodes + 1 + local maximum = limit(state, "maxNodes") + if maximum and state.nodes > maximum then fail(state, "too many values") end +end + local function skip(state) - local _, last = state.src:find("^[ \t\r\n]*", state.pos) - state.pos = last + 1 + while true do + local _, last = state.src:find("^[ \t\r\n]*", state.pos) + state.pos = last + 1 + if not (state.limits and state.limits.allowComments + and state.src:sub(state.pos, state.pos + 1) == "--") then + return + end + local newline = state.src:find("\n", state.pos + 2, true) + state.pos = newline and newline + 1 or (#state.src + 1) + end end local function peek(state) @@ -90,7 +108,10 @@ local function readString(state) fail(state, "unterminated string") elseif c == '"' then state.pos = i + 1 - return table.concat(out) + local value = table.concat(out) + local maximum = limit(state, "maxStringBytes") + if maximum and #value > maximum then fail(state, "string too long") end + return value elseif c == "\\" then local nxt = src:sub(i + 1, i + 1) if nxt:match("%d") then @@ -136,10 +157,14 @@ end local readValue local function readTable(state) + bumpNode(state) state.depth = state.depth + 1 - if state.depth > MAX_DEPTH then fail(state, "table nesting too deep") end + if state.depth > (limit(state, "maxDepth") or MAX_DEPTH) then + fail(state, "table nesting too deep") + end state.pos = state.pos + 1 local out = {} + local entries, nextArray = 0, 1 skip(state) if peek(state) == "}" then state.pos = state.pos + 1 @@ -148,7 +173,7 @@ local function readTable(state) end while true do skip(state) - local key + local key, value local c = peek(state) if c == "[" then state.pos = state.pos + 1 @@ -156,15 +181,38 @@ local function readTable(state) skip(state) if peek(state) ~= "]" then fail(state, "expected ]") end state.pos = state.pos + 1 + skip(state) + if peek(state) ~= "=" then fail(state, "expected =") end + state.pos = state.pos + 1 + value = readValue(state) elseif c:match("[%a_]") then - key = readIdent(state) + local start = state.pos + local ident = readIdent(state) + skip(state) + if peek(state) == "=" then + key = ident + state.pos = state.pos + 1 + value = readValue(state) + elseif state.limits and state.limits.allowArray then + state.pos = start + key = nextArray + value = readValue(state) + else + fail(state, "expected =") + end + elseif state.limits and state.limits.allowArray then + key = nextArray + value = readValue(state) else fail(state, "expected key") end - skip(state) - if peek(state) ~= "=" then fail(state, "expected =") end - state.pos = state.pos + 1 - out[key] = readValue(state) + if key == nil then fail(state, "nil table key") end + if out[key] ~= nil then fail(state, "duplicate table key") end + entries = entries + 1 + local maximum = limit(state, "maxTableEntries") + if maximum and entries > maximum then fail(state, "too many table entries") end + out[key] = value + if type(key) == "number" and key == nextArray then nextArray = nextArray + 1 end skip(state) local sep = peek(state) if sep == "," then @@ -189,25 +237,30 @@ readValue = function(state) skip(state) local c = peek(state) if c == '"' then + bumpNode(state) return readString(state) elseif c == "{" then return readTable(state) elseif c:match("[%a_]") then -- the only bare words in the grammar are the boolean literals local word = readIdent(state) - if word == "true" then return true end - if word == "false" then return false end + if word == "true" then bumpNode(state); return true end + if word == "false" then bumpNode(state); return false end state.pos = state.pos - #word fail(state, "unexpected name '" .. word .. "'") elseif c:match("[%-%d%.]") then + bumpNode(state) return readNumber(state) end fail(state, c == "" and "unexpected end of input" or "unexpected character") end -function SaveSerializer.decode(str) +function SaveSerializer.decode(str, limits) if type(str) ~= "string" then return nil, "save must be a string" end - local state = { src = str, pos = 1, depth = 0 } + if limits and limits.maxBytes and #str > limits.maxBytes then + return nil, ("input is %d bytes (max %d)"):format(#str, limits.maxBytes) + end + local state = { src = str, pos = 1, depth = 0, nodes = 0, limits = limits } local ok, result = pcall(function() skip(state) local word = state.src:match("^[%a_][%w_]*", state.pos) @@ -219,7 +272,9 @@ function SaveSerializer.decode(str) return value end) if not ok then return nil, result end - if type(result) ~= "table" then return nil, "save root must be a table" end + if type(result) ~= "table" then + return nil, (limits and limits.rootName or "save") .. " root must be a table" + end return result end diff --git a/src/import/CacheContract.lua b/src/import/CacheContract.lua new file mode 100644 index 00000000..9b8d6413 --- /dev/null +++ b/src/import/CacheContract.lua @@ -0,0 +1,160 @@ +-- Pure version-scoped readiness rules shared by the importer and read-only +-- dataset consumers. Callers inject a filesystem; no global cache prefix or +-- active game state is changed while inspecting another version. + +local CacheFormat = require("src.import.CacheFormat") +local GameVersion = require("src.core.GameVersion") + +local CacheContract = {} + +CacheContract.MARKER_PATH = "rom-cache.complete" + +local REQUIRED_FILES = { + "data/generated/constants.lua", "data/generated/maps.lua", + "data/generated/text.lua", "data/generated/field.lua", + "data/generated/battle_anims.lua", + "assets/generated/title/pokemon_logo.png", + "assets/generated/fonts/font.png", + "assets/generated/battle/front/pikachu.png", + "assets/generated/battle/anims/move_anim_0.png", + "assets/generated/battle/anims/move_anim_1.png", + "assets/generated/audio/programs.bin", + "assets/generated/trade/game_boy.png", +} + +local VERSION_REQUIRED_FILES = { + yellow = { + "assets/generated/battle/trainers/jessie_james.png", + "assets/generated/battle/profoakb.png", + "assets/generated/pikachu/pikapic_1.png", + }, +} + +local GOLD_REQUIRED_FILES = { + "data/generated/constants.lua", "data/generated/maps.lua", + "data/generated/roofs.lua", "data/generated/sprites.lua", + "data/generated/scripts.lua", "data/generated/text.lua", + "data/generated/rom_text.lua", + "data/generated/pokemon.lua", "data/generated/tilesets.lua", + "data/generated/audio.lua", "data/generated/marts.lua", + "assets/generated/fonts/font.png", "assets/generated/fonts/frames.png", + "assets/generated/title/pokemon_logo.png", + "assets/generated/title/title_screen.png", "assets/generated/title/hooh.png", + "assets/generated/title/hooh_5.png", "assets/generated/title/clouds.png", + "assets/generated/title/copyright_splash.png", + "data/generated/oak_speech.lua", "assets/generated/intro/oak.png", + "assets/generated/intro/cal.png", "assets/generated/tilesets/johto.png", + "assets/generated/tilesets/roofs/new_bark.png", + "assets/generated/sprites/chris.png", + "assets/generated/battle/front/chikorita.png", + "assets/generated/battle/front/pikachu.png", + "assets/generated/battle/front/marill.png", + "assets/generated/battle/trainers/falkner.png", + "assets/generated/battle/hud/balls.png", + "assets/generated/audio/programs.bin", + "assets/generated/slots/gold_slots_1.png", + "assets/generated/card_flip/card_flip_1.png", + "assets/generated/pc/mail_item.png", +} + +local SEMANTIC_MODULES = { + [1] = { + "constants", "maps", "tilesets", "text", "text_pointers", + "trainer_headers", "font", "sprites", "pokemon", "moves", "items", + "type_chart", "trainers", "encounters", "field", "battle_anims", + }, + [2] = { + "pokemon", "moves", "items", "type_chart", "audio", "font", "maps", + "tilesets", "text", "trainers", "encounters", "sprites", "palettes", + "icons", "battle_anims", "constants", "landmarks", + }, +} + +local OPTIONAL_SEMANTIC_MODULES = { [1] = { "audio", "palettes", "icons" }, [2] = {} } + +local function copy(values) + local out = {} + for index, value in ipairs(values or {}) do out[index] = value end + return out +end + +function CacheContract.requiredFiles(version, semantic) + local files + if version == "gold" or version == "silver" then + files = copy(GOLD_REQUIRED_FILES) + else + files = copy(REQUIRED_FILES) + for _, path in ipairs(VERSION_REQUIRED_FILES[version] or {}) do + files[#files + 1] = path + end + end + if semantic then + local generation = GameVersion.generation(version) + for _, name in ipairs(SEMANTIC_MODULES[generation] or {}) do + files[#files + 1] = "data/generated/" .. name .. ".lua" + end + end + local unique, out = {}, {} + for _, path in ipairs(files) do + if not unique[path] then unique[path], out[#out + 1] = true, path end + end + return out +end + +function CacheContract.semanticModules(version) + return copy(SEMANTIC_MODULES[GameVersion.generation(version)]) +end + +function CacheContract.optionalSemanticModules(version) + return copy(OPTIONAL_SEMANTIC_MODULES[GameVersion.generation(version)]) +end + +local function isFile(fs, path) + local info = fs.getInfo and fs.getInfo(path, "file") + if info then return info.type == nil or info.type == "file" end + return false +end + +local function hasFiles(version, fs, prefix, semantic) + for _, path in ipairs(CacheContract.requiredFiles(version, semantic)) do + if not isFile(fs, prefix .. path) then return false, path end + end + return true +end + +local function sourcePrefix(version) + return version == "red" and "" or GameVersion.cachePrefix(version) +end + +local function sourceReady(version, fs, semantic) + if not (fs.getRealDirectory and fs.getSource) then return nil end + local prefix = sourcePrefix(version) + local ok = hasFiles(version, fs, prefix, semantic) + if not ok then return nil end + local first = prefix .. CacheContract.requiredFiles(version, semantic)[1] + if fs.getRealDirectory(first) ~= fs.getSource() then return nil end + return { kind = "source", prefix = prefix } +end + +function CacheContract.inspect(version, fs, opts) + opts = opts or {} + if not (GameVersion.VERSIONS[version] and fs and fs.read and fs.getInfo) then + return nil, "not_imported", "unsupported cache inspection" + end + if opts.allowSource then + local source = sourceReady(version, fs, opts.semantic) + if source then return source end + end + local prefix = GameVersion.cachePrefix(version) + local marker = fs.read(prefix .. CacheContract.MARKER_PATH) + if not CacheFormat.matches(version, marker) then + return nil, "not_imported", "completion marker is missing or stale" + end + local complete, missing = hasFiles(version, fs, prefix, opts.semantic) + if not complete then + return nil, "not_imported", "required cache file is missing: " .. missing + end + return { kind = "cache", prefix = prefix, marker = marker } +end + +return CacheContract diff --git a/src/import/CacheFs.lua b/src/import/CacheFs.lua index 4b86d5cb..deedb517 100644 --- a/src/import/CacheFs.lua +++ b/src/import/CacheFs.lua @@ -324,9 +324,9 @@ function CacheFs.openWrite(rel) return file end --- read cache-relative `rel`; returns the bytes or nil -function CacheFs.read(rel) - rel = withPrefix(rel) +-- Read an exact version-qualified path without consulting CacheFs.prefix. +-- Readiness checks use this to inspect another version without global state. +function CacheFs.readAt(rel) local root = CacheFs.root() if root then local f = io.open(realPath(root, rel), "rb") @@ -341,6 +341,11 @@ function CacheFs.read(rel) return love.filesystem.read(rel) end +-- read cache-relative `rel`; returns the bytes or nil +function CacheFs.read(rel) + return CacheFs.readAt(withPrefix(rel)) +end + -- Read cache-relative `rel` for the active GameVersion when PhysFS may hide -- prefixed Blue/Yellow/Gold trees (fused NX mount hole). Same order Data:load -- already used: active version prefix with CacheFs.prefix cleared, then @@ -386,8 +391,7 @@ function CacheFs.loadActive(rel) end -- does cache-relative `rel` exist as a file? -function CacheFs.exists(rel) - rel = withPrefix(rel) +function CacheFs.existsAt(rel) local root = CacheFs.root() if root then local f = io.open(realPath(root, rel), "rb") @@ -398,6 +402,11 @@ function CacheFs.exists(rel) return love.filesystem.getInfo(rel, "file") ~= nil end + +function CacheFs.exists(rel) + return CacheFs.existsAt(withPrefix(rel)) +end + -- remove a single cache-relative file function CacheFs.remove(rel) rel = withPrefix(rel) diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 9d5d2f39..0e1dfc76 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -1,5 +1,6 @@ local GameVersion = require("src.core.GameVersion") local CacheFormat = require("src.import.CacheFormat") +local CacheContract = require("src.import.CacheContract") local GamepadMap = require("src.core.GamepadMap") local Logger = require("src.core.Logger") local Strings = require("src.core.Strings") @@ -38,13 +39,13 @@ end -- the same unbootable save as before. -- Deliberately NOT bumped for the Gold trainer-pic gap: this tag invalidates -- every version at once, and that gap is Gold-only. A per-version marker in --- VERSION_REQUIRED_FILES_OVERRIDE.gold re-imports exactly the caches that lack --- the stage, which is what the Yellow markers below already do for #439/#557. +-- CacheContract's Gold required-file list re-imports exactly the caches that +-- lack the stage, the same per-version mechanism used for Yellow #439/#557. -- Reach for a bump when the change spans versions or has no single file to -- point at. -- The completion marker is written under each version's cache prefix -- (red/rom-cache.complete, blue/rom-cache.complete, ...). -local MARKER_PATH = "rom-cache.complete" +local MARKER_PATH = CacheContract.MARKER_PATH -- The marker a finished import writes for a version: the generation tag plus -- that version's ROM hash, so both a format bump and a swapped ROM invalidate. @@ -56,109 +57,6 @@ local TRUST_WARNING = "if you did not get this from bryanthaboi's github " .. "or a link from the discord that bryanthaboi himself posted, just know " .. "it might have been tampered with. go to the discord to verify " .. COMMUNITY_URL .. " (or click the logo above)" -local REQUIRED_FILES = { - "data/generated/constants.lua", - "data/generated/maps.lua", - "data/generated/text.lua", - "data/generated/field.lua", - "data/generated/battle_anims.lua", - "assets/generated/title/pokemon_logo.png", - "assets/generated/fonts/font.png", - "assets/generated/battle/front/pikachu.png", - "assets/generated/battle/anims/move_anim_0.png", - "assets/generated/battle/anims/move_anim_1.png", - "assets/generated/audio/programs.bin", - -- The trade cinematic's Game Boy / cable art. Caches built before #750 - -- carry none of it and fall back to plain rectangles, so listing one of - -- the files re-imports them without a CACHE_FORMAT bump. - "assets/generated/trade/game_boy.png", -} - --- Files only one version's cache carries. A version that predates one of --- them re-imports on its own, without dragging the other versions through a --- CACHE_FORMAT bump. -local VERSION_REQUIRED_FILES = { - yellow = { - "assets/generated/battle/trainers/jessie_james.png", -- #439 - -- Oak's own back pic and the pikapic base frames only exist in caches - -- built after their manifest symbols landed, so an older Yellow cache - -- has to re-import to stop falling back to the old man's back pic and - -- to the battle front pic (#557, #561). Both are gated on manifest - -- symbols in RomExtractor, so these markers must only ever list files - -- tools/rom_manifest_yellow.json can actually produce -- otherwise the - -- cache reads as incomplete and re-importing cannot clear it. - "assets/generated/battle/profoakb.png", - "assets/generated/pikachu/pikapic_1.png", - }, -} - --- Gold Phase 1 writes a thinner cache than Gen 1 (no battle anim sheets, --- trade art, or field.lua payload yet -- see docs/gold-phase1.md). This --- list replaces REQUIRED_FILES entirely for that version so a successful --- Gen 2 extract is not stuck as "incomplete" waiting on Gen 1 markers. -local VERSION_REQUIRED_FILES_OVERRIDE = { - gold = { - "data/generated/constants.lua", - "data/generated/maps.lua", - "data/generated/roofs.lua", -- Phase 2: forces re-import of Phase 1 caches - "data/generated/sprites.lua", -- OW sheets (Chris + NPCs) - "data/generated/scripts.lua", -- disassembled map scripts - "data/generated/text.lua", -- decoded Gen 2 dialogue strings - -- The engine's own strings, keyed by label rather than by address. A - -- cache built before RomExtractorGen2:extractText has none, and every - -- line that reads through src/core/RomText.lua would silently keep - -- printing its Lua fallback, so this re-imports those caches rather than - -- bumping CACHE_FORMAT and dragging Red, Blue and Yellow through it too. - "data/generated/rom_text.lua", - "data/generated/pokemon.lua", - "data/generated/tilesets.lua", - "data/generated/audio.lua", - -- Mart shelves + the heal machine art ride the same import, so listing - -- marts.lua alone re-imports the caches from before either existed - -- (empty shop shelves, no Pokecenter light show). - "data/generated/marts.lua", - "assets/generated/fonts/font.png", - "assets/generated/fonts/frames.png", -- the seven other OPTION textbox frames - "assets/generated/title/pokemon_logo.png", - "assets/generated/title/title_screen.png", -- TitleScreenTilemap composition - "assets/generated/title/hooh.png", - "assets/generated/title/hooh_5.png", -- wing-flap frames force re-import - "assets/generated/title/clouds.png", - "assets/generated/title/copyright_splash.png", - "data/generated/oak_speech.lua", -- Oak texts + trainer pics - "assets/generated/intro/oak.png", - "assets/generated/intro/cal.png", - "assets/generated/tilesets/johto.png", - "assets/generated/tilesets/roofs/new_bark.png", - "assets/generated/sprites/chris.png", - "assets/generated/battle/front/chikorita.png", - "assets/generated/battle/front/pikachu.png", - "assets/generated/battle/front/marill.png", -- Oak speech demo mon - -- The trainer class pics (TrainerPicPointers). FALKNER is row 0 of that - -- table, so a cache that produced any class pic at all produced this one. - -- Listed for the reason the Yellow markers above are: a cache built before - -- the stage existed reads as INCOMPLETE and re-imports itself, so this - -- particular gap cannot survive a tag bump being forgotten again. It - -- costs nothing on a current cache and is the difference between every - -- trainer battle opening with a picture and opening with none. - "assets/generated/battle/trainers/falkner.png", - -- BattleStart_TrainerHuds cannot draw its party rows from a cache made - -- before the four ball tiles were extracted (#1502). - "assets/generated/battle/hud/balls.png", - "assets/generated/audio/programs.bin", - -- Goldenrod Game Corner reel + board art (#1581). menu_gfx.lua used to - -- advertise these paths even when Slots*LZ / CardFlip* were absent from - -- the manifest, so a cache that never wrote the PNGs still looked - -- complete and SlotMachine crashed on its labelled-cell fallback. - "assets/generated/slots/gold_slots_1.png", - "assets/generated/card_flip/card_flip_1.png", - -- PCMailGFX (engine/pokemon/bills_pc.asm:2170-2173) - "assets/generated/pc/mail_item.png", - }, -} --- Same Gen 2 extract, so a Silver cache is complete when the same files exist. -VERSION_REQUIRED_FILES_OVERRIDE.silver = VERSION_REQUIRED_FILES_OVERRIDE.gold - -- "Split-screen ROM selector" first-run palette (matches the FirstRun mockup): -- a dark neon arcade panel, one column per game. -- Red, Blue, and Yellow share the same importer flow once listed in @@ -211,58 +109,6 @@ local PAL = { chipInkGold = { 58, 44, 0 }, -- #3a2c00 dark "Y" on the gold chip } --- Per-version required cache files. Gold replaces the Gen 1 list entirely --- (VERSION_REQUIRED_FILES_OVERRIDE); Yellow adds a few extra markers. -local function requiredFilesFor(version) - local override = VERSION_REQUIRED_FILES_OVERRIDE[version] - if override then return override, true end - return REQUIRED_FILES, false -end - --- CacheFs.exists checks the game folder directly for a portable install, --- otherwise the save directory through love.filesystem. It honors --- CacheFs.prefix, so we point it at the version's cache subtree (red/, --- blue/, yellow/, gold/). -local function allRequiredFilesExist(version) - local CacheFs = require("src.import.CacheFs") - local saved = CacheFs.prefix - CacheFs.prefix = GameVersion.cachePrefix(version) - local ok = true - local required, isOverride = requiredFilesFor(version) - for _, path in ipairs(required) do - if not CacheFs.exists(path) then ok = false; break end - end - if ok and not isOverride then - for _, path in ipairs(VERSION_REQUIRED_FILES[version] or {}) do - if not CacheFs.exists(path) then ok = false; break end - end - end - CacheFs.prefix = saved - return ok -end - --- A developer checkout / Python build leaves generated data in the physfs --- source: Red at the historical root, Blue/Yellow/Gold in their versioned --- trees. Imported Red caches still live under red/. Check source paths --- directly so that cache prefix cannot hide Red's source tree, and keep --- save-dir caches from counting as current source data. -local function sourceTreeHasData(version) - if not love.filesystem.getRealDirectory then return false end - local prefix = version == "red" and "" or GameVersion.cachePrefix(version) - local required, isOverride = requiredFilesFor(version) - for _, path in ipairs(required) do - if love.filesystem.getInfo(prefix .. path, "file") == nil then return false end - end - if not isOverride then - for _, path in ipairs(VERSION_REQUIRED_FILES[version] or {}) do - if love.filesystem.getInfo(prefix .. path, "file") == nil then return false end - end - end - local path = prefix .. required[1] - local real = love.filesystem.getRealDirectory(path) - return real == love.filesystem.getSource() -end - -- ------- ROM cache location -- -- The extracted cache (data/generated, assets/generated) plus the @@ -318,7 +164,8 @@ local function purgeSaveDirCache() -- / yellow/ / gold/ prefix) so it cannot shadow the portable game-folder cache. for _, version in ipairs(GameVersion.ORDER) do local prefix = GameVersion.cachePrefix(version) - if saveDirHas(prefix .. MARKER_PATH) or saveDirHas(prefix .. REQUIRED_FILES[1]) then + local probe = CacheContract.requiredFiles(version)[1] + if saveDirHas(prefix .. MARKER_PATH) or saveDirHas(prefix .. probe) then removeTree(prefix .. "data/generated") removeTree(prefix .. "assets/generated") love.filesystem.remove(prefix .. MARKER_PATH) @@ -336,13 +183,17 @@ function RomImporter.isReady(version) -- save-directory copy that would otherwise shadow it at runtime. purgeSaveDirCache() 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) - CacheFs.prefix = saved - return marker == markerFor(version) and allRequiredFilesExist(version) + local backing = { + read = CacheFs.readAt, + getInfo = function(path) + return CacheFs.existsAt(path) and { type = "file" } or nil + end, + } + if love and love.filesystem then + backing.getRealDirectory = love.filesystem.getRealDirectory + backing.getSource = love.filesystem.getSource + end + return CacheContract.inspect(version, backing, { allowSource = true }) ~= nil end function RomImporter.syncAndroidShortcuts(activeVersion) diff --git a/src/mods/Builtins.lua b/src/mods/Builtins.lua index 66695a17..b5cd4143 100644 --- a/src/mods/Builtins.lua +++ b/src/mods/Builtins.lua @@ -163,8 +163,8 @@ end -- a module the build dropped disables its registry rather than the game: -- the consumer still reads its own table, so vanilla keeps working -local function load(path) - local ok, module = pcall(require, path) +local function load(path, moduleLoader) + local ok, module = pcall(moduleLoader or require, path) if ok then return module end Logger.warn("builtin registrations skipped for %s (%s)", path, tostring(module)) return nil @@ -187,14 +187,14 @@ local function isolate(registry) }, { __index = registry }) end -function Builtins.install(content, data, generation) +function Builtins.install(content, data, generation, moduleLoader) for _, entry in ipairs(registrantsFor(generation)) do local registry = content[entry.name] and isolate(content[entry.name]) if registry then if entry.install then local modules, complete = {}, true for i, path in ipairs(entry.modules) do - modules[i] = load(path) + modules[i] = load(path, moduleLoader) if modules[i] == nil then complete = false end end -- data is the fourth argument, not the second, so the existing @@ -204,7 +204,7 @@ function Builtins.install(content, data, generation) entry.install(registry, modules, Builtins.OWNER, data) end else - local module = load(entry.from) + local module = load(entry.from, moduleLoader) -- entry.fn names the entry point for a module that owns more than one -- registry (Gold's Battle owns statuses and move_effects); the default -- is the registerInto every single-registry module exposes diff --git a/src/mods/DatasetViews.lua b/src/mods/DatasetViews.lua index 8576df96..eb0364b9 100644 --- a/src/mods/DatasetViews.lua +++ b/src/mods/DatasetViews.lua @@ -1,21 +1,32 @@ --- Read-only views over verified, version-scoped generated datasets. --- --- A view reads semantic registry records from another imported version without --- changing GameVersion, CacheFs.prefix, the active Data table, or any PhysFS --- mount. Records are detached copies and generated assets stay under the --- selected version's virtual cache prefix. +-- Read-only, bounded views over version-scoped generated datasets. -local CacheFormat = require("src.import.CacheFormat") +local CacheContract = require("src.import.CacheContract") +local DatasetHydration = require("src.core.DatasetHydration") local GameVersion = require("src.core.GameVersion") +local Logger = require("src.core.Logger") +local SaveSerializer = require("src.core.SaveSerializer") local Builtins = require("src.mods.Builtins") -local Merge = require("src.mods.Merge") local Registry = require("src.mods.Registry") local Schemas = require("src.mods.Schemas") local DatasetViews = {} DatasetViews.__index = DatasetViews -local GEN1_MODULES = { +-- A generated module is ROM-sized data, never an arbitrary program. These +-- caps bound both one malicious file and the aggregate work one open performs. +local DECODE_LIMITS = { + allowArray = true, + allowComments = true, + maxBytes = 8 * 1024 * 1024, + maxDepth = 64, + maxNodes = 500000, + maxStringBytes = 2 * 1024 * 1024, + maxTableEntries = 250000, + rootName = "generated data", +} +local MAX_AGGREGATE_BYTES = 48 * 1024 * 1024 + +local GEN1_ROOTS = { constants = "constants", maps = "maps", tilesets = "tilesets", text = "text", text_pointers = "text_pointers", trainer_headers = "trainer_headers", font = "font", sprites = "sprites", @@ -26,7 +37,7 @@ local GEN1_MODULES = { palettes = "palettes", icons = "icons", } -local GEN2_MODULES = { +local GEN2_ROOTS = { pokemon = "pokemon", moves = "moves", items = "items", type_chart = "type_chart", audio = "audio", font = "font", gen2Maps = "maps", gen2Tilesets = "tilesets", gen2Text = "text", @@ -36,22 +47,8 @@ local GEN2_MODULES = { gen2Constants = "constants", gen2Landmarks = "landmarks", } -local function compileTable(source, chunkname) - if type(source) ~= "string" or source:byte(1) == 27 then return nil end - local env = {} - local chunk, err - if setfenv then - chunk, err = loadstring(source, chunkname) - if chunk then setfenv(chunk, env) end - else - chunk, err = load(source, chunkname, "t", env) - end - if not chunk then return nil, err end - local ok, value = pcall(chunk) - if not ok or type(value) ~= "table" then - return nil, ok and "generated module did not return a table" or value - end - return value +local function decode(source) + return SaveSerializer.decode(source, DECODE_LIMITS) end local function resolvePath(root, suffix) @@ -82,38 +79,124 @@ local function assetRelative(path) return path end -function DatasetViews.new(fs) - assert(fs and fs.read, "DatasetViews.new requires a readable filesystem") - return setmetatable({ fs = fs, datasets = {} }, DatasetViews) +local function copyData(value, state, depth) + state = state or { seen = {}, nodes = 0 } + state.nodes = state.nodes + 1 + if state.nodes > DECODE_LIMITS.maxNodes then return nil, false end + local kind = type(value) + if kind == "nil" or kind == "boolean" or kind == "number" then return value end + if kind == "string" then + if #value > DECODE_LIMITS.maxStringBytes then return nil, false end + return value + end + if kind ~= "table" or getmetatable(value) ~= nil then return nil, false end + depth = (depth or 0) + 1 + if depth > DECODE_LIMITS.maxDepth or state.seen[value] then return nil, false end + state.seen[value] = true + local out, entries = {}, 0 + for key, child in pairs(value) do + entries = entries + 1 + if entries > DECODE_LIMITS.maxTableEntries then return nil, false end + local keyCopy, keyOk = copyData(key, state, depth) + local childCopy, childOk = copyData(child, state, depth) + if keyOk == false or childOk == false or keyCopy == nil then return nil, false end + out[keyCopy] = childCopy + end + state.seen[value] = nil + return out, true +end + +local function isDataOnly(value, state, depth) + state = state or { seen = {}, nodes = 0 } + state.nodes = state.nodes + 1 + if state.nodes > DECODE_LIMITS.maxNodes then return false end + local kind = type(value) + if kind == "nil" or kind == "boolean" or kind == "number" then return true end + if kind == "string" then return #value <= DECODE_LIMITS.maxStringBytes end + if kind ~= "table" or getmetatable(value) ~= nil then return false end + depth = (depth or 0) + 1 + if depth > DECODE_LIMITS.maxDepth or state.seen[value] then return false end + state.seen[value] = true + local entries = 0 + for key, child in pairs(value) do + entries = entries + 1 + if entries > DECODE_LIMITS.maxTableEntries + or not isDataOnly(key, state, depth) + or not isDataOnly(child, state, depth) then + return false + end + end + state.seen[value] = nil + return true +end + +function DatasetViews.new(fs, engineRequire) + assert(fs and fs.read and fs.getInfo, + "DatasetViews.new requires a readable filesystem") + return setmetatable({ fs = fs, engineRequire = engineRequire or require, + datasets = {} }, DatasetViews) +end + +function DatasetViews:_validate(version, inspected) + local sources, aggregate = {}, 0 + local modules = CacheContract.semanticModules(version) + for _, name in ipairs(CacheContract.optionalSemanticModules(version)) do + local path = inspected.prefix .. "data/generated/" .. name .. ".lua" + if self.fs.getInfo(path, "file") then modules[#modules + 1] = name end + end + for _, name in ipairs(modules) do + local path = inspected.prefix .. "data/generated/" .. name .. ".lua" + local info = self.fs.getInfo(path, "file") + local size = info and info.size + if size and size > DECODE_LIMITS.maxBytes then return nil, name .. ": size limit" end + aggregate = aggregate + (size or 0) + if aggregate > MAX_AGGREGATE_BYTES then return nil, "aggregate size limit" end + end + aggregate = 0 + for _, name in ipairs(modules) do + local path = inspected.prefix .. "data/generated/" .. name .. ".lua" + local source = self.fs.read(path) + if type(source) ~= "string" then return nil, "missing " .. name end + aggregate = aggregate + #source + if aggregate > MAX_AGGREGATE_BYTES then return nil, "aggregate size limit" end + local value, err = decode(source) + if type(value) ~= "table" then + return nil, name .. ": " .. tostring(err or "non-table root") + end + sources[name] = source + end + return sources end function DatasetViews:_module(view, root) local moduleName = view.modules[root] if not moduleName then return nil end + local source = view.sources[moduleName] + if source == nil then return nil end local cached = view.moduleCache[moduleName] - if cached ~= nil then return cached or nil end - local path = view.prefix .. "data/generated/" .. moduleName .. ".lua" - local source = self.fs.read(path) - local value = compileTable(source, "@" .. path) - view.moduleCache[moduleName] = value or false + if cached and cached.source == source then return cached.value end + local value = assert(decode(source)) + view.moduleCache[moduleName] = { source = source, value = value } return value end function DatasetViews:_data(view) if view.data then return view.data end local data = {} - for root in pairs(view.modules) do - local value = self:_module(view, root) - if value ~= nil then data[root] = value end - end + setmetatable(data, { + __index = function(target, root) + local value = self:_module(view, root) + if value ~= nil then rawset(target, root, value) end + return value + end, + }) + DatasetHydration.apply(data, view.version, self.engineRequire) view.data = data return data end local function registryBase(data, target) - return function() - return resolvePath(data, target) - end + return function() return resolvePath(data, target) end end function DatasetViews:_registries(view) @@ -123,10 +206,11 @@ function DatasetViews:_registries(view) for name, catalogSpec in pairs(Schemas.REGISTRIES) do local spec = Schemas.shapeFor(name, catalogSpec, view.generation) local registry = Registry.new(name, spec) - if spec.target then registry.base = registryBase(data, spec.target) end + local target = Schemas.targetFor(name, catalogSpec, view.generation) + if target then registry.base = registryBase(data, target) end registries[name] = registry end - Builtins.install(registries, data, view.generation) + Builtins.install(registries, data, view.generation, self.engineRequire) for _, registry in pairs(registries) do registry:freeze() end view.registries = registries return registries @@ -134,26 +218,30 @@ end function DatasetViews:_registry(view, name) local registry = self:_registries(view)[name] - - local function valueAt(id) + local function rawAt(id) local value = registry and registry:get(id) - if value == nil then return nil end - return Merge.deepCopy(value) + if value == nil or not isDataOnly(value) then return nil end + return value + end + local function valueAt(id) + local value = rawAt(id) + if value == nil then return nil end + return (copyData(value)) end - return { get = function(_, id) if type(id) ~= "string" or id == "" then return nil end return valueAt(id) end, has = function(_, id) - return valueAt(id) ~= nil + if type(id) ~= "string" or id == "" then return false end + return rawAt(id) ~= nil end, each = function() local ids = {} if registry then - for id in registry:each() do - if type(id) == "string" then ids[#ids + 1] = id end + for id, value in registry:each() do + if type(id) == "string" and isDataOnly(value) then ids[#ids + 1] = id end end end table.sort(ids) @@ -171,20 +259,15 @@ end function DatasetViews:_assets(view) local service = self local assets = {} - - function assets:path(path) - return view.prefix .. assetRelative(path) - end - + function assets:path(path) return view.prefix .. assetRelative(path) end function assets:info(path) local full = self:path(path) local info = service.fs.getInfo and service.fs.getInfo(full, "file") - if not info then return nil end - local out = { type = info.type } + if not info or (info.type and info.type ~= "file") then return nil end + local out = { type = "file" } if info.size ~= nil then out.size = info.size end return out end - return assets end @@ -192,28 +275,54 @@ function DatasetViews:open(version) if type(version) ~= "string" or not GameVersion.VERSIONS[version] then return nil, "unknown_version" end - local internal = self.datasets[version] - if not internal then - local prefix = GameVersion.cachePrefix(version) - local marker = self.fs.read(prefix .. "rom-cache.complete") - if not CacheFormat.matches(version, marker) then - return nil, "not_imported" - end - - local generation = GameVersion.generation(version) - internal = { - version = version, generation = generation, prefix = prefix, - modules = generation == 2 and GEN2_MODULES or GEN1_MODULES, - moduleCache = {}, - } - internal.registries = self:_registries(internal) - self.datasets[version] = internal + local inspected, reason, detail = CacheContract.inspect(version, self.fs, { + allowSource = true, semantic = true, + }) + if not inspected then + self.datasets[version] = nil + Logger.warn("dataset %s unavailable: %s", version, detail) + return nil, reason end - local view = { - version = version, - generation = internal.generation, - content = {}, - } + local sources, invalid = self:_validate(version, inspected) + if not sources then + self.datasets[version] = nil + Logger.warn("dataset %s cache rejected: %s", version, invalid) + return nil, "invalid_cache" + end + + local internal = self.datasets[version] + local changed = not internal or internal.prefix ~= inspected.prefix + if internal and not changed then + for name, source in pairs(internal.sources) do + if sources[name] ~= source then changed = true; break end + end + end + if internal and not changed then + for name, source in pairs(sources) do + if internal.sources[name] ~= source then changed = true; break end + end + end + if changed then + internal = { + version = version, + generation = GameVersion.generation(version), + prefix = inspected.prefix, + modules = GameVersion.generation(version) == 2 and GEN2_ROOTS or GEN1_ROOTS, + moduleCache = {}, sources = sources, + } + local ok, buildError = pcall(function() internal.registries = self:_registries(internal) end) + if not ok then + Logger.warn("dataset %s semantic hydration failed: %s", version, + tostring(buildError)) + self.datasets[version] = nil + return nil, "invalid_cache" + end + self.datasets[version] = internal + else + internal.sources = sources + end + + local view = { version = version, generation = internal.generation, content = {} } for name in pairs(Schemas.REGISTRIES) do view.content[name] = self:_registry(internal, name) end @@ -221,7 +330,6 @@ function DatasetViews:open(version) view.content[alias] = view.content[canonical] end view.assets = self:_assets(internal) - return view end diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 3dbba98e..caba3b90 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -987,7 +987,7 @@ function Loader:_api(mod) open = function(_, version) if not loader.datasetViews then local DatasetViews = engineRequire("src.mods.DatasetViews") - loader.datasetViews = DatasetViews.new(loader.fs) + loader.datasetViews = DatasetViews.new(loader.fs, engineRequire) end return loader.datasetViews:open(version) end, diff --git a/tests/engine/dataset_views_no_mod_parity.lua b/tests/engine/dataset_views_no_mod_parity.lua new file mode 100644 index 00000000..1b7a4cbe --- /dev/null +++ b/tests/engine/dataset_views_no_mod_parity.lua @@ -0,0 +1,22 @@ +-- Dedicated Route B no-mod parity: the additive facade remains cold. +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") + +local reads = 0 +local fs = T.sdk.memfs({}) +local read = fs.read +fs.read = function(path) + if path:match("^[a-z]+/data/generated/") + or path:match("^[a-z]+/rom%-cache%.complete$") then + reads = reads + 1 + end + return read(path) +end +local data = { pokemon = { ACTIVE = { id = "ACTIVE", nested = { n = 1 } } } } +local run = T.sdk.loadNone({ fs = fs, data = data, generation = 1 }) +T.eq(#run.errors, 0, "no-mod load remains clean") +T.eq(run.loader.datasetViews, nil, "no-mod load does not allocate dataset service") +T.eq(reads, 0, "no-mod load performs no imported dataset reads") +T.eq(data.pokemon.ACTIVE.nested.n, 1, "no-mod data remains unchanged") +run.release() +T.finish("dataset_views_no_mod_parity") diff --git a/tests/engine/generated_data_decoder_test.lua b/tests/engine/generated_data_decoder_test.lua new file mode 100644 index 00000000..dd0672f3 --- /dev/null +++ b/tests/engine/generated_data_decoder_test.lua @@ -0,0 +1,37 @@ +-- Restricted generated-data grammar and resource limits. +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local SaveSerializer = require("src.core.SaveSerializer") + +local limits = { + allowArray = true, allowComments = true, + maxBytes = 256, maxDepth = 4, maxNodes = 7, + maxStringBytes = 8, maxTableEntries = 5, rootName = "generated data", +} + +local value, err = SaveSerializer.decode( + '-- Generated by tools/build_data.py. DO NOT EDIT.\n' + .. 'return { rows = { "A", "B" }, [3] = true }\n', limits) +T.same(value, { rows = { "A", "B" }, [3] = true }, + "LuaWriter array and keyed-table grammar decodes as data") +T.eq(err, nil, "valid generated data has no error") + +local invalid = { + { "return function() end", "function literal" }, + { "owned = true; return {}", "global assignment" }, + { "return {}; print('x')", "trailing executable syntax" }, + { string.char(27) .. "Lua", "binary chunk" }, + { "return " .. string.rep(" ", 260) .. "{}", "source-size limit" }, + { 'return { a = { b = { c = { d = { e = {} } } } } }', "depth limit" }, + { 'return { a = "123456789" }', "string limit" }, + { 'return { 1, 2, 3, 4, 5, 6 }', "table-entry limit" }, + { 'return { a = 1, b = 2, c = 3, d = 4, e = { f = 6, g = 7 } }', + "node limit" }, + { "return 7", "non-table root" }, +} +for _, row in ipairs(invalid) do + local got = SaveSerializer.decode(row[1], limits) + T.eq(got, nil, row[2] .. " fails closed") +end + +T.finish("generated_data_decoder") diff --git a/tests/engine/rom_importer_source_tree_test.lua b/tests/engine/rom_importer_source_tree_test.lua index 508ba043..f5490421 100644 --- a/tests/engine/rom_importer_source_tree_test.lua +++ b/tests/engine/rom_importer_source_tree_test.lua @@ -1,32 +1,36 @@ --- sourceTreeHasData must use each version's required-file list. Gold's --- cache has no Gen 1 trade art / pikachu.png; validating it against --- REQUIRED_FILES made a Gold source tree look incomplete forever. +-- Shared cache readiness uses the selected version's source-tree contract. package.path = "./?.lua;./?/init.lua;" .. package.path local T = require("tests.harness") -local check = T.check +local CacheContract = require("src.import.CacheContract") +local GameVersion = require("src.core.GameVersion") -local f = assert(io.open("src/import/RomImporter.lua", "r")) -local src = f:read("*a") -f:close() +local present = {} +for _, path in ipairs(CacheContract.requiredFiles("gold")) do + present[GameVersion.cachePrefix("gold") .. path] = true +end +local fs = { + read = function() return nil end, + getInfo = function(path) return present[path] and { type = "file" } or nil end, + getRealDirectory = function(path) return present[path] and "/source" or nil end, + getSource = function() return "/source" end, +} -local start = src:find("local function sourceTreeHasData", 1, true) -check(start ~= nil, "sourceTreeHasData is defined") -local finish = src:find("\nfunction RomImporter.isReady", start, true) -check(finish ~= nil, "sourceTreeHasData ends before isReady") -local body = src:sub(start, finish) +local inspected = CacheContract.inspect("gold", fs, { allowSource = true }) +T.eq(inspected and inspected.kind, "source", + "Gold source tree uses Gold's required-file contract") -check(body:find("requiredFilesFor", 1, true) ~= nil, - "sourceTreeHasData uses requiredFilesFor (Gold override, not Gen 1 only)") -check(body:find("ipairs(REQUIRED_FILES)", 1, true) == nil, - "sourceTreeHasData does not iterate the Gen 1 REQUIRED_FILES list raw") +present["gold/assets/generated/battle/hud/balls.png"] = nil +T.eq(CacheContract.inspect("gold", fs, { allowSource = true }), nil, + "missing Gold-only trainer HUD asset makes the source tree incomplete") -local helperStart = src:find("local function requiredFilesFor", 1, true) -check(helperStart ~= nil, "requiredFilesFor helper exists") -local helper = src:sub(helperStart, start) -check(helper:find("VERSION_REQUIRED_FILES_OVERRIDE", 1, true) ~= nil, - "requiredFilesFor consults VERSION_REQUIRED_FILES_OVERRIDE") -check(src:find('"assets/generated/battle/hud/balls.png"', 1, true) ~= nil, - "Gold caches require the trainer HUD ball sheet") +local required = {} +for _, path in ipairs(CacheContract.requiredFiles("gold")) do required[path] = true end +T.eq(required["data/generated/rom_text.lua"], true, + "Gold requires generated ROM text") +T.eq(required["assets/generated/pc/mail_item.png"], true, + "Gold requires PC mail art") +T.eq(required["assets/generated/trade/game_boy.png"], nil, + "Gold does not inherit the Gen 1 trade-art contract") T.finish() diff --git a/tests/engine/trade_art_import.lua b/tests/engine/trade_art_import.lua index 71736adf..8e2318cd 100644 --- a/tests/engine/trade_art_import.lua +++ b/tests/engine/trade_art_import.lua @@ -83,16 +83,12 @@ if extractor then "field.lua publishes tradeArt") end --- a cache imported before #750 has none of the art; listing one of the --- files in REQUIRED_FILES is what makes it re-import -local importer = readFile("src/import/RomImporter.lua") -T.check(importer ~= nil, "src/import/RomImporter.lua is readable") -if importer then - local required = importer:match("local REQUIRED_FILES = {(.-)\n}") - T.check(required ~= nil, "REQUIRED_FILES parses") - T.check(required ~= nil and required:find( - '"assets/generated/trade/game_boy.png"', 1, true) ~= nil, - "REQUIRED_FILES makes pre-#750 caches re-import the trade art") -end +-- a cache imported before #750 has none of the art; the shared readiness +-- contract is what makes it re-import. +local CacheContract = require("src.import.CacheContract") +local required = {} +for _, path in ipairs(CacheContract.requiredFiles("red")) do required[path] = true end +T.eq(required["assets/generated/trade/game_boy.png"], true, + "required-file contract makes pre-#750 caches re-import the trade art") T.finish("trade art import") diff --git a/tests/modkit/cases/dataset_views.lua b/tests/modkit/cases/dataset_views.lua index 59edb614..d655fdae 100644 --- a/tests/modkit/cases/dataset_views.lua +++ b/tests/modkit/cases/dataset_views.lua @@ -1,251 +1,288 @@ --- Cross-version imported datasets through the public sandboxed mod API. --- The view is semantic and read-only: registry-shaped records in, detached --- copies out, with generated assets kept inside the selected version namespace. - +-- Sandboxed public-API coverage for imported semantic dataset views. package.path = "./?.lua;./?/init.lua;" .. package.path local T = require("tests.modkit") +local Fixture = require("tests.modkit.dataset_view_fixture") local GameVersion = require("src.core.GameVersion") local CacheFs = require("src.import.CacheFs") -local CacheFormat = require("src.import.CacheFormat") - -local function serialized(value) - local function encode(v) - if type(v) == "string" then return string.format("%q", v) end - if type(v) == "number" or type(v) == "boolean" then return tostring(v) end - local keys = {} - for key in pairs(v) do keys[#keys + 1] = key end - table.sort(keys, function(a, b) return tostring(a) < tostring(b) end) - local out = { "{" } - for _, key in ipairs(keys) do - out[#out + 1] = "[" .. encode(key) .. "]=" .. encode(v[key]) .. "," - end - out[#out + 1] = "}" - return table.concat(out) - end - return "return " .. encode(value) -end - -local files = { - ["mods/dataset_probe/manifest.json"] = [[{ - "id": "dataset_probe", - "name": "Dataset Probe", - "version": "1.0.0", - "entry": "main.lua", - "api": 2, - "games": ["all"] - }]], - ["mods/dataset_probe/main.lua"] = [[ -local mod = ... -local out = {} -for _, version in ipairs({ "red", "blue", "yellow", "gold" }) do - local view, reason = mod.datasets:open(version) - if not view then - out[version] = { reason = reason } - else - local registry = view.content.pokemon - local first = registry:get("FIXMON") - first.name = "MUTATED" - local ids = {} - for id in registry:each() do ids[#ids + 1] = id end - local immune = view.content.type_chart:get("NORMAL>GHOST") - local steel = view.content.type_chart:get("STEEL") - out[version] = { - version = view.version, - generation = view.generation, - name = registry:get("FIXMON").name, - has = registry:has("FIXMON"), - ids = ids, - writable = registry.register ~= nil or registry.patch ~= nil - or registry.override ~= nil or registry.remove ~= nil, - sprite = view.assets:path(registry:get("FIXMON").spriteFront), - spriteInfo = view.assets:info(registry:get("FIXMON").spriteFront), - normalGhost = immune and immune.multiplier, - steelCategory = steel and steel.category, - } - end -end -local missing, missingReason = mod.datasets:open("silver") -local unknown, unknownReason = mod.datasets:open("crystal") -out.silver = { present = missing ~= nil, reason = missingReason } -out.crystal = { present = unknown ~= nil, reason = unknownReason } -local gold = mod.datasets:open("gold") -out.assetEscape = pcall(function() gold.assets:path("../save.lua") end) -out.rawAssetRead = gold.assets.read ~= nil -mod.exports.result = out - ]], -} - -local isolationMods = { - ["mods/dataset_mutator/manifest.json"] = [[{ - "id": "dataset_mutator", - "name": "Dataset Mutator", - "version": "1.0.0", - "entry": "main.lua", - "api": 2, - "games": ["all"] - }]], - ["mods/dataset_mutator/main.lua"] = [[ -local mod = ... -local view = assert(mod.datasets:open("red")) -view.content.pokemon.get = function() return { name = "POISONED" } end - ]], - ["mods/dataset_observer/manifest.json"] = [[{ - "id": "dataset_observer", - "name": "Dataset Observer", - "version": "1.0.0", - "entry": "main.lua", - "api": 2, - "games": ["all"], - "dependencies": ["dataset_mutator"] - }]], - ["mods/dataset_observer/main.lua"] = [[ -local mod = ... -local view = assert(mod.datasets:open("red")) -mod.exports.name = view.content.pokemon:get("FIXMON").name - ]], -} - -local labels = { - red = "RED SOURCE", blue = "BLUE SOURCE", - yellow = "YELLOW SOURCE", gold = "GOLD SOURCE", -} - -for _, version in ipairs({ "red", "blue", "yellow", "gold" }) do - local prefix = GameVersion.cachePrefix(version) - files[prefix .. "rom-cache.complete"] = - "rom-cache-v10:" .. GameVersion.info(version).sha1 - files[prefix .. "data/generated/pokemon.lua"] = serialized({ - FIXMON = { - id = "FIXMON", name = labels[version], dex = version == "gold" and 252 or 152, - spriteFront = "assets/generated/battle/front/fixmon.png", - }, - ALPHA = { id = "ALPHA", name = "ALPHA", dex = 1, - spriteFront = "assets/generated/battle/front/alpha.png" }, - }) - files[prefix .. "data/generated/type_chart.lua"] = serialized({ - matchups = { { attacker = "NORMAL", defender = "GHOST", multiplier = 0 } }, - types = version == "gold" and { - STEEL = { name = "STEEL", category = "physical", index = 9 }, - } or {}, - }) - files[prefix .. "assets/generated/battle/front/fixmon.png"] = "png-" .. version -end - --- An otherwise plausible Silver tree has no completion marker. A view must --- not execute or expose it. -files[GameVersion.cachePrefix("silver") .. "data/generated/pokemon.lua"] = - serialized({ FIXMON = { id = "FIXMON", name = "UNVERIFIED" } }) - -T.eq(CacheFormat.markerFor("red"), - "rom-cache-v10:" .. GameVersion.info("red").sha1, - "cache marker follows the importer format and version hash") -T.eq(CacheFormat.matches("silver", "rom-cache-v9:stale"), false, - "stale cache formats are rejected") -T.eq(CacheFormat.markerFor("crystal"), nil, - "unknown versions have no valid cache marker") +local Schemas = require("src.mods.Schemas") local originalVersion = GameVersion.get() local originalPrefix = CacheFs.prefix GameVersion.set("red") -local function isVersionCachePath(path) - for _, version in ipairs(GameVersion.ORDER) do - local prefix = GameVersion.cachePrefix(version) - if path:sub(1, #prefix) == prefix then return true end - end - return false +local files = {} +for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver" }) do + Fixture.cache(files, version) end +local probe = Fixture.addMod(files, "dataset_probe", [[ +local mod = ... +local out = {} +for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver" }) do + local view, reason = mod.datasets:open(version) + local mon = view and view.content.pokemon:get("FIXMON") + if mon then mon.name = "MUTATED" end + local ids = {} + if view then + for id, value in view.content.pokemon:each() do + ids[#ids + 1] = id + if value then value.name = "EACH_MUTATED" end + end + end + out[version] = { + reason = reason, generation = view and view.generation, + name = view and view.content.pokemon:get("FIXMON").name, + has = view and view.content.pokemon:has("FIXMON"), ids = ids, + aliasScripts = view and view.content.scripts == view.content.map_scripts, + aliasUi = view and view.content.ui == view.content.screens, + readOnly = view and view.content.pokemon.register == nil + and view.content.pokemon.patch == nil and view.content.pokemon.remove == nil, + } +end +local yellow = assert(mod.datasets:open("yellow")) +out.yellowOldMan = yellow.content.field:get("oldManBattle") +out.yellowBoot = yellow.content.field:get("boot") +local red = assert(mod.datasets:open("red")) +out.redBoot = red.content.field:get("boot") +out.redConstants = red.content.constants:get("dexSize") +local gold = assert(mod.datasets:open("gold")) +out.registryCount = 0 +for _, registry in pairs(gold.content) do + if type(registry) == "table" then out.registryCount = out.registryCount + 1 end +end +out.foresight = gold.content.type_chart:get("NORMAL>GHOST") +out.held = gold.content.held_items:get("LEFTOVERS") +out.continuations, out.moves, out.assets = {}, {}, {} +for _, id in ipairs({ "CROBAT", "BELLOSSOM", "POLITOED", "SLOWKING", + "STEELIX", "SCIZOR", "KINGDRA", "PORYGON2", "BLISSEY" }) do + local row = gold.content.pokemon:get(id) + out.continuations[id] = row and row.dex + out.assets[id] = row and { + gold.assets:info(row.spriteFront), gold.assets:info(row.spriteBack) } +end +for _, id in ipairs({ "IRON_TAIL", "METAL_CLAW", "STEEL_WING", "RAIN_DANCE", + "SUNNY_DAY", "SANDSTORM", "SLUDGE_BOMB", "SHADOW_BALL" }) do + out.moves[id] = gold.content.moves:has(id) +end +out.steel = gold.content.type_chart:has("STEEL") +out.executableBuiltinHidden = gold.content.statuses:get("sleep") == nil +out.assetDirectory = gold.assets:info("assets/generated/battle/front") +out.assetRejects = {} +for _, path in ipairs({ "/assets/generated/x", "assets\\generated\\x", + "assets/generated/../x", "assets/generated/./x", "other/x", + "assets/generated/x\1" }) do + out.assetRejects[#out.assetRejects + 1] = pcall(gold.assets.path, gold.assets, path) +end +local unknown, unknownReason = mod.datasets:open("crystal") +out.unknown = { unknown ~= nil, unknownReason } +mod.exports.result = out +]]) --- No mod means the new service is completely cold. Count only cross-version --- cache reads so the loader's ordinary discovery/state reads do not matter. -local vanillaFiles = {} -for path, body in pairs(files) do - if not path:match("^mods/") then - vanillaFiles[path] = body - end -end -local vanillaFs = T.sdk.memfs(vanillaFiles) -local vanillaReads = 0 -local vanillaRead = vanillaFs.read -vanillaFs.read = function(path) - if isVersionCachePath(path) then - vanillaReads = vanillaReads + 1 - end - return vanillaRead(path) -end -local vanillaData = { pokemon = { ACTIVE = { id = "ACTIVE", name = "ACTIVE" } } } -local vanilla = T.sdk.loadNone({ fs = vanillaFs, data = vanillaData }) -T.eq(#vanilla.errors, 0, "no-mod loader remains clean") -T.eq(vanillaReads, 0, "no mod performs no imported-dataset reads") -T.eq(vanillaData.pokemon.ACTIVE.name, "ACTIVE", - "no mod leaves the active dataset unchanged") -vanilla.release() - -local activeData = { pokemon = { ACTIVE = { id = "ACTIVE", name = "ACTIVE" } } } -local run = T.sdk.loadMods({ "mods/dataset_probe" }, { - fs = T.sdk.memfs(files), data = activeData, generation = 1, +local active = { pokemon = { ACTIVE = { id = "ACTIVE", nested = { n = 1 } } } } +local run = T.sdk.loadMods({ probe }, { + fs = T.sdk.memfs(files), data = active, generation = 1, }) -T.eq(#run.errors, 0, - "sandboxed dataset probe loads clean: " .. tostring(run.errors[1])) -local out = run.loader.exports.dataset_probe - and run.loader.exports.dataset_probe.result or {} - -for _, version in ipairs({ "red", "blue", "yellow", "gold" }) do - local got = out[version] or {} - T.eq(got.version, version, version .. " view reports its selected version") - T.eq(got.generation, version == "gold" and 2 or 1, - version .. " view reports the selected generation") - T.eq(got.name, labels[version], - version .. " returns its own semantic species record") - T.eq(got.has, true, version .. " registry has() reads the imported view") - T.same(got.ids, { "ALPHA", "FIXMON" }, - version .. " registry each() is deterministic") - T.eq(got.writable, false, version .. " registry exposes no write verbs") - T.eq(got.sprite, - GameVersion.cachePrefix(version) .. "assets/generated/battle/front/fixmon.png", - version .. " asset path stays in its cache namespace") - T.same(got.spriteInfo, { type = "file" }, - version .. " asset info is sanitized metadata") - T.eq(got.normalGhost, 0, - version .. " exposes structured matchup ids semantically") +T.eq(#run.errors, 0, "dataset public probe loads clean") +local out = run.loader.exports.dataset_probe.result +for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver" }) do + T.eq(out[version].reason, nil, version .. " opens") + T.eq(out[version].generation, + (version == "gold" or version == "silver") and 2 or 1, + version .. " reports selected generation") + T.eq(out[version].name, "FIXMON", version .. " get/each values are detached") + T.eq(out[version].has, true, version .. " has reads without exposing a copy") + T.eq(out[version].aliasScripts, true, version .. " scripts alias is canonical") + T.eq(out[version].aliasUi, true, version .. " ui alias is canonical") + T.eq(out[version].readOnly, true, version .. " registry has no write verbs") end -T.eq(out.gold and out.gold.steelCategory, "physical", - "Gold exposes imported Steel as a semantic type record") - -T.same(out.silver, { present = false, reason = "not_imported" }, - "an unverified cache is unavailable") -T.same(out.crystal, { present = false, reason = "unknown_version" }, - "an unknown version fails with a stable reason") -T.eq(out.assetEscape, false, "dataset asset paths reject traversal") -T.eq(out.rawAssetRead, false, "dataset assets expose no raw byte reader") -T.eq(activeData.pokemon.ACTIVE.name, "ACTIVE", - "cross-version reads do not mutate the active game dataset") -T.eq(activeData.pokemon.FIXMON, nil, - "cross-version records are not merged into active game data") -T.eq(GameVersion.get(), "red", "dataset reads do not switch the active game") -T.eq(CacheFs.prefix, originalPrefix, "dataset reads do not change cache prefix state") - +T.eq(out.redConstants, 1, "Red derives dexSize through canonical defaults") +T.eq(out.redBoot and out.redBoot.screens and out.redBoot.screens.splash, + "IntroMovie", "Red uses canonical boot defaults") +T.eq(out.yellowOldMan and out.yellowOldMan.species, "RATTATA", + "Yellow applies canonical correction") +T.eq(out.yellowBoot and out.yellowBoot.screens and out.yellowBoot.screens.splash, + "YellowIntro", + "Yellow uses its canonical splash") +T.eq(out.foresight and out.foresight.multiplier, 0, + "Gold appends Foresight matchup rows") +T.eq(out.held and out.held.heldEffect, "HELD_LEFTOVERS", "Gold derives held_items") +for _, id in ipairs(Fixture.CONTINUATIONS) do + T.check(out.continuations[id] ~= nil, "Gold exposes " .. id) + T.same(out.assets[id], { { type = "file" }, { type = "file" } }, + "Gold exposes namespaced front/back assets for " .. id) +end +for _, id in ipairs(Fixture.MOVES) do T.eq(out.moves[id], true, "Gold exposes " .. id) end +T.eq(out.steel, true, "Gold exposes Steel") +local registryCount = 0 +for _ in pairs(Schemas.REGISTRIES) do registryCount = registryCount + 1 end +for _ in pairs(Schemas.ALIASES) do registryCount = registryCount + 1 end +T.eq(out.registryCount, registryCount, + "view exposes every canonical registry and alias") +T.eq(out.executableBuiltinHidden, true, + "engine records containing closures do not cross the data-only facade") +T.eq(out.assetDirectory, nil, "asset info does not expose directories") +for index, accepted in ipairs(out.assetRejects) do + T.eq(accepted, false, "asset rejection " .. index) +end +T.same(out.unknown, { false, "unknown_version" }, "unknown version fails stably") +T.eq(active.pokemon.ACTIVE.nested.n, 1, "active Data stays unchanged") +T.eq(active.pokemon.FIXMON, nil, "inactive records do not merge into active Data") +T.eq(GameVersion.get(), "red", "active GameVersion stays unchanged") +T.eq(CacheFs.prefix, originalPrefix, "CacheFs prefix stays unchanged") run.release() -local isolationFiles = {} -for path, body in pairs(files) do - if not path:match("^mods/") then - isolationFiles[path] = body - end -end -for path, body in pairs(isolationMods) do isolationFiles[path] = body end -local isolationRun = T.sdk.loadMods({ - "mods/dataset_mutator", "mods/dataset_observer", -}, { fs = T.sdk.memfs(isolationFiles), - data = { pokemon = {} }, generation = 1 }) -T.eq(#isolationRun.errors, 0, "cross-mod isolation fixture loads cleanly") -T.eq(isolationRun.loader.exports.dataset_observer - and isolationRun.loader.exports.dataset_observer.name, - labels.red, "one mod cannot mutate another mod dataset facade") +-- Facade replacement and nested record mutation do not cross mod boundaries. +local isolation = {} +Fixture.cache(isolation, "red") +local mutator = Fixture.addMod(isolation, "dataset_mutator", [[ +local mod = ... +local view = assert(mod.datasets:open("red")) +view.content.pokemon.get = function() return { name = "POISONED" } end +local row = view.content.pokemon:get("FIXMON") +if row then row.name = "CHANGED" end +]]) +local observer = Fixture.addMod(isolation, "dataset_observer", [[ +local mod = ... +local view = assert(mod.datasets:open("red")) +mod.exports.name = view.content.pokemon:get("FIXMON").name +]]) +isolation["mods/dataset_observer/manifest.json"] = [[{ + "id": "dataset_observer", "name": "dataset_observer", "version": "1.0.0", + "entry": "main.lua", "api": 2, "games": ["all"], + "dependencies": ["dataset_mutator"] +}]] +local isolationRun = T.sdk.loadMods({ mutator, observer }, { + fs = T.sdk.memfs(isolation), data = { pokemon = {} }, generation = 1, +}) +T.eq(isolationRun.loader.exports.dataset_observer.name, "FIXMON", + "facade and record mutation cannot cross mods") isolationRun.release() +-- Marker-only, empty, partial, and stale-after-first-open caches fail closed. +local readiness = {} +local emptyPrefix = GameVersion.cachePrefix("red") +readiness[emptyPrefix .. "rom-cache.complete"] = + "rom-cache-v10:" .. GameVersion.info("red").sha1 +Fixture.cache(readiness, "blue") +readiness[GameVersion.cachePrefix("blue") .. "data/generated/moves.lua"] = nil +Fixture.cache(readiness, "gold") +local staleReads = 0 +local readinessFs = T.sdk.memfs(readiness) +local rawRead = readinessFs.read +readinessFs.read = function(path) + if path == GameVersion.cachePrefix("gold") .. "rom-cache.complete" then + staleReads = staleReads + 1 + if staleReads > 1 then return "rom-cache-v9:stale" end + end + return rawRead(path) +end +local readinessMod = Fixture.addMod(readiness, "readiness_probe", [[ +local mod = ... +local out = {} +for _, version in ipairs({ "red", "blue" }) do + local view, reason = mod.datasets:open(version) + out[version] = { view ~= nil, reason } +end +local first, firstReason = mod.datasets:open("gold") +local second, secondReason = mod.datasets:open("gold") +out.gold = { first ~= nil, firstReason, second ~= nil, secondReason } +mod.exports.result = out +]]) +local readinessRun = T.sdk.loadMods({ readinessMod }, { + fs = readinessFs, data = { pokemon = {} }, generation = 1, +}) +local readyOut = readinessRun.loader.exports.readiness_probe.result +T.same(readyOut.red, { false, "not_imported" }, "marker-only empty cache fails") +T.same(readyOut.blue, { false, "not_imported" }, "partial cache fails") +T.same(readyOut.gold, { true, nil, false, "not_imported" }, + "cached view is invalidated when marker becomes stale") +readinessRun.release() + +-- Removal followed by a fresh import cannot resurrect the pre-removal view. +local transition = {} +Fixture.cache(transition, "red") +local transitionFs = T.sdk.memfs(transition) +local transitionRead = transitionFs.read +local transitionMarkers = 0 +local reimportedPokemon = require("src.import.LuaWriter").encode({ + FIXMON = { id = "FIXMON", name = "REIMPORTED", dex = 1, + spriteFront = "assets/generated/battle/front/fixmon.png", + spriteBack = "assets/generated/battle/back/fixmon.png", frontSize = 5 }, +}) +transitionFs.read = function(path) + if path == GameVersion.cachePrefix("red") .. "rom-cache.complete" then + transitionMarkers = transitionMarkers + 1 + if transitionMarkers == 2 then return nil end + end + local body = transitionRead(path) + if transitionMarkers >= 3 + and path == GameVersion.cachePrefix("red") .. "data/generated/pokemon.lua" then + return reimportedPokemon + end + return body +end +local transitionMod = Fixture.addMod(transition, "transition_probe", [[ +local mod = ... +local out = {} +for index = 1, 3 do + local view, reason = mod.datasets:open("red") + out[index] = { view and view.content.pokemon:get("FIXMON").name, reason } +end +mod.exports.result = out +]]) +local transitionRun = T.sdk.loadMods({ transitionMod }, { + fs = transitionFs, data = { pokemon = {} }, generation = 1, +}) +T.same(transitionRun.loader.exports.transition_probe.result, { + { "FIXMON" }, { nil, "not_imported" }, { "REIMPORTED" }, +}, "remove and reimport transition rebuilds the semantic view") +transitionRun.release() + +-- Every hostile source is rejected as data, with one stable public reason. +local hostile = { + { "red", "return { BAD = function() return 1 end }" }, + { "blue", "owned = true; return {}" }, + { "yellow", "return 7" }, + { "gold", string.char(27) .. "Lua" }, + { "silver", "return {}; while true do end" }, +} +local hostileFiles, hostilePaths = {}, {} +for _, row in ipairs(hostile) do + Fixture.cache(hostileFiles, row[1], { pokemon = row[2] }) +end +local hostileMod = Fixture.addMod(hostileFiles, "hostile_probe", [[ +local mod = ... +local out = {} +for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver" }) do + local view, reason = mod.datasets:open(version) + out[version] = { view ~= nil, reason } +end +mod.exports.result = out +]]) +hostilePaths[1] = hostileMod +local previousHook, previousMask, previousCount +local beforeHook +if debug.gethook and debug.sethook then + previousHook, previousMask, previousCount = debug.gethook() + beforeHook = function() end + debug.sethook(beforeHook, "", 1000) +end +local hostileRun = T.sdk.loadMods(hostilePaths, { + fs = T.sdk.memfs(hostileFiles), data = { pokemon = {} }, generation = 1, +}) +local hostileOut = hostileRun.loader.exports.hostile_probe.result +for _, row in ipairs(hostile) do + T.same(hostileOut[row[1]], { false, "invalid_cache" }, + row[1] .. " hostile generated source fails closed") +end +T.eq(debug.gethook and debug.gethook(), beforeHook, + "dataset decoding preserves the caller debug hook") +if debug.sethook then + if previousHook then debug.sethook(previousHook, previousMask, previousCount) + else debug.sethook() end +end +hostileRun.release() + GameVersion.set(originalVersion) CacheFs.prefix = originalPrefix - T.finish("dataset_views") diff --git a/tests/modkit/cases/dataset_views_nontermination.lua b/tests/modkit/cases/dataset_views_nontermination.lua new file mode 100644 index 00000000..e895f06a --- /dev/null +++ b/tests/modkit/cases/dataset_views_nontermination.lua @@ -0,0 +1,21 @@ +-- A generated chunk that would loop if executed must be rejected as syntax. +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Fixture = require("tests.modkit.dataset_view_fixture") + +local files = {} +Fixture.cache(files, "red", { + pokemon = "while true do end; return {}", +}) +local modPath = Fixture.addMod(files, "nontermination_probe", [[ +local mod = ... +local view, reason = mod.datasets:open("red") +mod.exports.result = { view ~= nil, reason } +]]) +local run = T.sdk.loadMods({ modPath }, { + fs = T.sdk.memfs(files), data = { pokemon = {} }, generation = 1, +}) +T.same(run.loader.exports.nontermination_probe.result, + { false, "invalid_cache" }, "generated code is never executed") +run.release() +T.finish("dataset_views_nontermination") diff --git a/tests/modkit/dataset_view_fixture.lua b/tests/modkit/dataset_view_fixture.lua new file mode 100644 index 00000000..5d2e80bf --- /dev/null +++ b/tests/modkit/dataset_view_fixture.lua @@ -0,0 +1,121 @@ +local GameVersion = require("src.core.GameVersion") +local LuaWriter = require("src.import.LuaWriter") +local CacheContract = require("src.import.CacheContract") + +local Fixture = {} + +local GEN1_MODULES = { + "constants", "maps", "tilesets", "text", "text_pointers", + "trainer_headers", "font", "sprites", "pokemon", "moves", "items", + "type_chart", "trainers", "encounters", "field", "battle_anims", +} +local GEN1_OPTIONAL_MODULES = { "audio", "palettes", "icons" } + +local GEN2_MODULES = { + "pokemon", "moves", "items", "type_chart", "audio", "font", "maps", + "tilesets", "text", "trainers", "encounters", "sprites", "palettes", + "icons", "battle_anims", "constants", "landmarks", +} + +local CONTINUATIONS = { + "CROBAT", "BELLOSSOM", "POLITOED", "SLOWKING", "STEELIX", + "SCIZOR", "KINGDRA", "PORYGON2", "BLISSEY", +} + +local MOVES = { + "IRON_TAIL", "METAL_CLAW", "STEEL_WING", "RAIN_DANCE", "SUNNY_DAY", + "SANDSTORM", "SLUDGE_BOMB", "SHADOW_BALL", +} + +local function species(id, dex, generation) + local row = { + id = id, name = id, dex = dex, + spriteFront = "assets/generated/battle/front/" .. id:lower() .. ".png", + spriteBack = "assets/generated/battle/back/" .. id:lower() .. ".png", + } + if generation == 2 then row.picSize = 5 else row.frontSize = 5 end + return row +end + +local function defaults(version) + local generation = GameVersion.generation(version) + local pokemon = { FIXMON = species("FIXMON", 1, generation) } + local moves = {} + local items = {} + local typeChart = { + matchups = { { attacker = "NORMAL", defender = "ROCK", multiplier = 5 } }, + types = {}, + } + if generation == 2 then + for index, id in ipairs(CONTINUATIONS) do + pokemon[id] = species(id, 168 + index, generation) + end + for index, id in ipairs(MOVES) do + moves[id] = { id = id, name = id, index = index, type = "STEEL", + power = 50, accuracy = 100, pp = 15, effect = "NO_ADDITIONAL_EFFECT" } + end + typeChart.types.STEEL = { name = "STEEL", category = "physical", index = 9 } + typeChart.foresightMatchups = { + { attacker = "NORMAL", defender = "GHOST", multiplier = 0 }, + } + items.LEFTOVERS = { id = "LEFTOVERS", name = "LEFTOVERS", price = 0, + heldEffect = "HELD_LEFTOVERS", heldParameter = 0 } + end + return { + constants = {}, maps = {}, tilesets = {}, text = {}, text_pointers = {}, + trainer_headers = {}, font = {}, sprites = {}, pokemon = pokemon, + moves = moves, items = items, type_chart = typeChart, trainers = {}, + encounters = {}, field = { oakSpeech = {} }, battle_anims = {}, + audio = {}, palettes = {}, icons = {}, landmarks = {}, + } +end + +function Fixture.cache(files, version, overrides) + local prefix = GameVersion.cachePrefix(version) + local generation = GameVersion.generation(version) + local values = defaults(version) + for name, value in pairs(overrides or {}) do values[name] = value end + files[prefix .. "rom-cache.complete"] = + "rom-cache-v10:" .. GameVersion.info(version).sha1 + for _, name in ipairs(generation == 2 and GEN2_MODULES or GEN1_MODULES) do + local value = values[name] + files[prefix .. "data/generated/" .. name .. ".lua"] = + type(value) == "string" and value or LuaWriter.encode(value or {}) + end + if generation == 1 then + for _, name in ipairs(GEN1_OPTIONAL_MODULES) do + files[prefix .. "data/generated/" .. name .. ".lua"] = + LuaWriter.encode(values[name] or {}) + end + end + for _, path in ipairs(CacheContract.requiredFiles(version)) do + if files[prefix .. path] == nil then + files[prefix .. path] = path:match("%.lua$") and LuaWriter.encode({}) or "fixture" + end + end + if type(values.pokemon) == "table" then + for _, row in pairs(values.pokemon) do + if type(row) == "table" and row.spriteFront then + files[prefix .. row.spriteFront] = "front" + files[prefix .. row.spriteBack] = "back" + end + end + end + return files +end + +function Fixture.addMod(files, id, body) + files["mods/" .. id .. "/manifest.json"] = ([[{ + "id": %q, "name": %q, "version": "1.0.0", "entry": "main.lua", + "api": 2, "games": ["all"] + }]]):format(id, id) + files["mods/" .. id .. "/main.lua"] = body + return "mods/" .. id +end + +Fixture.GEN1_MODULES = GEN1_MODULES +Fixture.GEN2_MODULES = GEN2_MODULES +Fixture.CONTINUATIONS = CONTINUATIONS +Fixture.MOVES = MOVES + +return Fixture From 7e069df74062ae4ef24f0289b0c03e1b3b121fc6 Mon Sep 17 00:00:00 2001 From: MaxTomahawk Date: Mon, 24 Aug 2026 09:57:57 +0200 Subject: [PATCH 03/16] fix(mod-api): validate dataset roots lazily --- docs/modding.md | 22 ++- docs/modding/reference/registries.md | 48 +++++ docs/rfcs/0015-imported-dataset-views.md | 55 ++++-- src/mods/DatasetViews.lua | 177 +++++++++++++----- src/mods/Schemas.lua | 29 +++ .../engine/dataset_views_lazy_validation.lua | 46 +++++ tests/modkit/cases/dataset_views.lua | 116 +++++++++++- .../cases/dataset_views_nontermination.lua | 10 +- tests/modkit/dataset_view_fixture.lua | 19 +- 9 files changed, 442 insertions(+), 80 deletions(-) create mode 100644 tests/engine/dataset_views_lazy_validation.lua diff --git a/docs/modding.md b/docs/modding.md index 16e28741..2845f9f2 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -226,18 +226,28 @@ end `view.content` exposes the same registry names, aliases, generation routing, and data-only record shapes as `mod.content`, but only `get`, `has`, and `each`. Returned records are detached copies and cannot mutate either dataset. +Every generated base record passes the selected generation's existing public +schema before it is returned; extractor metadata beside record maps stays out +of the registry id space. A malformed record makes `get` return nil, `has` +return false, and `each` return no rows, and invalidates that dataset view. Records containing functions, userdata, threads, metatables, or cycles are not exposed. Each open call receives an independent facade, so one mod cannot replace another mod view method. Canonical boot shaping is included, such as Gen 1 defaults and Yellow corrections, and Gold's Foresight matchup rows and derived `held_items`. -The marker and every required generated module for the selected version are -rechecked on each open. Missing, partial, and stale imports return -`nil, "not_imported"`; malformed or resource-limit-breaking generated data -returns `nil, "invalid_cache"`. Generated modules are decoded with a bounded -literal-only grammar and are never executed. No raw ROM bytes or generated -source are exposed. `view.assets:path(relative)` and +`open` checks the completion marker, exact version-specific file inventory, +source/cache boundary, and file-size bounds without reading or decoding the +semantic modules. A root is read, bounded-decoded, normalized, and cached only +when a content operation first needs it. Each later view operation rechecks +readiness and the source bytes behind already cached roots; unchanged roots are +not decoded again. Missing, partial, and stale imports return +`nil, "not_imported"`. Malformed syntax, a resource-limit violation, or a +record that fails the public schema is discovered on first root access and +fails that operation closed; a later `open` against the same source returns +`nil, "invalid_cache"`. Generated modules use a bounded literal-only grammar +and are never executed. No raw ROM bytes or generated source are exposed. +`view.assets:path(relative)` and `view.assets:info(relative)` accept only `assets/generated/...` paths and keep them under the selected version cache prefix. The API never changes `mod.game`, the active `Data` table, `GameVersion`, or cache mount state. diff --git a/docs/modding/reference/registries.md b/docs/modding/reference/registries.md index be3ed622..96693e63 100644 --- a/docs/modding/reference/registries.md +++ b/docs/modding/reference/registries.md @@ -510,6 +510,26 @@ mod.content.item_effects:register("MOON_FLUTE", { use = fn, field = true }) mod.content.items:patch("POTION", { price = 100 }) ``` +### On Gold (Gen 2) + +- semantics: `record` +- target: `Data.items` + +The record differs; the registry name, the verbs and the id space +do not. + +| field | type | required | +|---|---|---| +| `ball` | balls id | no | +| `effect` | item_effects id | no | +| `id` | string | yes | +| `index` | integer 0..255 | no | +| `machine` | {kind, move, number} | no | +| `name` | string | yes | +| `needsTarget` | boolean | no | +| `price` | integer >= 0 | yes | +| `tossable` | boolean | no | + ## landmarks - semantics: `record` @@ -674,6 +694,34 @@ mod.content.move_effects:register("DRAIN_PP_EFFECT", { kind = "primary", run = f mod.content.moves:patch("BLIZZARD", { accuracy = 70 }) ``` +### On Gold (Gen 2) + +- semantics: `record` +- target: `Data.moves` + +The record differs; the registry name, the verbs and the id space +do not. + +| field | type | required | +|---|---|---| +| `accuracy` | integer 0..100 | yes | +| `anim` | any value | no | +| `category` | one of "physical" | "special" | "status" | no | +| `chargeText` | string | no | +| `counterable` | boolean | no | +| `effect` | move_effects id | yes | +| `fixedDamage` | integer >= 1 | function | no | +| `highCrit` | boolean | no | +| `id` | string | yes | +| `index` | integer 0..255 | no | +| `multiHit` | integer >= 1 | list of integer >= 1 | no | +| `name` | string | yes | +| `power` | integer 0..255 | yes | +| `pp` | integer 0..64 | yes | +| `priority` | integer -7..7 | no | +| `semiInvulnerable` | boolean | no | +| `type` | type_chart id | yes | + ## music - semantics: `record` diff --git a/docs/rfcs/0015-imported-dataset-views.md b/docs/rfcs/0015-imported-dataset-views.md index b1aa7c88..0bf7d9d9 100644 --- a/docs/rfcs/0015-imported-dataset-views.md +++ b/docs/rfcs/0015-imported-dataset-views.md @@ -70,9 +70,13 @@ semantic presence. the selected version's generation routing and the engine's existing `Schemas`, `Registry`, and `Builtins` normalization, so structured sources such as type matchups retain the same public ids used by the active -`mod.content` facade. No register, patch, override, or remove verb is exposed. Each call returns an -independent facade over the cached internal dataset, so facade mutation cannot cross -mod boundaries. +`mod.content` facade and extractor metadata beside record maps is not exposed +as a record id. Every generated base record is checked with that selected +generation's existing public schema before it can cross `get`, `has`, or +`each`; there is no dataset-specific duplicate schema. No register, patch, +override, or remove verb is exposed. Each call returns an independent facade +over the cached internal dataset, so facade mutation cannot cross mod +boundaries. `assets:path` returns the selected cache-prefixed virtual path. `assets:info` returns sanitized `type` and optional `size` metadata. Both @@ -80,16 +84,29 @@ accept only relative paths below `assets/generated/`, reject control characters, absolute paths, backslashes, and traversal, and expose no byte reader. -An unknown version returns `nil, "unknown_version"`. A missing, partial, or -stale cache returns `nil, "not_imported"`. A required module that is malformed -or exceeds the limits (8 MiB per module, 48 MiB aggregate, depth 64, 500,000 -values, 2 MiB per string, or 250,000 entries per table) returns -`nil, "invalid_cache"`; actionable detail is engine-logged but not exposed to -the mod. Generated Lua is decoded with the existing restricted -literal grammar and never executed. Functions, userdata, threads, metatables, -cycles, non-table roots, binary chunks, and trailing syntax cannot cross the -facade. Successful roots are decoded lazily and cached per selected version. -The API never exposes raw ROM bytes, generated source, host paths, or a mount. +An unknown version returns `nil, "unknown_version"`. `open` checks the current +completion marker, exact version-specific file inventory, source/cache +boundary, and available file sizes; it does not read or decode semantic +modules. A missing, partial, or stale cache returns `nil, "not_imported"`. + +The first content operation that needs a root reads it once, applies the +limits (8 MiB per module, 48 MiB aggregate, depth 64, 500,000 values, 2 MiB +per string, and 250,000 entries per table), decodes the restricted literal +grammar, applies canonical selected-version normalization, and caches the +detached root. Each later content or asset operation rechecks marker/file and +source-bound readiness. It also rereads the source bytes for already cached +roots; unchanged roots are not decoded again, while a changed root clears the +derived registry cache and is decoded on its next use. + +Malformed syntax, non-table roots, binary/trailing content, resource-limit +violations, and records that fail the public schema are therefore discovered +on first access rather than during `open`. The triggering `get` returns nil, +`has` returns false, or `each` returns no rows; the whole internal view is +invalidated, and a subsequent `open` against the unchanged source returns +`nil, "invalid_cache"`. Actionable detail is engine-logged but not exposed to +the mod. Generated Lua is never executed. Functions, userdata, threads, +metatables, and cycles cannot cross the facade. The API never exposes raw ROM +bytes, generated source, host paths, or a mount. ## Migration and compatibility @@ -106,7 +123,9 @@ The completion marker and per-version required-file rules live in the pure, injected `CacheContract` shared by the importer and dataset service. It also defines source-tree behavior. Neither consumer mutates `CacheFs.prefix` while checking readiness. Every `open` revalidates the contract and required module -shapes; a stale/remove/reimport transition evicts the previous semantic view. +inventory without semantic decoding. Every view operation rechecks that +readiness before serving cached state; a stale/remove/reimport transition +evicts the previous semantic view. ## Verification @@ -117,8 +136,12 @@ shapes; a stale/remove/reimport transition evicts the previous semantic view. version-prefixed generated assets, traversal rejection, stable failure reasons, and stale-marker rejection. - It also proves missing/empty/partial/stale/remove/reimport behavior, hostile - generated-source rejection, canonical Gen 1/Yellow/Gold hydration, and the - approved Kanto+ Gold records and assets. + generated-source and malformed-record rejection, canonical Gen + 1/Yellow/Gold hydration, active Red/Blue/Yellow isolation while reading Gold, + and the approved Kanto+ Gold records and assets. +- `tests/engine/dataset_views_lazy_validation.lua` counts semantic reads and + decoder calls to prove `open` decodes nothing, unused roots stay unread, and + repeated access does not re-decode an unchanged cached root. - `tests/modkit/cases/dataset_views_nontermination.lua` proves generated code is rejected rather than executed; `tests/engine/generated_data_decoder_test.lua` proves every decoder resource bound. diff --git a/src/mods/DatasetViews.lua b/src/mods/DatasetViews.lua index eb0364b9..253338d0 100644 --- a/src/mods/DatasetViews.lua +++ b/src/mods/DatasetViews.lua @@ -47,10 +47,6 @@ local GEN2_ROOTS = { gen2Constants = "constants", gen2Landmarks = "landmarks", } -local function decode(source) - return SaveSerializer.decode(source, DECODE_LIMITS) -end - local function resolvePath(root, suffix) local node = root for key in suffix:gmatch("[^.]+") do @@ -130,15 +126,15 @@ local function isDataOnly(value, state, depth) return true end -function DatasetViews.new(fs, engineRequire) +function DatasetViews.new(fs, engineRequire, decoder) assert(fs and fs.read and fs.getInfo, "DatasetViews.new requires a readable filesystem") return setmetatable({ fs = fs, engineRequire = engineRequire or require, - datasets = {} }, DatasetViews) + decoder = decoder or SaveSerializer.decode, datasets = {} }, DatasetViews) end -function DatasetViews:_validate(version, inspected) - local sources, aggregate = {}, 0 +function DatasetViews:_preflight(version, inspected) + local paths, aggregate = {}, 0 local modules = CacheContract.semanticModules(version) for _, name in ipairs(CacheContract.optionalSemanticModules(version)) do local path = inspected.prefix .. "data/generated/" .. name .. ".lua" @@ -151,31 +147,80 @@ function DatasetViews:_validate(version, inspected) if size and size > DECODE_LIMITS.maxBytes then return nil, name .. ": size limit" end aggregate = aggregate + (size or 0) if aggregate > MAX_AGGREGATE_BYTES then return nil, "aggregate size limit" end + paths[name] = path end - aggregate = 0 - for _, name in ipairs(modules) do - local path = inspected.prefix .. "data/generated/" .. name .. ".lua" - local source = self.fs.read(path) - if type(source) ~= "string" then return nil, "missing " .. name end - aggregate = aggregate + #source - if aggregate > MAX_AGGREGATE_BYTES then return nil, "aggregate size limit" end - local value, err = decode(source) - if type(value) ~= "table" then - return nil, name .. ": " .. tostring(err or "non-table root") + return { paths = paths, key = table.concat(modules, "\n") } +end + +local function resetInternal(view) + view.moduleCache, view.data, view.registries = {}, nil, nil +end + +function DatasetViews:_reject(view, moduleName, source, detail) + view.invalid = { module = moduleName, source = source, detail = detail } + view.data, view.registries = nil, nil + Logger.warn("dataset %s cache rejected: %s", view.version, tostring(detail)) + return nil +end + +function DatasetViews:_ready(view) + if view.invalid or view.unavailable then return false end + local inspected, _, detail = CacheContract.inspect(view.version, self.fs, { + allowSource = true, semantic = true, + }) + if not inspected then + view.unavailable = true + if self.datasets[view.version] == view then self.datasets[view.version] = nil end + Logger.warn("dataset %s unavailable: %s", view.version, detail) + return false + end + local plan, invalid = self:_preflight(view.version, inspected) + if not plan then + self:_reject(view, nil, nil, invalid) + return false + end + if view.prefix ~= inspected.prefix or view.plan.key ~= plan.key then + view.prefix, view.plan = inspected.prefix, plan + resetInternal(view) + else + view.plan = plan + end + for name, cached in pairs(view.moduleCache) do + local source = self.fs.read(plan.paths[name]) + if type(source) ~= "string" then + self:_reject(view, name, source, name .. ": unreadable generated module") + return false + end + if source ~= cached.source then + resetInternal(view) + break end - sources[name] = source end - return sources + return not view.invalid end function DatasetViews:_module(view, root) local moduleName = view.modules[root] if not moduleName then return nil end - local source = view.sources[moduleName] - if source == nil then return nil end local cached = view.moduleCache[moduleName] - if cached and cached.source == source then return cached.value end - local value = assert(decode(source)) + if cached then return cached.value end + local path = view.plan.paths[moduleName] + if not path then return nil end + local source = self.fs.read(path) + if type(source) ~= "string" then + return self:_reject(view, moduleName, source, + moduleName .. ": unreadable generated module") + end + local aggregate = #source + for _, loaded in pairs(view.moduleCache) do aggregate = aggregate + #loaded.source end + if aggregate > MAX_AGGREGATE_BYTES then + return self:_reject(view, moduleName, source, "aggregate size limit") + end + local value, err = self.decoder(source, DECODE_LIMITS) + if type(value) ~= "table" then + return self:_reject(view, moduleName, source, + moduleName .. ": " .. tostring(err or "non-table root")) + end view.moduleCache[moduleName] = { source = source, value = value } return value end @@ -191,6 +236,7 @@ function DatasetViews:_data(view) end, }) DatasetHydration.apply(data, view.version, self.engineRequire) + if view.invalid then error(view.invalid.detail, 0) end view.data = data return data end @@ -217,10 +263,37 @@ function DatasetViews:_registries(view) end function DatasetViews:_registry(view, name) - local registry = self:_registries(view)[name] + local service = self + local function registryForRead() + if not service:_ready(view) then return nil end + local ok, registries = pcall(service._registries, service, view) + if not ok then + if not view.invalid then service:_reject(view, nil, nil, registries) end + return nil + end + if view.invalid then return nil end + return registries[name] + end + local function validate(registry, id, value) + if value == nil then return true end + local ok, detail = Schemas.check(registry.spec, registry.name, id, + value, "override") + if ok then return true end + local target = registry.spec.target + or Schemas.targetFor(registry.name, registry.spec, view.generation) + local root = target and target:match("^[^%.]+") + local moduleName = root and view.modules[root] + if root == "gen2HeldItems" then moduleName = "items" end + local cached = moduleName and view.moduleCache[moduleName] + service:_reject(view, moduleName, cached and cached.source, + "invalid " .. registry.name .. " record: " .. detail) + return false + end local function rawAt(id) + local registry = registryForRead() local value = registry and registry:get(id) - if value == nil or not isDataOnly(value) then return nil end + if value == nil or not validate(registry, id, value) + or not isDataOnly(value) then return nil end return value end local function valueAt(id) @@ -239,9 +312,16 @@ function DatasetViews:_registry(view, name) end, each = function() local ids = {} + local registry = registryForRead() if registry then for id, value in registry:each() do - if type(id) == "string" and isDataOnly(value) then ids[#ids + 1] = id end + if not validate(registry, id, value) then + ids = {} + break + end + if type(id) == "string" and isDataOnly(value) then + ids[#ids + 1] = id + end end end table.sort(ids) @@ -259,9 +339,13 @@ end function DatasetViews:_assets(view) local service = self local assets = {} - function assets:path(path) return view.prefix .. assetRelative(path) end + function assets:path(path) + if not service:_ready(view) then return nil end + return view.prefix .. assetRelative(path) + end function assets:info(path) local full = self:path(path) + if not full then return nil end local info = service.fs.getInfo and service.fs.getInfo(full, "file") if not info or (info.type and info.type ~= "file") then return nil end local out = { type = "file" } @@ -283,43 +367,36 @@ function DatasetViews:open(version) Logger.warn("dataset %s unavailable: %s", version, detail) return nil, reason end - local sources, invalid = self:_validate(version, inspected) - if not sources then - self.datasets[version] = nil + local plan, invalid = self:_preflight(version, inspected) + if not plan then Logger.warn("dataset %s cache rejected: %s", version, invalid) return nil, "invalid_cache" end local internal = self.datasets[version] + if internal and internal.invalid then + local bad = internal.invalid + local path = bad.module and plan.paths[bad.module] + local source = path and self.fs.read(path) + if source == bad.source then return nil, "invalid_cache" end + internal = nil + self.datasets[version] = nil + end local changed = not internal or internal.prefix ~= inspected.prefix - if internal and not changed then - for name, source in pairs(internal.sources) do - if sources[name] ~= source then changed = true; break end - end - end - if internal and not changed then - for name, source in pairs(sources) do - if internal.sources[name] ~= source then changed = true; break end - end - end + or internal.plan.key ~= plan.key if changed then + if internal then internal.unavailable = true end internal = { version = version, generation = GameVersion.generation(version), prefix = inspected.prefix, + plan = plan, modules = GameVersion.generation(version) == 2 and GEN2_ROOTS or GEN1_ROOTS, - moduleCache = {}, sources = sources, + moduleCache = {}, } - local ok, buildError = pcall(function() internal.registries = self:_registries(internal) end) - if not ok then - Logger.warn("dataset %s semantic hydration failed: %s", version, - tostring(buildError)) - self.datasets[version] = nil - return nil, "invalid_cache" - end self.datasets[version] = internal else - internal.sources = sources + internal.plan = plan end local view = { version = version, generation = internal.generation, content = {} } diff --git a/src/mods/Schemas.lua b/src/mods/Schemas.lua index f07c03c9..b3fbf90d 100644 --- a/src/mods/Schemas.lua +++ b/src/mods/Schemas.lua @@ -757,6 +757,32 @@ end local R = {} Schemas.REGISTRIES = R +-- Some generated Gen 2 modules keep extractor metadata beside their public +-- record maps. These callbacks are the registry normalization boundary: the +-- metadata remains available to engine consumers through Data, but is not an +-- id a mod can read or overwrite through the record registry. +local function recordMapExcept(...) + local excluded = {} + for index = 1, select("#", ...) do excluded[select(index, ...)] = true end + return function(base, id) + if excluded[id] then return nil end + return base[id] + end, function(base) + local ids = {} + for id in pairs(base) do + if not excluded[id] then ids[#ids + 1] = id end + end + return ids + end +end + +local pokemonGen2BaseAt, pokemonGen2BaseIds = + recordMapExcept("growthRates", "tmhmMoves") +local movesGen2BaseAt, movesGen2BaseIds = + recordMapExcept("generation", "source") +local itemsGen2BaseAt, itemsGen2BaseIds = + recordMapExcept("generation", "source", "pockets") + -- ------- shared Gen 2 leaves -- -- The ROM name spaces Gold's tables key by. They are enums rather than @@ -779,6 +805,7 @@ local gen2PaletteRow = f.list(gen2Color) R.pokemon = { semantics = "record", target = "pokemon", + gen2BaseAt = pokemonGen2BaseAt, gen2BaseIds = pokemonGen2BaseIds, fields = { id = f.str, name = f.str, dex = f.int(1), index = f.opt(f.int(0, 255)), @@ -870,6 +897,7 @@ R.pokemon = { R.moves = { semantics = "record", target = "moves", + gen2BaseAt = movesGen2BaseAt, gen2BaseIds = movesGen2BaseIds, fields = { id = f.str, name = f.str, index = f.opt(f.int(0, 255)), @@ -894,6 +922,7 @@ R.moves = { R.items = { semantics = "record", target = "items", + gen2BaseAt = itemsGen2BaseAt, gen2BaseIds = itemsGen2BaseIds, fields = { id = f.str, name = f.str, index = f.opt(f.int(0, 255)), diff --git a/tests/engine/dataset_views_lazy_validation.lua b/tests/engine/dataset_views_lazy_validation.lua new file mode 100644 index 00000000..fa9ff9d0 --- /dev/null +++ b/tests/engine/dataset_views_lazy_validation.lua @@ -0,0 +1,46 @@ +-- Imported semantic modules are opened without eager reads and are decoded +-- once, on first use, through the same public facade mods receive. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Fixture = require("tests.modkit.dataset_view_fixture") +local DatasetViews = require("src.mods.DatasetViews") +local GameVersion = require("src.core.GameVersion") +local SaveSerializer = require("src.core.SaveSerializer") + +local files = {} +Fixture.cache(files, "gold") +local fs = T.sdk.memfs(files) +local rawRead = fs.read +local reads = {} +fs.read = function(path) + reads[path] = (reads[path] or 0) + 1 + return rawRead(path) +end +local decodes = 0 +local service = DatasetViews.new(fs, require, function(source, limits) + decodes = decodes + 1 + return SaveSerializer.decode(source, limits) +end) +local pokemonPath = GameVersion.cachePrefix("gold") + .. "data/generated/pokemon.lua" +local movesPath = GameVersion.cachePrefix("gold") + .. "data/generated/moves.lua" + +local view, reason = service:open("gold") +T.eq(reason, nil, "marker-valid Gold dataset opens") +T.check(view ~= nil, "open returns the lazy view") +T.eq(reads[pokemonPath] or 0, 0, "open does not read an unused root") +T.eq(reads[movesPath] or 0, 0, "open does not read another unused root") +T.eq(decodes, 0, "open decodes no semantic root") + +local first = view and view.content.pokemon:get("FIXMON") +T.eq(first and first.name, "FIXMON", "first access decodes a valid root") +T.check(decodes > 0, "first root access performs bounded decoding") +T.eq(reads[movesPath] or 0, 0, "pokemon access leaves moves unused") +local afterFirst = decodes +local second = view and view.content.pokemon:get("FIXMON") +T.eq(second and second.name, "FIXMON", "repeated access remains available") +T.eq(decodes, afterFirst, "repeated access does not re-decode cached roots") + +T.finish("dataset_views_lazy_validation") diff --git a/tests/modkit/cases/dataset_views.lua b/tests/modkit/cases/dataset_views.lua index d655fdae..be672b46 100644 --- a/tests/modkit/cases/dataset_views.lua +++ b/tests/modkit/cases/dataset_views.lua @@ -65,6 +65,13 @@ for _, id in ipairs({ "IRON_TAIL", "METAL_CLAW", "STEEL_WING", "RAIN_DANCE", out.moves[id] = gold.content.moves:has(id) end out.steel = gold.content.type_chart:has("STEEL") +out.metadataHidden = not gold.content.pokemon:has("growthRates") + and not gold.content.pokemon:has("tmhmMoves") + and not gold.content.moves:has("generation") + and not gold.content.moves:has("source") + and not gold.content.items:has("generation") + and not gold.content.items:has("source") + and not gold.content.items:has("pockets") out.executableBuiltinHidden = gold.content.statuses:get("sleep") == nil out.assetDirectory = gold.assets:info("assets/generated/battle/front") out.assetRejects = {} @@ -113,6 +120,8 @@ for _, id in ipairs(Fixture.CONTINUATIONS) do end for _, id in ipairs(Fixture.MOVES) do T.eq(out.moves[id], true, "Gold exposes " .. id) end T.eq(out.steel, true, "Gold exposes Steel") +T.eq(out.metadataHidden, true, + "Gold extractor metadata stays outside record registry id spaces") local registryCount = 0 for _ in pairs(Schemas.REGISTRIES) do registryCount = registryCount + 1 end for _ in pairs(Schemas.ALIASES) do registryCount = registryCount + 1 end @@ -206,16 +215,22 @@ local transitionRead = transitionFs.read local transitionMarkers = 0 local reimportedPokemon = require("src.import.LuaWriter").encode({ FIXMON = { id = "FIXMON", name = "REIMPORTED", dex = 1, + types = {}, catchRate = 45, baseExp = 64, growthRate = "MEDIUM_FAST", + baseStats = { hp = 45, attack = 49, defense = 49, speed = 45, + special = 65 }, + level1Moves = {}, tmhm = {}, learnset = {}, evolutions = {}, spriteFront = "assets/generated/battle/front/fixmon.png", spriteBack = "assets/generated/battle/back/fixmon.png", frontSize = 5 }, }) transitionFs.read = function(path) if path == GameVersion.cachePrefix("red") .. "rom-cache.complete" then transitionMarkers = transitionMarkers + 1 - if transitionMarkers == 2 then return nil end + -- open and the first facade read each recheck readiness; remove the cache + -- for the next open, then make the following open the reimport. + if transitionMarkers == 3 then return nil end end local body = transitionRead(path) - if transitionMarkers >= 3 + if transitionMarkers >= 4 and path == GameVersion.cachePrefix("red") .. "data/generated/pokemon.lua" then return reimportedPokemon end @@ -255,7 +270,12 @@ local mod = ... local out = {} for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver" }) do local view, reason = mod.datasets:open(version) - out[version] = { view ~= nil, reason } + local value = view and view.content.pokemon:get("BAD") + local reopened, reopenedReason = mod.datasets:open(version) + out[version] = { + first = view ~= nil, firstReason = reason, value = value, + reopened = reopened ~= nil, reason = reopenedReason, + } end mod.exports.result = out ]]) @@ -272,8 +292,9 @@ local hostileRun = T.sdk.loadMods(hostilePaths, { }) local hostileOut = hostileRun.loader.exports.hostile_probe.result for _, row in ipairs(hostile) do - T.same(hostileOut[row[1]], { false, "invalid_cache" }, - row[1] .. " hostile generated source fails closed") + T.same(hostileOut[row[1]], { + first = true, reopened = false, reason = "invalid_cache", + }, row[1] .. " hostile generated source fails closed on first root access") end T.eq(debug.gethook and debug.gethook(), beforeHook, "dataset decoding preserves the caller debug hook") @@ -283,6 +304,91 @@ if debug.sethook then end hostileRun.release() +-- A syntactically valid semantic root with a primitive record must fail closed +-- whichever public read verb encounters it first. +local malformedFiles = {} +for _, version in ipairs({ "red", "blue", "yellow" }) do + Fixture.cache(malformedFiles, version, { + pokemon = "return { FIXMON = 7 }", + }) +end +local malformedMod = Fixture.addMod(malformedFiles, "malformed_probe", [[ +local mod = ... +local out = {} +local getView = assert(mod.datasets:open("red")) +out.get = getView.content.pokemon:get("FIXMON") +local getAgain, getReason = mod.datasets:open("red") +out.getAgain = { getAgain ~= nil, getReason } + +local hasView = assert(mod.datasets:open("blue")) +out.has = hasView.content.pokemon:has("FIXMON") +local hasAgain, hasReason = mod.datasets:open("blue") +out.hasAgain = { hasAgain ~= nil, hasReason } + +local eachView = assert(mod.datasets:open("yellow")) +for id, value in eachView.content.pokemon:each() do + out.each = { id, value } + break +end +local eachAgain, eachReason = mod.datasets:open("yellow") +out.eachAgain = { eachAgain ~= nil, eachReason } +mod.exports.result = out +]]) +local malformedRun = T.sdk.loadMods({ malformedMod }, { + fs = T.sdk.memfs(malformedFiles), data = { pokemon = {} }, generation = 1, +}) +T.eq(#malformedRun.errors, 0, "malformed-record probe stays sandboxed") +local malformedOut = malformedRun.loader.exports.malformed_probe.result +T.eq(malformedOut.get, nil, "get never exposes a malformed semantic record") +T.eq(malformedOut.has, false, "has never affirms a malformed semantic record") +T.eq(malformedOut.each, nil, "each never exposes a malformed semantic record") +T.same(malformedOut.getAgain, { false, "invalid_cache" }, + "get invalidates the malformed dataset") +T.same(malformedOut.hasAgain, { false, "invalid_cache" }, + "has invalidates the malformed dataset") +T.same(malformedOut.eachAgain, { false, "invalid_cache" }, + "each invalidates the malformed dataset") +malformedRun.release() + +-- The same Gold view stays independent while each Gen 1 version is the +-- actual active runtime version and cache namespace, not just a fixture label. +for _, activeVersion in ipairs({ "red", "blue", "yellow" }) do + local matrixFiles = {} + Fixture.cache(matrixFiles, "gold") + local matrixMod = Fixture.addMod(matrixFiles, + "active_" .. activeVersion .. "_gold_probe", [[ +local mod = ... +local gold = assert(mod.datasets:open("gold")) +mod.exports.result = { + species = gold.content.pokemon:get("FIXMON").name, + foresight = gold.content.type_chart:get("NORMAL>GHOST").multiplier, + held = gold.content.held_items:get("LEFTOVERS").heldEffect, +} +]]) + GameVersion.set(activeVersion) + CacheFs.prefix = GameVersion.cachePrefix(activeVersion) + local activeData = { + pokemon = { ACTIVE = { id = "ACTIVE_" .. activeVersion, + nested = { version = activeVersion } } }, + } + local matrixRun = T.sdk.loadMods({ matrixMod }, { + fs = T.sdk.memfs(matrixFiles), data = activeData, generation = 1, + }) + T.eq(#matrixRun.errors, 0, activeVersion .. "-active Gold probe loads") + T.same(matrixRun.loader.exports["active_" .. activeVersion .. "_gold_probe"].result, + { species = "FIXMON", foresight = 0, held = "HELD_LEFTOVERS" }, + activeVersion .. "-active runtime sees canonical Gold semantics") + T.eq(GameVersion.get(), activeVersion, + activeVersion .. " remains the active GameVersion") + T.eq(CacheFs.prefix, GameVersion.cachePrefix(activeVersion), + activeVersion .. " remains the active cache namespace") + T.eq(activeData.pokemon.ACTIVE.nested.version, activeVersion, + activeVersion .. " active Data remains unchanged") + T.eq(activeData.pokemon.FIXMON, nil, + activeVersion .. " active Data receives no Gold record") + matrixRun.release() +end + GameVersion.set(originalVersion) CacheFs.prefix = originalPrefix T.finish("dataset_views") diff --git a/tests/modkit/cases/dataset_views_nontermination.lua b/tests/modkit/cases/dataset_views_nontermination.lua index e895f06a..3a0da2e6 100644 --- a/tests/modkit/cases/dataset_views_nontermination.lua +++ b/tests/modkit/cases/dataset_views_nontermination.lua @@ -10,12 +10,18 @@ Fixture.cache(files, "red", { local modPath = Fixture.addMod(files, "nontermination_probe", [[ local mod = ... local view, reason = mod.datasets:open("red") -mod.exports.result = { view ~= nil, reason } +local value = view and view.content.pokemon:get("FIXMON") +local reopened, reopenedReason = mod.datasets:open("red") +mod.exports.result = { + first = view ~= nil, firstReason = reason, value = value, + reopened = reopened ~= nil, reason = reopenedReason, +} ]]) local run = T.sdk.loadMods({ modPath }, { fs = T.sdk.memfs(files), data = { pokemon = {} }, generation = 1, }) T.same(run.loader.exports.nontermination_probe.result, - { false, "invalid_cache" }, "generated code is never executed") + { first = true, reopened = false, reason = "invalid_cache" }, + "generated code is never executed and invalidates on first root access") run.release() T.finish("dataset_views_nontermination") diff --git a/tests/modkit/dataset_view_fixture.lua b/tests/modkit/dataset_view_fixture.lua index 5d2e80bf..fe1472bc 100644 --- a/tests/modkit/dataset_view_fixture.lua +++ b/tests/modkit/dataset_view_fixture.lua @@ -30,10 +30,23 @@ local MOVES = { local function species(id, dex, generation) local row = { id = id, name = id, dex = dex, + types = {}, catchRate = 45, baseExp = 64, growthRate = "MEDIUM_FAST", + tmhm = {}, evolutions = {}, spriteFront = "assets/generated/battle/front/" .. id:lower() .. ".png", spriteBack = "assets/generated/battle/back/" .. id:lower() .. ".png", } - if generation == 2 then row.picSize = 5 else row.frontSize = 5 end + if generation == 2 then + row.baseStats = { hp = 45, attack = 49, defense = 49, speed = 45, + specialAttack = 65, specialDefense = 65 } + row.levelMoves = {} + row.picSize = 5 + else + row.baseStats = { hp = 45, attack = 49, defense = 49, speed = 45, + special = 65 } + row.level1Moves = {} + row.learnset = {} + row.frontSize = 5 + end return row end @@ -47,6 +60,10 @@ local function defaults(version) types = {}, } if generation == 2 then + pokemon.growthRates = {} + pokemon.tmhmMoves = {} + moves.generation, moves.source = 2, "fixture moves" + items.generation, items.source, items.pockets = 2, "fixture items", {} for index, id in ipairs(CONTINUATIONS) do pokemon[id] = species(id, 168 + index, generation) end From 38d515f547063cb2fb413486506a2299217ec5c2 Mon Sep 17 00:00:00 2001 From: MaxTomahawk Date: Mon, 24 Aug 2026 10:10:23 +0200 Subject: [PATCH 04/16] fix(mod-api): reserve generated metadata ids --- docs/modding.md | 4 +- docs/rfcs/0015-imported-dataset-views.md | 5 +- src/mods/Registry.lua | 7 ++ src/mods/Schemas.lua | 19 ++-- tests/engine/gen2_reserved_metadata_ids.lua | 112 ++++++++++++++++++++ 5 files changed, 137 insertions(+), 10 deletions(-) create mode 100644 tests/engine/gen2_reserved_metadata_ids.lua diff --git a/docs/modding.md b/docs/modding.md index 2845f9f2..952f876f 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -228,7 +228,9 @@ and data-only record shapes as `mod.content`, but only `get`, `has`, and `each`. Returned records are detached copies and cannot mutate either dataset. Every generated base record passes the selected generation's existing public schema before it is returned; extractor metadata beside record maps stays out -of the registry id space. A malformed record makes `get` return nil, `has` +of the registry id space and is reserved against `register`, `override`, +`patch`, and `remove` writes through the active registry. A malformed record +makes `get` return nil, `has` return false, and `each` return no rows, and invalidates that dataset view. Records containing functions, userdata, threads, metatables, or cycles are not exposed. Each open call receives an independent facade, so one mod cannot diff --git a/docs/rfcs/0015-imported-dataset-views.md b/docs/rfcs/0015-imported-dataset-views.md index 0bf7d9d9..dd25b1f9 100644 --- a/docs/rfcs/0015-imported-dataset-views.md +++ b/docs/rfcs/0015-imported-dataset-views.md @@ -71,8 +71,9 @@ the selected version's generation routing and the engine's existing `Schemas`, `Registry`, and `Builtins` normalization, so structured sources such as type matchups retain the same public ids used by the active `mod.content` facade and extractor metadata beside record maps is not exposed -as a record id. Every generated base record is checked with that selected -generation's existing public schema before it can cross `get`, `has`, or +as a record id or writable through the active registry's mutation verbs. Every +generated base record is checked with that selected generation's existing +public schema before it can cross `get`, `has`, or `each`; there is no dataset-specific duplicate schema. No register, patch, override, or remove verb is exposed. Each call returns an independent facade over the cached internal dataset, so facade mutation cannot cross mod diff --git a/src/mods/Registry.lua b/src/mods/Registry.lua index 3c7ff4bb..a98e0ca6 100644 --- a/src/mods/Registry.lua +++ b/src/mods/Registry.lua @@ -12,6 +12,12 @@ Registry.__index = Registry -- exposed to mods as mod.DELETE: a patch value that unsets a field Registry.DELETE = Merge.DELETE +local function assertWritable(self, id) + if self.spec.reservedIds and self.spec.reservedIds[id] then + error(("%s id is reserved engine metadata: %s"):format(self.name, id)) + end +end + -- spec comes from Schemas.REGISTRIES[name]; bare Registry.new(name) keeps -- the v1 record behavior for standalone use in tests and tools function Registry.new(name, spec) @@ -33,6 +39,7 @@ local function append(self, id, op, value, owner) error(self.name .. ": content is frozen after load") end assert(type(id) == "string" and id ~= "", self.name .. " id is required") + assertWritable(self, id) local list = self.ops[id] if not list then list = {} diff --git a/src/mods/Schemas.lua b/src/mods/Schemas.lua index b3fbf90d..2b6e8d38 100644 --- a/src/mods/Schemas.lua +++ b/src/mods/Schemas.lua @@ -676,9 +676,10 @@ end -- -- So beside `value` / `fields` / `keys` / `keyValue` a spec may carry -- `gen2Value` / `gen2Fields` / `gen2Keys` / `gen2KeyValue`, and beside --- `semantics` / `extra` / `write` / `baseAt` / `baseIds` / `example` / --- `notes` the matching `gen2*`. Absent means "the Gen 1 shape is right here --- too", which is the common case and why most registries carry none of this. +-- `semantics` / `extra` / `write` / `baseAt` / `baseIds` / `reservedIds` / +-- `example` / `notes` the matching `gen2*`. Absent means "the Gen 1 shape is +-- right here too", which is the common case and why most registries carry +-- none of this. -- The registry NAME, the verbs and (wherever the id space allows it) the ids -- stay shared, exactly as the routing table keeps them shared. -- @@ -700,6 +701,7 @@ local GEN2_SHAPE = { gen2KeyValue = "keyValue", gen2Extra = "extra", gen2Semantics = "semantics", gen2Write = "write", gen2BaseAt = "baseAt", gen2BaseIds = "baseIds", + gen2ReservedIds = "reservedIds", gen2Example = "example", gen2Notes = "notes", } @@ -773,14 +775,14 @@ local function recordMapExcept(...) if not excluded[id] then ids[#ids + 1] = id end end return ids - end + end, excluded end -local pokemonGen2BaseAt, pokemonGen2BaseIds = +local pokemonGen2BaseAt, pokemonGen2BaseIds, pokemonGen2ReservedIds = recordMapExcept("growthRates", "tmhmMoves") -local movesGen2BaseAt, movesGen2BaseIds = +local movesGen2BaseAt, movesGen2BaseIds, movesGen2ReservedIds = recordMapExcept("generation", "source") -local itemsGen2BaseAt, itemsGen2BaseIds = +local itemsGen2BaseAt, itemsGen2BaseIds, itemsGen2ReservedIds = recordMapExcept("generation", "source", "pockets") -- ------- shared Gen 2 leaves @@ -806,6 +808,7 @@ local gen2PaletteRow = f.list(gen2Color) R.pokemon = { semantics = "record", target = "pokemon", gen2BaseAt = pokemonGen2BaseAt, gen2BaseIds = pokemonGen2BaseIds, + gen2ReservedIds = pokemonGen2ReservedIds, fields = { id = f.str, name = f.str, dex = f.int(1), index = f.opt(f.int(0, 255)), @@ -898,6 +901,7 @@ R.pokemon = { R.moves = { semantics = "record", target = "moves", gen2BaseAt = movesGen2BaseAt, gen2BaseIds = movesGen2BaseIds, + gen2ReservedIds = movesGen2ReservedIds, fields = { id = f.str, name = f.str, index = f.opt(f.int(0, 255)), @@ -923,6 +927,7 @@ R.moves = { R.items = { semantics = "record", target = "items", gen2BaseAt = itemsGen2BaseAt, gen2BaseIds = itemsGen2BaseIds, + gen2ReservedIds = itemsGen2ReservedIds, fields = { id = f.str, name = f.str, index = f.opt(f.int(0, 255)), diff --git a/tests/engine/gen2_reserved_metadata_ids.lua b/tests/engine/gen2_reserved_metadata_ids.lua new file mode 100644 index 00000000..88495e3a --- /dev/null +++ b/tests/engine/gen2_reserved_metadata_ids.lua @@ -0,0 +1,112 @@ +-- Extractor metadata hidden from record id spaces remains engine-owned: mods +-- cannot claim, update, or remove it through the public content registries. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Mon = require("src.battle.gen2.Mon") + +local function goldData() + local data = T.fixtures.fresh() + data.pokemon.growthRates = { + GROWTH_MEDIUM_FAST = { + numerator = 1, denominator = 1, squared = 0, linear = 0, constant = 0, + }, + } + data.pokemon.tmhmMoves = { "FIX_TACKLE", "FIX_CUT" } + data.moves.generation, data.moves.source = 2, "ROM:Moves" + data.items.generation, data.items.source = 2, "ROM:Items" + data.items.pockets = { "ITEM", "BALL", "KEY_ITEM", "TM_HM" } + return data +end + +local function assertMetadata(data, refs, label) + T.eq(data.pokemon.growthRates, refs.growthRates, + label .. ": growth-rate table identity is preserved") + T.eq(data.pokemon.tmhmMoves, refs.tmhmMoves, + label .. ": TM/HM order identity is preserved") + T.eq(data.moves.generation, 2, label .. ": move generation is preserved") + T.eq(data.moves.source, "ROM:Moves", label .. ": move source is preserved") + T.eq(data.items.generation, 2, label .. ": item generation is preserved") + T.eq(data.items.source, "ROM:Items", label .. ": item source is preserved") + T.eq(data.items.pockets, refs.pockets, + label .. ": item pocket order identity is preserved") + T.eq(Mon.experienceForLevel( + Mon.growthFor(data, "GROWTH_MEDIUM_FAST"), 10), 1000, + label .. ": active Gold growth behavior is preserved") +end + +-- A no-mod load characterizes the unchanged active-boot behavior. +do + local data = goldData() + local refs = { + growthRates = data.pokemon.growthRates, + tmhmMoves = data.pokemon.tmhmMoves, + pockets = data.items.pockets, + } + local run = T.sdk.loadNone({ data = data, generation = 2 }) + T.eq(#run.errors, 0, "no-mod Gold load remains clean") + assertMetadata(run.data, refs, "no-mod Gold") + run.release() +end + +local files, paths = {}, {} +local attempts = { + register_growth_rates = [[ +local mod = ... +mod.content.pokemon:register("growthRates", { + id = "growthRates", name = "CLAIMED", dex = 999, + types = {}, + baseStats = { hp = 1, attack = 1, defense = 1, speed = 1, + specialAttack = 1, specialDefense = 1 }, + catchRate = 1, baseExp = 1, growthRate = "GROWTH_MEDIUM_FAST", + levelMoves = {}, evolutions = {}, + spriteFront = "assets/generated/battle/front/claimed.png", + spriteBack = "assets/generated/battle/back/claimed.png", picSize = 5, +}) +]], + override_move_generation = [[ +local mod = ... +mod.content.moves:override("generation", { + id = "generation", name = "CLAIMED", type = "NORMAL", + power = 1, accuracy = 100, pp = 1, effect = "NO_ADDITIONAL_EFFECT", +}) +]], + patch_item_pockets = [[ +local mod = ... +mod.content.items:patch("pockets", { price = 999 }) +]], + remove_tmhm_moves = [[ +local mod = ... +mod.content.pokemon:remove("tmhmMoves") +]], +} + +for id, body in pairs(attempts) do + files["mods/" .. id .. "/manifest.json"] = ([[{ + "id": %q, "name": %q, "version": "1.0.0", "entry": "main.lua", + "api": 2, "gen2compat": true + }]]):format(id, id) + files["mods/" .. id .. "/main.lua"] = body + paths[#paths + 1] = "mods/" .. id +end +table.sort(paths) + +local data = goldData() +local refs = { + growthRates = data.pokemon.growthRates, + tmhmMoves = data.pokemon.tmhmMoves, + pockets = data.items.pockets, +} +local run = T.sdk.loadMods(paths, { + fs = T.sdk.memfs(files), data = data, generation = 2, +}) +for id in pairs(attempts) do + local mod = run.loader.mods[id] + T.eq(mod and mod.state, "failed", id .. " is rejected") + T.check(mod and tostring(mod.failure):match("reserved") ~= nil, + id .. " reports the reserved metadata boundary") +end +assertMetadata(run.data, refs, "rejected metadata writes") +run.release() + +T.finish("gen2_reserved_metadata_ids") From cec19db5e00f0d5d01f57ff7175d6a199aeedccb Mon Sep 17 00:00:00 2001 From: MaxTomahawk Date: Mon, 24 Aug 2026 10:19:33 +0200 Subject: [PATCH 05/16] test(mod-api): cover reserved metadata replace --- tests/engine/gen2_reserved_metadata_ids.lua | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/engine/gen2_reserved_metadata_ids.lua b/tests/engine/gen2_reserved_metadata_ids.lua index 88495e3a..78e35d04 100644 --- a/tests/engine/gen2_reserved_metadata_ids.lua +++ b/tests/engine/gen2_reserved_metadata_ids.lua @@ -63,6 +63,19 @@ mod.content.pokemon:register("growthRates", { spriteFront = "assets/generated/battle/front/claimed.png", spriteBack = "assets/generated/battle/back/claimed.png", picSize = 5, }) +]], + replace_growth_rates = [[ +local mod = ... +mod.content.pokemon:register("growthRates", { + id = "growthRates", name = "REPLACED", dex = 999, + types = {}, + baseStats = { hp = 1, attack = 1, defense = 1, speed = 1, + specialAttack = 1, specialDefense = 1 }, + catchRate = 1, baseExp = 1, growthRate = "GROWTH_MEDIUM_FAST", + levelMoves = {}, evolutions = {}, + spriteFront = "assets/generated/battle/front/replaced.png", + spriteBack = "assets/generated/battle/back/replaced.png", picSize = 5, +}, { replace = true }) ]], override_move_generation = [[ local mod = ... From 68fc01dd1cedcd064debf3264bd95f5d091be467 Mon Sep 17 00:00:00 2001 From: MaxTomahawk Date: Mon, 24 Aug 2026 10:25:37 +0200 Subject: [PATCH 06/16] fix(mod-api): silence optional dataset absence --- src/mods/DatasetViews.lua | 3 +- tests/engine/dataset_views_warning_policy.lua | 56 +++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) create mode 100644 tests/engine/dataset_views_warning_policy.lua diff --git a/src/mods/DatasetViews.lua b/src/mods/DatasetViews.lua index 253338d0..ef44d6e2 100644 --- a/src/mods/DatasetViews.lua +++ b/src/mods/DatasetViews.lua @@ -359,12 +359,11 @@ function DatasetViews:open(version) if type(version) ~= "string" or not GameVersion.VERSIONS[version] then return nil, "unknown_version" end - local inspected, reason, detail = CacheContract.inspect(version, self.fs, { + local inspected, reason = CacheContract.inspect(version, self.fs, { allowSource = true, semantic = true, }) if not inspected then self.datasets[version] = nil - Logger.warn("dataset %s unavailable: %s", version, detail) return nil, reason end local plan, invalid = self:_preflight(version, inspected) diff --git a/tests/engine/dataset_views_warning_policy.lua b/tests/engine/dataset_views_warning_policy.lua new file mode 100644 index 00000000..66fc92b2 --- /dev/null +++ b/tests/engine/dataset_views_warning_policy.lua @@ -0,0 +1,56 @@ +-- Optional dataset absence is a handled API result, while a previously valid +-- view disappearing and malformed cache content remain actionable warnings. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Fixture = require("tests.modkit.dataset_view_fixture") +local DatasetViews = require("src.mods.DatasetViews") +local GameVersion = require("src.core.GameVersion") +local Logger = require("src.core.Logger") + +local previousWarn = Logger.warn +local warnings = {} +Logger.warn = function(fmt, ...) + warnings[#warnings + 1] = select("#", ...) > 0 + and string.format(fmt, ...) or fmt +end + +local ok, err = xpcall(function() + local unavailable = DatasetViews.new(T.sdk.memfs({})) + local unknown, unknownReason = unavailable:open("missing-version") + T.eq(unknown, nil, "unknown version has no view") + T.eq(unknownReason, "unknown_version", "unknown version returns its reason") + local absent, absentReason = unavailable:open("gold") + T.eq(absent, nil, "missing optional Gold dataset has no view") + T.eq(absentReason, "not_imported", "missing optional Gold returns its reason") + T.eq(#warnings, 0, "unknown and initially absent datasets do not warn") + + local files = {} + Fixture.cache(files, "gold") + local service = DatasetViews.new(T.sdk.memfs(files)) + local view = assert(service:open("gold")) + files[GameVersion.cachePrefix("gold") .. "rom-cache.complete"] = nil + T.eq(view.assets:path("assets/generated/missing.png"), nil, + "a stale view closes when its imported dataset disappears") + T.eq(#warnings, 1, "a previously valid view disappearing still warns") + T.check(warnings[1]:find("dataset gold unavailable", 1, true) ~= nil, + "the stale-view warning identifies the unavailable dataset") + + warnings = {} + local malformedFiles = {} + Fixture.cache(malformedFiles, "gold", { pokemon = "not generated data" }) + local malformed = assert(DatasetViews.new(T.sdk.memfs(malformedFiles)):open("gold")) + T.eq(#warnings, 0, "lazy open does not warn before malformed data is read") + T.eq(malformed.content.pokemon:get("FIXMON"), nil, + "malformed generated data fails closed") + T.eq(#warnings, 1, "malformed generated data still warns") + T.check(warnings[1]:find("dataset gold cache rejected", 1, true) ~= nil, + "the malformed-cache warning identifies cache rejection") + T.check(warnings[1]:find("pokemon:", 1, true) ~= nil, + "the malformed-cache warning keeps actionable module detail") +end, debug.traceback) + +Logger.warn = previousWarn +if not ok then error(err, 0) end + +T.finish("dataset_views_warning_policy") From 56fc4653d7071b29b34c61f5d123864430a079c1 Mon Sep 17 00:00:00 2001 From: AverageConsumer <35539970+AverageConsumer@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:25:22 +0200 Subject: [PATCH 07/16] Add Gen 2 party reorder API parity --- src/world/gen2/WorldAPI.lua | 32 ++++++++++ .../modkit/cases/world_party_reorder_gen2.lua | 59 +++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 tests/modkit/cases/world_party_reorder_gen2.lua diff --git a/src/world/gen2/WorldAPI.lua b/src/world/gen2/WorldAPI.lua index b5f1dbd9..52bdcfe6 100644 --- a/src/world/gen2/WorldAPI.lua +++ b/src/world/gen2/WorldAPI.lua @@ -30,6 +30,7 @@ local MapOverview = require("src.world.MapOverview") local Bike = require("src.world.gen2.Bike") local FieldMoves = require("src.world.gen2.FieldMoves") local Permissions = require("src.world.gen2.Permissions") +local Mail = require("src.core.gen2.Mail") local WorldAPI = {} WorldAPI.__index = WorldAPI @@ -49,6 +50,11 @@ local FIELD_ACTIONS = { { id = "teleport", move = "TELEPORT" }, } +local function validPartySlot(party, slot) + return type(slot) == "number" and slot == math.floor(slot) + and party[slot] ~= nil +end + function WorldAPI.new(game, modId) return setmetatable({ game = game, modId = modId }, WorldAPI) end @@ -68,6 +74,32 @@ function WorldAPI:current() facing = p and p.facing } end +-- Keep the public party-ordering contract identical across generations. +-- Gen 2 stores mail by party slot, so it must move with the Pokemon just as +-- the native PartyMenu's SwitchPartyMons path does. +function WorldAPI:canReorderParty() + local world, game = self:overworld(), self.game + local party = game and game.save and game.save.party or {} + return #party > 1 and world ~= nil and world:acceptsMenuInput() +end + +function WorldAPI:reorderParty(fromSlot, toSlot) + local world, game = self:overworld(), self.game + if not world then return nil, NO_OVERWORLD end + if not world:acceptsMenuInput() then return nil, "world is busy" end + local party = game.save and game.save.party or {} + if not validPartySlot(party, fromSlot) + or not validPartySlot(party, toSlot) then + return nil, "invalid party slot" + end + if fromSlot ~= toSlot then + party[fromSlot], party[toSlot] = party[toSlot], party[fromSlot] + Mail.swapSlots(game.save, fromSlot, toSlot) + require("src.core.Sound").play(game.data, "Sfx_SwitchPokemon") + end + return true +end + local function itemLabel(game, id) local def = game and game.data and game.data.items and game.data.items[id] diff --git a/tests/modkit/cases/world_party_reorder_gen2.lua b/tests/modkit/cases/world_party_reorder_gen2.lua new file mode 100644 index 00000000..35a4b030 --- /dev/null +++ b/tests/modkit/cases/world_party_reorder_gen2.lua @@ -0,0 +1,59 @@ +-- Gen 2 parity for the public party-ordering contract. The fixture also +-- carries slot-based mail because a reorder must move that state with its mon. + +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") + +local T = require("tests.harness").suite("mod world party reorder gen2") +local WorldAPI = require("src.world.gen2.WorldAPI") + +local first = { species = "CHIKORITA" } +local second = { species = "CYNDAQUIL" } +local firstMail = { message = "FIRST" } +local secondMail = { message = "SECOND" } +local world = { map = { id = "NEW_BARK_TOWN" }, accepts = true } +function world:acceptsMenuInput() return self.accepts end + +local game = { + data = { audio = { sfx = {} } }, + save = { + party = { first, second }, + mail = { party = { firstMail, secondMail }, box = {} }, + }, + world = world, +} +local api = WorldAPI.new(game, "fixture") + +T.check(api:canReorderParty(), "idle free roam allows party reordering") + +local Sound = require("src.core.Sound") +local realPlay, played = Sound.play +Sound.play = function(_, name) played = name end +T.check(api:reorderParty(1, 2) == true, "valid slots reorder") +Sound.play = realPlay +T.check(game.save.party[1] == second and game.save.party[2] == first, + "the live party is swapped") +T.check(game.save.mail.party[1] == secondMail + and game.save.mail.party[2] == firstMail, + "slot-based mail follows its Pokemon") +T.eq(played, "Sfx_SwitchPokemon", "the native Gen 2 swap sound is used") + +local value, err = api:reorderParty(1.5, 2) +T.check(value == nil and err == "invalid party slot", + "non-integer slots are rejected") +value, err = api:reorderParty("1", 2) +T.check(value == nil and err == "invalid party slot", + "string slots are rejected") + +world.accepts = false +T.check(not api:canReorderParty(), "busy free roam blocks reordering") +value, err = api:reorderParty(1, 2) +T.check(value == nil and err == "world is busy", + "reordering refuses while the world owns input") + +game.world = nil +value, err = api:reorderParty(1, 2) +T.check(value == nil and err == "no overworld", + "reordering outside the overworld fails closed") + +T.finish() From d76d173b60a1708efd9045de76e1d1ce1575fe97 Mon Sep 17 00:00:00 2001 From: 1jamie Date: Mon, 24 Aug 2026 14:33:43 -0500 Subject: [PATCH 08/16] fix(android): keep Gen1 Game.load after in-process EXIT GAME MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stop fanning out arbitrary field:release() during Game/Game2 reset — shared modules use :release as a handle API, and that teardown left the Gen1 singleton unbootable (Game:load nil) on Play-again. Harden bootGame and endGameSession to rebuild the module if load is missing. --- main.lua | 9 +++++- src/core/Game.lua | 23 ++++++++------ src/core/Game2.lua | 12 ++++---- src/core/SessionLifecycle.lua | 7 +++++ .../engine/android_exit_to_launcher_test.lua | 15 ++++++++++ .../engine/launcher_session_teardown_test.lua | 30 +++++++++++++++++++ 6 files changed, 79 insertions(+), 17 deletions(-) diff --git a/main.lua b/main.lua index 8220c9c2..7b92e902 100644 --- a/main.lua +++ b/main.lua @@ -384,7 +384,14 @@ function bootGame(version, cartId) Game = require("src.core.Game2").new() Game:load() else - Game = require("src.core.Game") + -- Gen1 Game is a module singleton. In-process EXIT GAME resets it in + -- place; if a prior teardown left load missing, rebuild from source. + local gameMod = require("src.core.Game") + if type(gameMod.load) ~= "function" then + package.loaded["src.core.Game"] = nil + gameMod = require("src.core.Game") + end + Game = gameMod Game:load() if os.getenv("POKEPORT_AUTOPILOT") then autopilot = require("tests.autopilot") diff --git a/src/core/Game.lua b/src/core/Game.lua index 2cc890d7..c5b6937e 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -1389,8 +1389,13 @@ end -- Drop every session-owned field so the next Game:load() starts clean when -- the process returns to the launcher in-place (Android / intent_game). --- main.lua must not guess field names: new systems (Game.network, …) are --- cleared automatically because only functions (methods) are kept. +-- +-- Gen1 Game is a MODULE SINGLETON (methods live on this table). Never fan +-- out arbitrary field:release() here: session fields can hold shared modules +-- (Fetch, SyncClient, …) whose :release is a job-handle API, not instance +-- teardown -- calling them as value:release() corrupts process state and has +-- been observed to leave Game.load nil after EXIT GAME on Android. +-- Explicit GPU owners are released below; everything else is just dropped. function Game:reset() if self.stack and self.stack.clear then pcall(function() self.stack:clear() end) @@ -1403,23 +1408,23 @@ function Game:reset() if canvas and canvas.release then pcall(canvas.release, canvas) end end end - if self.renderer and self.renderer.releaseCanvases then - pcall(function() self.renderer:releaseCanvases() end) + if self.renderer then + local release = self.renderer.releaseCanvases or self.renderer.release + if release then pcall(release, self.renderer) end end + -- Keep methods; clear every other field (including session scalars like + -- speedOverride). Re-seed module constants afterward. + local skinFast = self.SKIN_FAST_FORWARD local keys = {} for key, value in pairs(self) do if type(value) ~= "function" then - if key ~= "world" and key ~= "renderer" and key ~= "_canvases" then - if type(value) == "table" and value.release then - pcall(value.release, value) - end - end keys[#keys + 1] = key end end for _, key in ipairs(keys) do self[key] = nil end + self.SKIN_FAST_FORWARD = skinFast or 4 end return Game diff --git a/src/core/Game2.lua b/src/core/Game2.lua index 22d914ff..342fd5f8 100644 --- a/src/core/Game2.lua +++ b/src/core/Game2.lua @@ -2204,6 +2204,8 @@ end -- In-process return-to-launcher (Android / intent_game): drop session fields -- so a later Game2.new() + load is not sharing a live stack or mod loader. -- Methods live on the class table; pairs(self) only sees instance state. +-- Same rule as Gen1: only release known GPU owners -- never fan out +-- arbitrary field:release() (shared modules use :release as a handle API). function Game2:reset() if self.stack and self.stack.clear then pcall(function() self.stack:clear() end) @@ -2216,17 +2218,13 @@ function Game2:reset() if canvas and canvas.release then pcall(canvas.release, canvas) end end end - if self.renderer and self.renderer.releaseCanvases then - pcall(function() self.renderer:releaseCanvases() end) + if self.renderer then + local release = self.renderer.releaseCanvases or self.renderer.release + if release then pcall(release, self.renderer) end end local keys = {} for key, value in pairs(self) do if type(value) ~= "function" then - if key ~= "world" and key ~= "renderer" and key ~= "_canvases" then - if type(value) == "table" and value.release then - pcall(value.release, value) - end - end keys[#keys + 1] = key end end diff --git a/src/core/SessionLifecycle.lua b/src/core/SessionLifecycle.lua index 84508a67..815a00d4 100644 --- a/src/core/SessionLifecycle.lua +++ b/src/core/SessionLifecycle.lua @@ -87,6 +87,13 @@ function SessionLifecycle.endGameSession(game) if game and game.reset then pcall(function() game:reset() end) end + -- Gen1 Game is the module singleton. If teardown left it without load, + -- drop the cached module so the next require rebuilds a clean table + -- (bootGame also guards this; doing it here keeps Play-again reliable). + if game and type(game.load) ~= "function" + and package.loaded["src.core.Game"] == game then + package.loaded["src.core.Game"] = nil + end local Input = require("src.core.Input") local TouchControls = require("src.core.TouchControls") diff --git a/tests/engine/android_exit_to_launcher_test.lua b/tests/engine/android_exit_to_launcher_test.lua index 64ae7165..04e69531 100644 --- a/tests/engine/android_exit_to_launcher_test.lua +++ b/tests/engine/android_exit_to_launcher_test.lua @@ -99,4 +99,19 @@ do check(type(Game.load) == "function", "Game:reset keeps methods") end +-- 6. Play-again after endGameSession: Game.load must still be callable +-- (Android crash: main.lua bootGame → Game:load with load == nil) +do + local Game = require("src.core.Game") + local SessionLifecycle = require("src.core.SessionLifecycle") + Game.save = {} + Game.stack = { clear = function() end } + -- Mimic a shared net module parked on the singleton (handle-style release). + Game.net = { release = function(id) end } + SessionLifecycle.endGameSession(Game) + local again = require("src.core.Game") + check(type(again.load) == "function", + "Play-again can call Game:load after endGameSession") +end + T.finish("android_exit_to_launcher_test") diff --git a/tests/engine/launcher_session_teardown_test.lua b/tests/engine/launcher_session_teardown_test.lua index e63d3fd7..5e3b4b66 100644 --- a/tests/engine/launcher_session_teardown_test.lua +++ b/tests/engine/launcher_session_teardown_test.lua @@ -28,8 +28,15 @@ do Game.network = { live = true } -- future field: must not need a whitelist Game.stack = StateStack Game.renderer = Renderer + Game.SKIN_FAST_FORWARD = 4 Renderer.canvas = love.graphics.newCanvas(8, 8) + -- Handle-style :release (Fetch/SyncClient) must not run as instance teardown. + local shared = { + release = function(self) self.killed = true end, + } + Game.sharedNet = shared + Game:reset() check(type(Game.load) == "function", "Game:reset keeps methods") @@ -40,6 +47,29 @@ do check(Game.stack == nil, "Game:reset clears stack reference") check(Game.renderer == nil, "Game:reset clears renderer reference") check(StateStack:top() == nil, "Game:reset cleared the shared StateStack") + check(shared.killed ~= true, + "Game:reset does not call handle-style :release on session fields") + check(Game.SKIN_FAST_FORWARD == 4, + "Game:reset preserves module scalars like SKIN_FAST_FORWARD") +end + +-- ---- endGameSession must leave Gen1 Game.load callable for Play-again ------ +do + Game.save = { money = 1 } + Game.stack = { clear = function() end } + -- Poison pattern from the Android crash: a field whose :release is a + -- job-handle API. Old reset called it as value:release() and could leave + -- the singleton unbootable (Game.load nil → main.lua bootGame crash). + local jobs = {} + Game.linkFetch = { + release = function(id) jobs[id] = nil end, + } + SessionLifecycle.endGameSession(Game) + check(type(Game.load) == "function", + "endGameSession leaves Game.load intact for the next bootGame") + check(package.loaded["src.core.Game"] == Game + or type((package.loaded["src.core.Game"] or {}).load) == "function", + "Gen1 Game module remains require-able after endGameSession") end -- ---- Game2:reset releases world GPU and present canvases ------------------ From 233082967cbe25aa0f2e04de2aebca11a5b85070 Mon Sep 17 00:00:00 2001 From: 1jamie Date: Mon, 24 Aug 2026 17:22:08 -0500 Subject: [PATCH 09/16] Enhance Surfing Pikachu minigame assets and functionality - Updated .gitignore to include new generated assets for the Surfing Pikachu minigame. - Added new image assets for the minigame, including title background and intro frames. - Implemented extraction of Surfing Pikachu title art in RomExtractor. - Updated CacheContract to include new asset paths. - Enhanced SurfingMinigame with new constants and functions for improved gameplay mechanics. - Added unit tests for new features and ensured existing tests pass. --- .gitignore | 11 +- src/import/CacheContract.lua | 5 + src/import/RomExtractor.lua | 108 ++++ src/ui/SurfingMinigame.lua | 1045 +++++++++++++++++++++---------- tests/test_surfing_minigame.lua | 153 ++++- tools/build_rom_data.py | 12 +- tools/make_yellow_manifest.py | 41 ++ tools/rom_manifest_yellow.json | 49 ++ 8 files changed, 1066 insertions(+), 358 deletions(-) diff --git a/.gitignore b/.gitignore index 16d74675..5053e814 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,9 @@ -# Generated from a user-provided Pokemon Red ROM. -# Regenerate with: python3 tools/build_data.py --rom /path/to/pokemon-red.gb --clean +# Generated from a user-provided Pokemon ROM (Red/Blue/Yellow/Gold/Silver/Crystal). +# Regenerate with: python3 tools/build_data.py --rom /path/to/rom.gb --clean +# Yellow Surfing Pikachu minigame art also lands here on import: +# assets/generated/minigame/surf_1{a,b,c}.png +# assets/generated/minigame/title_bg.png +# assets/generated/minigame/intro_pika_{0,1,2,3}.png data/generated/ assets/generated/ @@ -62,6 +66,9 @@ mobile/dist/ # ROM cache exactly like data/generated/, so it is never committable. /build/tiled/ +# Local visual scratch from minigame development (not wired to CI). +/tests/fixtures/ + # per-machine iOS bundle-id pin (see scripts/build_ios.sh) mobile/ios/bundle_id.local diff --git a/src/import/CacheContract.lua b/src/import/CacheContract.lua index 1937cb21..42efbe3e 100644 --- a/src/import/CacheContract.lua +++ b/src/import/CacheContract.lua @@ -34,6 +34,11 @@ CacheContract.VERSION_REQUIRED_FILES = { "assets/generated/battle/trainers/jessie_james.png", "assets/generated/battle/profoakb.png", "assets/generated/pikachu/pikapic_1.png", + "assets/generated/minigame/surf_1a.png", + "assets/generated/minigame/surf_1b.png", + "assets/generated/minigame/surf_1c.png", + "assets/generated/minigame/title_bg.png", + "assets/generated/minigame/intro_pika_0.png", }, } diff --git a/src/import/RomExtractor.lua b/src/import/RomExtractor.lua index 6c4319cc..a1b59884 100644 --- a/src/import/RomExtractor.lua +++ b/src/import/RomExtractor.lua @@ -1637,6 +1637,113 @@ function RomExtractor:extractYellowTitleArt() end end +function RomExtractor:extractSurfingPikachuTitleArt() + -- engine/minigame/surfing_pikachu.asm + -- DrawSurfingPikachuMinigameIntroBackground: compose the 160x144 + -- "Pikachu's Beach" title from SurfingMinigame_* tilemaps over + -- SurfingPikachu1Graphics3 tiles (mirrors tools/build_rom_data.py). + if not self.symbols["SurfingPikachu1Graphics3"] then return end + if not self.symbols["SurfingMinigame_BeachIntroTilemap"] then return end + + local beachIntro = self:symbol("SurfingMinigame_BeachIntroTilemap") + local useCtrlPad = self:symbol("SurfingMinigame_UseControlPadTilemap") + local toSurfRad = self:symbol("SurfingMinigame_ToSurfRadTilemap") + local titleMap = self:symbol("SurfingMinigame_TitleTilemap") + + local beachBytes = self.rom:bytes( + beachIntro.bank, beachIntro.address, 12 * 20) + local useCtrlBytes = self.rom:bytes( + useCtrlPad.bank, useCtrlPad.address, 15) + local toSurfRadBytes = self.rom:bytes( + toSurfRad.bank, toSurfRad.address, 13) + local titleMapBytes = self.rom:bytes( + titleMap.bank, titleMap.address, 6 * 12) + + local screen = {} + for _ = 1, 20 * 18 do screen[#screen + 1] = 0xff end + + for i = 1, #beachBytes do + screen[6 * 20 + i] = beachBytes[i] + end + for r = 0, 5 do + for c = 0, 11 do + screen[r * 20 + (4 + c) + 1] = titleMapBytes[r * 12 + c + 1] + end + end + for r = 0, 2 do + for c = 0, 14 do + screen[(7 + r) * 20 + (3 + c) + 1] = 0xff + end + end + for i = 1, #useCtrlBytes do + screen[7 * 20 + 3 + i] = useCtrlBytes[i] + end + for i = 1, #toSurfRadBytes do + screen[9 * 20 + 4 + i] = toSurfRadBytes[i] + end + + local gfx3 = self:symbol("SurfingPikachu1Graphics3") + local rawGfx3 = self.rom:bytes(gfx3.bank, gfx3.address, 144 * 16) + local tiles = {} + for tile = 0, 143 do + local one = {} + for j = 1, 16 do one[j] = rawGfx3[tile * 16 + j] end + tiles[tile + 1] = ImageWriter.decode2bpp(one, 8, 8, false) + end + local blank = ImageWriter.blank(8, 8, 1, 1, 1, 1) + + local titleBg = ImageWriter.blank(160, 144, 1, 1, 1, 1) + for r = 0, 17 do + for c = 0, 19 do + local tileId = screen[r * 20 + c + 1] + local tileImg = blank + if tileId ~= 0xff then + local idx = tileId >= 0x80 and (tileId - 0x80 + 1) or (128 + tileId + 1) + if idx >= 1 and idx <= 144 then tileImg = tiles[idx] end + end + ImageWriter.blit(titleBg, tileImg, c * 8, r * 8) + end + end + self:save(titleBg, "minigame/title_bg.png") + + -- Intro paddling Pikachu frames (surfing_pikachu_oam.asm .IntroPikachu). + local INTRO_PIKA_FRAME_BASE = { 0x80, 0x84, 0x88, 0x8c } + local INTRO_PIKA_OAM = { + { -12, -16, 0x03, true }, { -12, -8, 0x02, true }, + { -12, 0, 0x01, true }, { -12, 8, 0x00, true }, + { -4, -16, 0x13, true }, { -4, -8, 0x12, true }, + { -4, 0, 0x11, true }, { -4, 8, 0x10, true }, + { 4, -16, 0x23, true }, { 4, -8, 0x22, true }, + { 4, 0, 0x21, true }, { 4, 8, 0x20, true }, + } + local function blitIntroTile(target, tile, tx, ty, flipX) + for y = 0, 7 do + for x = 0, 7 do + local sx = flipX and (7 - x) or x + local r, g, b, a = tile:getPixel(sx, y) + local px, py = tx + x, ty + y + if a ~= 0 and px >= 0 and py >= 0 + and px < target:getWidth() and py < target:getHeight() then + target:setPixel(px, py, r, g, b, a) + end + end + end + end + for frame, vramBase in ipairs(INTRO_PIKA_FRAME_BASE) do + local pose = ImageWriter.blank(32, 24, 1, 1, 1, 0) + local sheetBase = vramBase - 0x80 + for _, sp in ipairs(INTRO_PIKA_OAM) do + local dy, dx, rel, flipX = sp[1], sp[2], sp[3], sp[4] + local tileImg = tiles[sheetBase + rel + 1] + if tileImg then + blitIntroTile(pose, tileImg, + 16 + dx + (flipX and 8 or 0), 12 + dy, flipX) + end + end + self:save(pose, ("minigame/intro_pika_%d.png"):format(frame - 1)) + end +end + function RomExtractor:raw2bpp(label, width, height, relative, options) options = options or {} local expected = width * height / 4 @@ -1978,6 +2085,7 @@ function RomExtractor:extractField() self:save(image, spec[5]) end end + self:extractSurfingPikachuTitleArt() -- Yellow-only: TalkToPikachu's framed portrait, one 5x5 base frame per -- PikaPicAnimScript -- each script's FIRST pikapic_loadgfx in diff --git a/src/ui/SurfingMinigame.lua b/src/ui/SurfingMinigame.lua index 91e236e1..5eac657a 100644 --- a/src/ui/SurfingMinigame.lua +++ b/src/ui/SurfingMinigame.lua @@ -9,7 +9,7 @@ -- - Input accumulator eliminating the 3-frame polling blind spot -- - GB hardware VBlank execution order (collision boundary checked before position update) -- - Deterministic Game Boy DIV/Random LFSR wave sequence generator --- - 14-angle rotation model with 3-frame buffered input (Right = frontflip, Left = backflip) +-- - 14-angle rotation model with 3-frame joy duty cycle (Right = frontflip, Left = backflip) -- - Stunt scoring: +50 (single), +150 (double same), +350 (triple same), +180 (mixed), +500 (triple mixed) -- - Tile interaction landing matrix (Clean, Rough -64, Hard -128, Crash/Wipeout) -- - Non-fatal crash recovery: Pikachu wipes out for 96 frames, resets speed to 64 (0.25), and continues @@ -52,18 +52,57 @@ end SurfingMinigame.isOpaque = true -- Fixed-point physics constants (256 = 1.0 px/frame) +-- Pret metric audit (surfing_pikachu.asm): +-- Speed: init 0.25, max 2.0 (high byte cp $2), +1/128/frame, jump min GetSpeedDividedBy32 >= $a +-- Penalties: rough -0.25, hard -0.5, wipeout reset 0.25 (underflow guards at 0.25 / 0.5) +-- HP: $6000 BCD, -1/frame; course: 24 sections (distance byte cp $18); Big Kahuna at section $16 +-- BG scroll: 1.5 px/frame; coast: 9.0 px/frame for 192 frames; crash $60 frames; landing splash $20/4 +-- Flips: left cp $b, right cp $d; radness meter caps at $3; trick flags bits 0=left 1=right +-- Joypad: SurfingPikachu_GetJoypad_3FrameBuffer reloads hFrameCounter with $2 (1 sample + 2 blank frames) +-- Music tempo index: high byte of ((speed & $3ff) << 1) → tiers at speed 128/256/384/512 local SPEED_INITIAL = 64 -- 0.25 * 256 -local SPEED_MAX = 512 -- 2.00 * 256 -local SPEED_ACCEL = 2 -- (1/128) * 256 +local SPEED_MAX = 512 -- 2.00 * 256 (SpeedUpPikachu: high byte cp $2) +local SPEED_ACCEL = 2 -- 0.0078125 = 1/128 px/frame in 8.8 fixed local SPEED_ROUGH_PENALTY = 64 -- 0.25 * 256 local SPEED_HARD_PENALTY = 128 -- 0.50 * 256 -local SPEED_JUMP_THRESHOLD = 320 -- 1.25 * 256 (10/32) +local PIKA_SPRITE_OFFSET = 16 -- sprite anchor above wSurfingMinigamePikachuObjectHeight +local SPEED_JUMP_MIN_DIV32 = 10 -- TryStartJump: GetSpeedDividedBy32 cp $a (speed >= 1.25) +local FLIP_LEFT_FRAMES = 11 -- DPadAction .dLeft cp $b +local FLIP_RIGHT_FRAMES = 13 -- DPadAction .dRight cp $d +local RADNESS_METER_MAX = 3 -- IncreaseRadnessMeter cap +local JOY_FRAME_RELOAD = 2 -- GetJoypad_3FrameBuffer: ld a, $2 +local CRASH_FRAMES = 96 -- UpdateCrashedPikachu initial timer $60 +local COAST_FRAMES = 192 -- WaitToShowResults routine delay +local GAME_OVER_DELAY = 128 -- Game over before accepting A ($80) +local LANDING_SPLASH_FRAMES = 8 -- FIELD_C += 4 until cp $20 +local RESULTS_FRAMESET_INIT = 0x0f -- InitResultsPikachu: frameset ID written to ANIM_OBJ_FRAME_SET +local RESULTS_BOB_CYCLE = 64 -- UpdateResultsPikachu FIELD_C & $3f +local RESULTS_BOB_START = 32 -- sine bob only when FIELD_C >= $20 +local RESULTS_BOB_AMP = 2 -- SurfingPikachu_Sine scale ($10 table; ~2px on screen) +local RESULTS_PIKA_BASES = { 0xa0, 0xa3 } -- .ResultsPikachu OAM frame pair -- Original constants (surfing_pikachu.asm) local FLAT_WATER_Y = 116 -- OAM Y 0x74 = screen Y 100 (PIKA_Y = FLAT_WATER_Y - 16) local PIKA_X = 68 -- Fixed screen X while riding (center 80, 24px pose) -local TOTAL_SECTIONS = 24 -- 24 sections ($18) of 128px = 3072px course +local TOTAL_SECTIONS = 24 -- wSurfingMinigameDistance byte 0 reaches $18 (24) +local SECTION_ACC_MAX = 65536 -- 16-bit distance acc overflow (256px per section in 8.8 fixed) +local SECTION_PX = 256 -- pixels advanced per section (65536 >> 8) local BG_HEIGHT = 128 -- Rows the BG shows; HP window covers the rest (y=128..144) +local COAST_SPEED_FIXED = 9 * 256 -- SurfingMinigame_CoastAfterGoal: 9.0 px/frame +local BG_SCROLL_STEP = 384 -- ScrollAndGenerateBGMap: 1.5 px/frame (8.8 fixed) +local RDIV_PER_FRAME = 17 -- rDIV @ 16384 Hz ≈ 273 ticks per 59.7275 Hz frame (mod 256) +local COAST_X_OFFSET = 160 -- $a0 ahead of viewport (CoastAfterGoal) +local OUTRO_SCROLL_X_OFFSET = 224 -- TILEMAP_WIDTH_PX - 32 (ScrollToResultsScreen) +local OUTRO_SCROLL_START = 144 -- hSCX $90 at results transition +local OUTRO_SCROLL_STEP = 4 -- hSCX -= 4 per frame (36 frames total) +local FINISH_DISTANCE_FIXED = TOTAL_SECTIONS * SECTION_PX * 256 +local MAP_COLS = 16 -- vBGMap0 metatile columns (256px / 16) +-- SurfingPikachuMinigame_InitStaticSpriteLayout cloud OAM X coords (9 sprites) +local CLOUD_SPRITE_X_INIT = { 32, 40, 48, 56, 64, 128, 136, 144, 152 } + +local function scrollPx(self) + return math.floor((self.scrollFixed or 0) / 256) +end -- Routine numbers (wSurfingMinigameRoutineNumber) local ROUTINE_TITLE = -1 @@ -81,6 +120,95 @@ local ROUTINE_WAIT_LAST = 10 local ROUTINE_EXIT_ON_PRESS_A = 11 local ROUTINE_GAME_OVER = 12 +local function isOutroScroll(self) + return self.routine == ROUTINE_WAIT_RESULTS or self.routine == ROUTINE_SCROLL_RESULTS +end + +local function displayScx(self) + return self.hScx or 0 +end + +-- SurfingMinigame_UpdateMusicTempo: index = high byte of ((speed & $3ff) << 1) +local function pretTempoTier(speedFixed) + local lo = bit.band(speedFixed, 0xFF) + local hi = bit.band(math.floor(speedFixed / 256), 3) + local shifted = bit.band((lo + hi * 256) * 2, 0xFFFF) + return math.min(4, math.floor(shifted / 256)) + 1 +end + +-- SurfingMinigame_GetSpeedDividedBy32 (speed * 8, high byte) +local function getSpeedDividedBy32(speedFixed) + return math.floor((speedFixed * 8) / 256) +end + +local function pikaSpriteYFromObjectHeight(objectHeightPx) + return math.floor(objectHeightPx or FLAT_WATER_Y) - PIKA_SPRITE_OFFSET +end + +-- SurfingMinigame_ReduceSpeedBy64 / ReduceSpeedBy128 +local function reduceSpeedBy64(speedFixed) + if speedFixed >= 256 then + return speedFixed - SPEED_ROUGH_PENALTY + elseif speedFixed >= SPEED_INITIAL then + return speedFixed - SPEED_ROUGH_PENALTY + end + return 0 +end + +local function reduceSpeedBy128(speedFixed) + if speedFixed >= 256 then + return speedFixed - SPEED_HARD_PENALTY + elseif speedFixed >= SPEED_HARD_PENALTY then + return speedFixed - SPEED_HARD_PENALTY + end + return 0 +end + +local function jumpArcCombined(self) + return (self.jumpArcMagnitude or 0) * 256 + (self.jumpArcFraction or 0) +end + +local function setJumpArcCombined(self, value) + if value < 0 then value = 0 end + self.jumpArcMagnitude = math.floor(value / 256) + self.jumpArcFraction = value % 256 +end + +local function applyJumpArcDelta(self, delta) + setJumpArcCombined(self, jumpArcCombined(self) + delta) +end + +local function applyJumpVerticalDelta(self, sign) + local a = self.jumpArcMagnitude or 0 + if a == 0 and (self.jumpArcFraction or 0) == 0 then return end + self.pikaSubY = (self.pikaSubY or 0) + (a * a) * 4 + local intDelta = math.floor(self.pikaSubY / 256) + self.pikaSubY = self.pikaSubY % 256 + if sign < 0 then + self.pikaY = self.pikaY - intDelta + else + self.pikaY = self.pikaY + intDelta + end +end + +-- pret GenerateBGMap write column: ((hSCX + XOffset) & $f0) / 16 +local function mapColWrite(self) + local xOffset = COAST_X_OFFSET + if self.routine == ROUTINE_SCROLL_RESULTS then + xOffset = OUTRO_SCROLL_X_OFFSET + end + local sum = (displayScx(self) + xOffset) % 256 + return math.floor(bit.band(sum, 0xF0) / 16) % MAP_COLS +end + +local function mapColScreen(scx, screenCol) + return (math.floor(scx / 16) + screenCol) % MAP_COLS +end + +local function mapColAtX(scx, x) + return math.floor((scx + x) / 16) % MAP_COLS +end + -- Pikachu states (wSurfingMinigamePikachuState) local PIKA_STATE_RIDING = 0 local PIKA_STATE_JUMPING = 1 @@ -270,6 +398,25 @@ local OAM_LARGE_SPLASH = { { dy = 4, dx = 8, tile = 0xc8, xflip = true }, } +-- Intro title Pikachu (surfing_pikachu_oam.asm .IntroPikachu, frames $20-$23). +-- Each animation frame adds four to the VRAM tile base ($80, $84, $88, $8c); +-- relative tile ids use the usual 16-wide OBJ row stride ($10 per row). +local INTRO_PIKA_FRAME_BASE = { 0x80, 0x84, 0x88, 0x8c } +local INTRO_PIKA_OAM = { + { dy = -12, dx = -16, tile = 0x03, xflip = true }, + { dy = -12, dx = -8, tile = 0x02, xflip = true }, + { dy = -12, dx = 0, tile = 0x01, xflip = true }, + { dy = -12, dx = 8, tile = 0x00, xflip = true }, + { dy = -4, dx = -16, tile = 0x13, xflip = true }, + { dy = -4, dx = -8, tile = 0x12, xflip = true }, + { dy = -4, dx = 0, tile = 0x11, xflip = true }, + { dy = -4, dx = 8, tile = 0x10, xflip = true }, + { dy = 4, dx = -16, tile = 0x23, xflip = true }, + { dy = 4, dx = -8, tile = 0x22, xflip = true }, + { dy = 4, dx = 0, tile = 0x21, xflip = true }, + { dy = 4, dx = 8, tile = 0x20, xflip = true }, +} + -- Beach outro tilemap (gfx/surfing_pikachu/beach_outro.tilemap, 20x10) local BEACH_OUTRO = { { 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0e, 0x0f, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b }, @@ -291,17 +438,28 @@ local PIKACHUS_BEACH_PAL = { { 88, 168, 248 }, -- 2: Ocean Blue Sea Water { 25, 25, 25 }, -- 3: Black Outlines } +-- Title intro uses BG palette slot 1 (PalPacket_PikachusBeachTitle / +-- UnknownPacket_72751) so the logo's shade-2 pixels tint yellow, not sea blue. +local PIKACHUS_BEACH_TITLE_PAL = { + { 255, 255, 255 }, + { 132, 132, 132 }, + { 255, 206, 74 }, + { 25, 25, 25 }, +} -- 5 Tempo tiers (117, 109, 101, 93, 85 from surfing_pikachu.asm) local TEMPO_TIERS = { 1.0, 117 / 109, 117 / 101, 117 / 93, 117 / 85 } function SurfingMinigame.new(game, onDone, skipTitle) local self = setmetatable({ game = game, onDone = onDone }, SurfingMinigame) - self.routine = skipTitle and ROUTINE_START_GAME or ROUTINE_TITLE + self.routine = skipTitle and ROUTINE_RUN_GAME or ROUTINE_TITLE self.pikaState = PIKA_STATE_RIDING self.t = 0 self.routineTimer = 0 - self.distanceFixed = 0 -- 16.8 fixed-point (256 = 1 pixel) + self.distanceFixed = 0 -- 16.8 fixed-point course progress (256 = 1 pixel) + self.distanceSection = 0 -- wSurfingMinigameDistance byte 0 (pret big-endian) + self.distanceAcc = 0 -- wSurfingMinigameDistance bytes 1-2 (16-bit, big-endian) + self.scrollFixed = 0 -- BG scroll (hSCX); diverges from distance during outro coast self.speedFixed = SPEED_INITIAL -- 64 = 0.25 px/frame self.hp = 6000 -- starts at 6000 (60.00 seconds) self.radness = 0 -- accumulated trick stunt points @@ -312,24 +470,38 @@ function SurfingMinigame.new(game, onDone, skipTitle) self.isMinigame = true self.isFixedSpeed = true - -- Hardware RNG simulation registers (hRandomAdd, hRandomSub, rDIV) + -- Hardware RNG simulation (pret never resets hRandomAdd/hRandomSub on minigame entry) self.rDiv = 0 self.rAdd = 0x55 self.rSub = 0xaa - -- Wave height tracking & column ring buffer + -- Wave height tracking: pret vBGMap0 (32 metatile columns, circular) self.waveFn = 0 + self.bgMap = {} + for c = 0, MAP_COLS - 1 do + self.bgMap[c] = { pat = WAVE_PATTERNS[0x00], hl = FLAT_WATER_Y, hr = FLAT_WATER_Y } + end + -- Linear cols mirror kept for unit tests only self.cols = {} for c = 0, 10 do - self.cols[c] = { pat = WAVE_PATTERNS[0x00], hl = FLAT_WATER_Y, hr = FLAT_WATER_Y } + self.cols[c] = flatCol end self.colTail = 10 + -- wSurfingMinigameWaveHeight (20 entries, shifted each GenerateBGMap) + self.waveHeight = {} + for i = 1, 20 do + self.waveHeight[i] = FLAT_WATER_Y + end + self.bgMapReadTile = 0x0b -- wSurfingMinigameBGMapReadBuffer (updated each ReadBGMapBuffer) + self.waveRandomValue = 0 -- wSurfingMinigameWaveRandomValue (Random each RunGame frame) + -- Jumping, Arc Physics & Air Rotation self.pikaY = FLAT_WATER_Y - 16 -- integer screen Y of Pikachu (sea waterline) self.pikaSubY = 0 -- 8.8 subpixel carry (0..255) self.pikaYOffset = 0 -- sine offset (splash/bobbing) - self.jumpArcMagnitude = 0 -- SpeedDividedBy32 (range 10..16) + self.jumpArcMagnitude = 0 -- wSurfingMinigameJumpArcMagnitude (GetSpeedDividedBy32, >= 10) + self.jumpArcFraction = 0 -- wSurfingMinigameJumpArcFraction (8.8 sub-byte) self.jumpDescending = false self.frameSet = 4 -- Starts at Frame 4 (flat horizontal ride) self.boardAngleOffset = 0 -- wobbling 0..2 @@ -338,9 +510,18 @@ function SurfingMinigame.new(game, onDone, skipTitle) self.crashTimer = 0 self.landingTimer = 0 - -- 3-frame buffered D-Pad rotation & input accumulator - self.joyCounter = 0 - self.inputAccum = 0 -- bit 0 = Right, bit 1 = Left + -- Outro coast / results card (SurfingMinigame_WaitToShowResults .. WaitLast) + self.hScx = 0 -- hSCX register (8-bit, wraps) + self.scxFrac = 0 -- wSurfingMinigameSCX fractional byte + self.scxHi = 0 -- wSurfingMinigameSCXHi (zero-init WRAM; band 0 until hSCX >= $10) + self.scxLast = 0 -- wSurfingMinigameSCX2 (last hSCX seen by GenerateBGMap) + self.showResultsCard = false + self.outroLines = { hp = false, rad = false, total = false, hiScore = false } + + -- SurfingPikachu_GetJoypad_3FrameBuffer (hJoy5 + hFrameCounter countdown) + self.joyFrameCounter = 0 + self.joy5Left = false + self.joy5Right = false self.rotCountLeft = 0 self.rotCountRight = 0 self.radnessMeter = 0 -- consecutive flips (capped at 3) @@ -348,11 +529,16 @@ function SurfingMinigame.new(game, onDone, skipTitle) -- Sprites & Popups self.startBannerX = 224 -- slides from 224 to 80 (center) + self.introPikaX = 80 -- intro Pikachu walks off-screen before the run self.ohNoBanner = false self.trickPopups = {} -- { text = "+150", x, y, timer } self.waterSprays = {} -- { x, y, timer } self.sprayTimer = 0 - self.cloudOffsetFixed = 0 + self.cloudScrollFrac = 0 -- wSurfingMinigameCloudScrollFraction + self.cloudSpriteX = {} + for i, x in ipairs(CLOUD_SPRITE_X_INIT) do + self.cloudSpriteX[i] = x + end -- Results Tally Animation self.tallyStep = 0 @@ -403,10 +589,6 @@ function SurfingMinigame.new(game, onDone, skipTitle) for n = 0, 143 do self.iq[n] = love.graphics.newQuad((n % 12) * 8, math.floor(n / 12) * 8, 8, 8, iW, iH) end - -- Pika Intro Poses (24x32 px each in top 32px: X=0, 24, 48) - self.introPikaQuad1 = love.graphics.newQuad(0, 0, 24, 32, iW, iH) - self.introPikaQuad2 = love.graphics.newQuad(24, 0, 24, 32, iW, iH) - self.introPikaQuad3 = love.graphics.newQuad(48, 0, 24, 32, iW, iH) -- Title Banner Logo ("PIKACHU'S BEACH") (96x32 px at Y=32..64) self.introLogoQuad = love.graphics.newQuad(0, 32, 96, 32, iW, iH) -- Instruction Text ("Use Control Pad to Surf") (96x32 px at Y=64..96) @@ -445,33 +627,38 @@ end -- Transition cleanly from Title Screen to active run, flushing input bleed-through function SurfingMinigame:startFromTitle() self.routine = ROUTINE_START_GAME - self.inputAccum = 0 - self.joyCounter = 0 + self.joyFrameCounter = 0 + self.joy5Left = false + self.joy5Right = false self.rotCountLeft = 0 self.rotCountRight = 0 + -- Title screen elapsed frames churn rDIV/LFSR like overworld play before entry. + for _ = 1, self.t + math.floor(self.introPikaX / 2) do + self:getGBRandom() + end self:resetTempo() if self.game and self.game.input and self.game.input.clearPressed then self.game.input:clearPressed() end - Sound.play(self.game and self.game.data, "Press_AB") end --- Authentic Game Boy LFSR Random Number Generator +-- Authentic Game Boy LFSR Random Number Generator (engine/math/random.asm Random_) function SurfingMinigame:getGBRandom() - self.rDiv = (self.rDiv + 13) % 256 - local tempAdd = self.rAdd + self.rDiv - self.rAdd = tempAdd % 256 - local tempSub = self.rSub - self.rDiv - self.rSub = (tempSub + 256) % 256 + local div1 = self.rDiv + self.rDiv = (self.rDiv + 1) % 256 + local div2 = self.rDiv + self.rAdd = (self.rAdd + div1) % 256 + self.rSub = (self.rSub - div2 + 256) % 256 return self.rAdd end function SurfingMinigame:chooseSequence() - local distPx = math.floor(self.distanceFixed / 256) - local section = math.floor(distPx / 128) + local section = self.distanceSection or 0 if section == 0x16 then self.waveFn = 0x6a elseif section < 0x16 then + -- Random at selection time (when waveFn==0 column generates), not frame-start + -- waveRandomValue. Fixed LFSR init otherwise phase-locks to two small sequences. local r = self:getGBRandom() if r ~= 0 then self.waveFn = SEQ_STARTS[bit.band(r - 1, 0x07) + 1] @@ -480,11 +667,35 @@ function SurfingMinigame:chooseSequence() return WAVE_PATTERNS[0x00], FLAT_WATER_Y, FLAT_WATER_Y end +-- SurfingMinigame_UpdatePikachuDistance (3-byte big-endian distance + section counter) +function SurfingMinigame:updatePikachuDistance() + local acc = (self.distanceAcc or 0) + self.speedFixed + while acc >= SECTION_ACC_MAX do + acc = acc - SECTION_ACC_MAX + self.distanceSection = (self.distanceSection or 0) + 1 + end + self.distanceAcc = acc + self.distanceFixed = self.distanceSection * SECTION_ACC_MAX + acc +end + +function SurfingMinigame:moveClouds() + -- SurfingMinigame_MoveClouds: add speed high byte to each cloud OAM X (8-bit wrap) + local sum = (self.cloudScrollFrac or 0) + (self.speedFixed or 0) + self.cloudScrollFrac = sum % 256 + local delta = math.floor(sum / 256) + if delta == 0 then return end + for i = 1, #CLOUD_SPRITE_X_INIT do + self.cloudSpriteX[i] = (self.cloudSpriteX[i] + delta) % 256 + end +end + function SurfingMinigame:pushColumn() local pat, hl, hr - local distPx = math.floor(self.distanceFixed / 256) - if distPx >= (TOTAL_SECTIONS * 128) or self.waveFn >= 0x74 then - -- Lock finish line to solid beach sand without cycling back to ocean waves + + if self.routine == ROUTINE_WAIT_RESULTS then + -- CoastAfterGoal with wSurfingMinigameWaveRandomValue = 0 → flat slice + pat, hl, hr = WAVE_PATTERNS[0x00], FLAT_WATER_Y, FLAT_WATER_Y + elseif self.waveFn >= 0x74 then pat, hl, hr = WAVE_PATTERNS["beach"], FLAT_WATER_Y, FLAT_WATER_Y self.waveFn = 0x74 elseif self.waveFn == 0 then @@ -500,55 +711,101 @@ function SurfingMinigame:pushColumn() elseif step[4] == RESET then self.waveFn = 0 end end end - self.colTail = self.colTail + 1 - self.cols[self.colTail] = { pat = pat, hl = hl, hr = hr } - self.cols[self.colTail - 24] = nil + + local col = { pat = pat, hl = hl, hr = hr } + self.bgMap[mapColWrite(self)] = col + + -- Shift wSurfingMinigameWaveHeight left, append new column heights + for i = 1, 18 do + self.waveHeight[i] = self.waveHeight[i + 2] + end + self.waveHeight[19] = hl + self.waveHeight[20] = hr + + if not isOutroScroll(self) then + self.colTail = self.colTail + 1 + self.cols[self.colTail] = col + self.cols[self.colTail - 24] = nil + end end +function SurfingMinigame:advanceScx(deltaFixed) + local sum = (self.hScx or 0) * 256 + (self.scxFrac or 0) + deltaFixed + self.hScx = math.floor(sum / 256) % 256 + self.scxFrac = sum % 256 +end + +-- SurfingMinigame_GenerateBGMap: new column only when hSCX changes and $f0 band changes +function SurfingMinigame:generateBgMapIfNeeded() + local h = self.hScx or 0 + if h == self.scxLast then return end + self.scxLast = h + local band = bit.band(h, 0xF0) + if band == self.scxHi then return end + self.scxHi = band + self:pushColumn() +end + +-- Legacy linear buffer kept for unit tests; pret uses generateBgMapIfNeeded only. function SurfingMinigame:generateAhead() - local distPx = math.floor(self.distanceFixed / 256) - while self.colTail * 16 < distPx + 176 do self:pushColumn() end + self:generateBgMapIfNeeded() +end + +local function columnAt(self, x) + local scx = displayScx(self) + return self.bgMap[mapColAtX(scx, x)] +end + +-- SurfingMinigame_SetPikachuHeight: wave height with slope from prior BGMapReadBuffer +function SurfingMinigame:pikaObjectHeight(tileForSlope) + local scx = displayScx(self) + local idx = (bit.band(scx, 8) ~= 0) and 9 or 8 + local h = self.waveHeight[idx] + local tile = tileForSlope or self.bgMapReadTile or 0x0b + if tile == 0x06 or tile == 0x14 then + return h - bit.band(scx, 7) + elseif tile == 0x07 then + return h + bit.band(scx, 7) + end + return h +end + +local function pikaWaterY(self) + return self:pikaObjectHeight(self.bgMapReadTile) end -- Get water surface Y for a screen X coordinate (X=80 under Pikachu center) function SurfingMinigame:seaY(x) - local distPx = math.floor(self.distanceFixed / 256) - local tile = math.floor((distPx + x) / 8) - local col = self.cols[math.floor(tile / 2)] + if x == 80 then + return pikaWaterY(self) + end + local col = columnAt(self, x) if not col then return FLAT_WATER_Y end + local scx = displayScx(self) + local tile = math.floor((scx + x) / 8) return tile % 2 == 0 and col.hl or col.hr end --- Get the tile ID of the wave under Pikachu (sample 9 tiles / 72 pixels into viewport) -function SurfingMinigame:getWaveTileUnderPika() - local distPx = math.floor(self.distanceFixed / 256) - local tile_col = math.floor((distPx + 72) / 8) - local waterY = math.floor(self:seaY(80)) - local tile_row = math.floor(waterY / 8) +-- Chr tile at Pikachu's object height (SurfingMinigame_ReadBGMapBuffer). +function SurfingMinigame:sampleBgTileAt(scx, objectHeightPx) + local tile_col = math.floor((scx + 72) / 8) + local tile_row = math.floor(objectHeightPx / 8) - local c = math.floor(tile_col / 2) - local col = self.cols[c] - if not col or not col.pat then return 0x01 end + local col = self.bgMap[mapColAtX(scx, 72)] + if not col or not col.pat then return 0x0b end local i = math.floor(tile_row / 2) + 1 - if i < 1 or i > 8 then return 0x01 end + if i < 1 or i > 8 then return 0x0b end local mt = BG_METATILES[col.pat[i]] - if not mt then return 0x01 end + if not mt then return 0x0b end local sub_x = (tile_col % 2 == 0) and 0 or 1 local sub_y = (tile_row % 2 == 0) and 0 or 1 + return mt[1 + sub_x + sub_y * 2] or 0x0b +end - local sub_idx = 1 + sub_x + sub_y * 2 - local mt_tile = mt[sub_idx] or 0x01 - - if mt_tile == 0x02 or mt_tile == 0x04 or mt_tile == 0x06 or mt_tile == 0x0a or mt_tile == 0x11 then - return 0x06 -- rising slope - elseif mt_tile == 0x03 or mt_tile == 0x05 or mt_tile == 0x07 or mt_tile == 0x0d or mt_tile == 0x13 then - return 0x07 -- falling slope - elseif mt_tile == 0x08 or mt_tile == 0x09 or mt_tile == 0x0f or mt_tile == 0x10 or mt_tile == 0x12 or mt_tile == 0x14 or mt_tile == 0x15 then - return 0x14 -- wave crest / face - end - return 0x01 -- open water (0x0b, 0x00, 0x0e, etc.) +function SurfingMinigame:getWaveTileUnderPika() + return self.bgMapReadTile or 0x0b end function SurfingMinigame:spawnTrickPopup(text) @@ -593,52 +850,44 @@ end -- Slope/tile interaction matrix upon landing (SurfingMinigame_TileInteraction) function SurfingMinigame:evaluateLanding() local f = self.frameSet - -- Flipped / upside-down frames (8..14) ALWAYS wipeout unconditionally! if f >= 8 or f < 1 then return "wipeout" end local tile = self:getWaveTileUnderPika() - if tile == 0x06 then -- risingSlope - if f == 6 then return "clean" + if tile == 0x06 then -- rising slope + if f <= 3 then return "wipeout" + elseif f == 4 then return "hard" elseif f == 5 or f == 7 then return "rough" - elseif f == 4 then return "hard" - else return "wipeout" end -- 1, 2, 3 + elseif f == 6 then return "clean" + else return "wipeout" end - elseif tile == 0x07 then -- fallingSlope - if f == 2 then return "clean" - elseif f == 1 or f == 3 then return "rough" + elseif tile == 0x07 then -- falling slope + if f == 1 then return "rough" + elseif f == 2 then return "clean" + elseif f == 3 then return "rough" elseif f == 4 then return "hard" - else return "wipeout" end -- 5, 6, 7 + else return "wipeout" end - elseif tile == 0x14 or tile == 0x12 then -- waveCrest / waveFace - if f == 4 or f == 5 then return "clean" - elseif f == 3 or f == 6 then return "rough" + elseif tile == 0x12 or tile == 0x14 then -- wave face / crest + if f == 1 then return "wipeout" elseif f == 2 or f == 7 then return "hard" - else return "wipeout" end -- 1 + elseif f == 3 or f == 6 then return "rough" + elseif f == 4 or f == 5 then return "clean" + else return "wipeout" end - else -- flat open water - if f == 4 then return "clean" - elseif f == 3 or f == 5 then return "rough" + else -- flat open water (every other metatile id) + if f == 1 or f == 7 then return "wipeout" elseif f == 2 or f == 6 then return "hard" - else return "wipeout" end -- 1, 7 + elseif f == 3 or f == 5 then return "rough" + elseif f == 4 then return "clean" + else return "wipeout" end end end function SurfingMinigame:updateTempo() - local tier = 1 - if self.speedFixed >= 416 then - tier = 5 - elseif self.speedFixed >= 320 then - tier = 4 - elseif self.speedFixed >= 224 then - tier = 3 - elseif self.speedFixed >= 128 then - tier = 2 - else - tier = 1 - end + local tier = pretTempoTier(self.speedFixed or SPEED_INITIAL) local targetPitch = TEMPO_TIERS[tier] or 1.0 if self.currentPitch ~= targetPitch then self.currentPitch = targetPitch @@ -655,60 +904,68 @@ function SurfingMinigame:resetTempo() end end -function SurfingMinigame:updateRiding() - -- Automatic speed up (+2/256 = +1/128 per frame up to max 512 = 2.0) - if self.speedFixed < SPEED_MAX then - self.speedFixed = math.min(SPEED_MAX, self.speedFixed + SPEED_ACCEL) +-- Pret RunDelayTimer: count down routineTimer; return true when it hits zero. +local function outroDelayExpired(self) + if self.routineTimer > 0 then + self.routineTimer = self.routineTimer - 1 + return false end - self:updateTempo() + return true +end - -- Follow wave surface height - local targetY = self:seaY(80) - self.pikaY = math.floor(targetY) - 16 - self.pikaSubY = 0 +function SurfingMinigame:beginResultsCard() + self.showResultsCard = true + self.outroLines = { hp = false, rad = false, total = false, hiScore = false } + self:initResultsPikachu() + self.speedFixed = 0 + -- DrawResultsScreen clears cloud OAM (sprites 5–13); hide parallax clouds on the beach card + self.cloudSpriteX = nil +end - -- Water spray every 4 frames - self.sprayTimer = self.sprayTimer + 1 - if self.sprayTimer % 4 == 0 then - table.insert(self.waterSprays, { x = PIKA_X, y = self.pikaY, timer = 4 }) - end +-- SurfingMinigame_InitResultsPikachu: flat results pose at shore Y, reset bob counter +function SurfingMinigame:initResultsPikachu() + self.pikaState = PIKA_STATE_INIT_RESULTS + self.frameSet = 4 -- flat ride (visible pose; pret writes frameset $0f to anim struct) + self.pikaY = FLAT_WATER_Y - PIKA_SPRITE_OFFSET + self.pikaSubY = 0 + self.pikaYOffset = 0 + self.resultsBobTimer = 0 +end - -- Board angle wobbling every 8 frames - self.boardAngleTimer = self.boardAngleTimer + 1 - if self.boardAngleTimer % 8 == 0 then - if self.boardAngleDecreasing then - if self.boardAngleOffset > 0 then - self.boardAngleOffset = self.boardAngleOffset - 1 - else - self.boardAngleDecreasing = false - end - else - if self.boardAngleOffset < 2 then - self.boardAngleOffset = self.boardAngleOffset + 1 - else - self.boardAngleDecreasing = true - end - end - end - - -- Select frame based on slope (lock flat open water to frame 4 without wobble) - local tile = self:getWaveTileUnderPika() - if tile == 0x06 or tile == 0x14 then - self.frameSet = 6 + (self.boardAngleOffset - 1) - elseif tile == 0x07 then - self.frameSet = 2 + (self.boardAngleOffset - 1) +-- SurfingMinigame_UpdateResultsPikachu (hi-score path only sets PIKA_STATE_RESULTS in pret) +function SurfingMinigame:updateResultsPikachu() + self.resultsBobTimer = (self.resultsBobTimer or 0) + 2 + local phase = bit.band(self.resultsBobTimer, RESULTS_BOB_CYCLE - 1) + if phase >= RESULTS_BOB_START then + self.pikaYOffset = math.floor( + math.sin((phase - RESULTS_BOB_START) / (RESULTS_BOB_CYCLE / 2) * math.pi * 2) * RESULTS_BOB_AMP + ) else - self.frameSet = 4 -- flat open water: steady horizontal ride + self.pikaYOffset = 0 end - self.frameSet = math.max(1, math.min(14, self.frameSet)) +end - -- Automatic jump off wave crest ($14) if speed >= 1.25 (SPEED_JUMP_THRESHOLD = 320) - local distPx = math.floor(self.distanceFixed / 256) - local subX = distPx % 8 - if (subX >= 3 and subX <= 4) and tile == 0x14 and self.speedFixed >= SPEED_JUMP_THRESHOLD then +function SurfingMinigame:drawResultsPikachu() + if not (self.ob and self.oq) then return end + local cx, cy = 80, self.pikaY + (self.pikaYOffset or 0) + self.pikaScreenY = cy + local toggle = math.floor(self.t / 8) % 2 + 1 + local base = RESULTS_PIKA_BASES[toggle] + if not self.oq[base] then + base = ANGLE_BASES[4][toggle] + end + self:draw3x3(base, cx, cy, false, false) +end + +function SurfingMinigame:updateRiding() + local tile = self.bgMapReadTile or 0x0b + local subX = bit.band(displayScx(self), 7) + + -- SurfingMinigame_TryStartJump (before SpeedUpPikachu; uses pre-accel speed) + if (subX >= 3 and subX <= 4) and tile == 0x14 and getSpeedDividedBy32(self.speedFixed) >= SPEED_JUMP_MIN_DIV32 then self.pikaState = PIKA_STATE_JUMPING - local spd = self.speedFixed / 256 - self.jumpArcMagnitude = math.min(16, math.max(10, math.floor(spd * 8))) + self.jumpArcMagnitude = getSpeedDividedBy32(self.speedFixed) + self.jumpArcFraction = 0 self.pikaSubY = 0 self.jumpDescending = false self.radnessMeter = 0 @@ -716,6 +973,59 @@ function SurfingMinigame:updateRiding() self.rotCountLeft = 0 self.rotCountRight = 0 Sound.play(self.game.data, "Ledge_Jump") + -- SurfingMinigame_UpdateSurfingFrame still runs on .startedJump + if subX >= 3 and subX <= 4 then + if tile == 0x06 or tile == 0x14 then + self.frameSet = 6 + (self.boardAngleOffset - 1) + elseif tile == 0x07 then + self.frameSet = 2 + (self.boardAngleOffset - 1) + end + self.frameSet = math.max(1, math.min(14, self.frameSet)) + end + return + end + + -- SurfingMinigame_UpdateSurfingFrame (only updates frame when subX is 3..4) + if subX >= 3 and subX <= 4 then + if tile == 0x06 or tile == 0x14 then + self.frameSet = 6 + (self.boardAngleOffset - 1) + elseif tile == 0x07 then + self.frameSet = 2 + (self.boardAngleOffset - 1) + else + self:updateBoardAngle() + self.frameSet = 4 + end + self.frameSet = math.max(1, math.min(14, self.frameSet)) + end + + -- SurfingMinigame_SpeedUpPikachu (+2/256 = +1/128 per frame up to max 512 = 2.0) + if self.speedFixed < SPEED_MAX then + self.speedFixed = math.min(SPEED_MAX, self.speedFixed + SPEED_ACCEL) + end + self:updateTempo() + + -- Water spray every 4 frames + self.sprayTimer = self.sprayTimer + 1 + if self.sprayTimer % 4 == 0 then + table.insert(self.waterSprays, { x = PIKA_X, y = self.pikaY, timer = 4 }) + end +end + +function SurfingMinigame:updateBoardAngle() + self.boardAngleTimer = (self.boardAngleTimer or 0) + 1 + if self.boardAngleTimer % 8 ~= 0 then return end + if self.boardAngleDecreasing then + if self.boardAngleOffset > 0 then + self.boardAngleOffset = self.boardAngleOffset - 1 + else + self.boardAngleDecreasing = false + end + else + if self.boardAngleOffset < 2 then + self.boardAngleOffset = self.boardAngleOffset + 1 + else + self.boardAngleDecreasing = true + end end end @@ -723,79 +1033,70 @@ function SurfingMinigame:handleLanding() local result = self:evaluateLanding() if result == "wipeout" then self.pikaState = PIKA_STATE_CRASHED - self.crashTimer = 96 + self.crashTimer = CRASH_FRAMES self.speedFixed = SPEED_INITIAL self.frameSet = 4 Sound.play(self.game.data, "Faint_Fall") else if result == "rough" then - self.speedFixed = math.max(SPEED_INITIAL, self.speedFixed - SPEED_ROUGH_PENALTY) + self.speedFixed = reduceSpeedBy64(self.speedFixed) elseif result == "hard" then - self.speedFixed = math.max(SPEED_INITIAL, self.speedFixed - SPEED_HARD_PENALTY) + self.speedFixed = reduceSpeedBy128(self.speedFixed) end if self.routine == ROUTINE_RUN_GAME then self:calculateStuntPoints() end self.pikaState = PIKA_STATE_LANDING - self.landingTimer = 32 + self.landingTimer = LANDING_SPLASH_FRAMES self.frameSet = 4 Sound.play(self.game.data, "Cut") end end function SurfingMinigame:updateJumping() - -- Process accumulated input on the 3-frame buffer boundary (only during active run) + -- SurfingMinigame_DPadAction (hJoy5 sampled every 2 frames via GetJoypad) if self.routine == ROUTINE_RUN_GAME then - self.joyCounter = (self.joyCounter + 1) % 3 - if self.joyCounter == 0 then - local rightHeld = bit.band(self.inputAccum, 1) ~= 0 - local leftHeld = bit.band(self.inputAccum, 2) ~= 0 - self.inputAccum = 0 - - -- Game Boy priority: Left D-pad checked first, then Right D-pad - if leftHeld then - self.rotCountRight = 0 - self.rotCountLeft = self.rotCountLeft + 1 - if self.rotCountLeft >= 11 then - self.rotCountLeft = 0 - self.radnessMeter = math.min(3, self.radnessMeter + 1) - self.trickFlags = bit.bor(self.trickFlags, 1) - Sound.play(self.game.data, "Tink") - end - self.frameSet = (self.frameSet % 14) + 1 - elseif rightHeld then + if self.joy5Left then + self.rotCountRight = 0 + self.rotCountLeft = self.rotCountLeft + 1 + if self.rotCountLeft >= FLIP_LEFT_FRAMES then self.rotCountLeft = 0 - self.rotCountRight = self.rotCountRight + 1 - if self.rotCountRight >= 13 then - self.rotCountRight = 0 - self.radnessMeter = math.min(3, self.radnessMeter + 1) - self.trickFlags = bit.bor(self.trickFlags, 2) - Sound.play(self.game.data, "Tink") - end + self.radnessMeter = math.min(RADNESS_METER_MAX, self.radnessMeter + 1) + self.trickFlags = bit.bor(self.trickFlags, 1) + Sound.play(self.game.data, "Tink") + end + if self.frameSet >= 14 then + self.frameSet = 1 + else + self.frameSet = self.frameSet + 1 + end + elseif self.joy5Right then + self.rotCountLeft = 0 + self.rotCountRight = self.rotCountRight + 1 + if self.rotCountRight >= FLIP_RIGHT_FRAMES then + self.rotCountRight = 0 + self.radnessMeter = math.min(RADNESS_METER_MAX, self.radnessMeter + 1) + self.trickFlags = bit.bor(self.trickFlags, 2) + Sound.play(self.game.data, "Tink") + end + if self.frameSet <= 1 then + self.frameSet = 14 + else self.frameSet = self.frameSet - 1 - if self.frameSet < 1 then self.frameSet = 14 end end end - else - self.inputAccum = 0 end - -- Authentic Game Boy collision boundary & integer fixed-point jump physics + -- SurfingMinigame_UpdatePikachuHeight (arc delta before velocity each phase) if not self.jumpDescending then - local a = math.floor(self.jumpArcMagnitude) - self.pikaSubY = (self.pikaSubY or 0) + (a * a) * 4 - local intDelta = math.floor(self.pikaSubY / 256) - self.pikaSubY = self.pikaSubY % 256 - self.pikaY = self.pikaY - intDelta - - self.jumpArcMagnitude = self.jumpArcMagnitude - 0.5 - if self.jumpArcMagnitude <= 0 then - self.jumpArcMagnitude = 0 + if (self.jumpArcMagnitude or 0) == 0 and (self.jumpArcFraction or 0) == 0 then self.jumpDescending = true + else + applyJumpArcDelta(self, -128) -- -0.5 px/frame in 8.8 fixed + applyJumpVerticalDelta(self, -1) end else - -- Hardware execution order: evaluate boundary before adding velocity - local waveY = math.floor(self:seaY(80)) - 16 + local waveY = pikaSpriteYFromObjectHeight(self.pikaObjectHeightPx) if self.pikaY >= waveY then self.pikaY = waveY self.pikaSubY = 0 @@ -803,12 +1104,8 @@ function SurfingMinigame:updateJumping() return end - local a = math.floor(self.jumpArcMagnitude) - self.pikaSubY = (self.pikaSubY or 0) + (a * a) * 4 - local intDelta = math.floor(self.pikaSubY / 256) - self.pikaSubY = self.pikaSubY % 256 - self.pikaY = self.pikaY + intDelta - self.jumpArcMagnitude = self.jumpArcMagnitude + 0.5 + applyJumpArcDelta(self, 128) -- +0.5 px/frame in 8.8 fixed + applyJumpVerticalDelta(self, 1) if self.pikaY >= waveY then self.pikaY = waveY @@ -821,12 +1118,9 @@ end function SurfingMinigame:updateLanding() self.landingTimer = (self.landingTimer or 0) - 1 - -- Follow wave surface height continuously while landing so slopes don't cause position jumps! - local targetY = self:seaY(80) - self.pikaY = math.floor(targetY) - 16 - self.pikaSubY = 0 + -- pikaY already updated by SetPikachuHeight in tick (pre-scroll object height). - -- Sine wave splash offset + -- Sine wave splash offset (FIELD_C 0..$20 by +4/frame) self.pikaYOffset = math.floor(math.sin((32 - math.max(0, self.landingTimer)) / 32 * math.pi * 2) * 4) if self.landingTimer % 4 == 0 then table.insert(self.waterSprays, { x = PIKA_X, y = self.pikaY, timer = 4 }) @@ -842,10 +1136,7 @@ end function SurfingMinigame:updateCrashed() self.crashTimer = self.crashTimer - 1 self:resetTempo() - -- Follow water surface while wiped out - local targetY = self:seaY(80) - self.pikaY = math.floor(targetY) - 16 - self.pikaSubY = 0 + -- pikaY already updated by SetPikachuHeight in tick. if self.crashTimer <= 0 then self.pikaState = PIKA_STATE_RIDING self.frameSet = 4 @@ -856,22 +1147,34 @@ end function SurfingMinigame:tick() local input = self.game and self.game.input self.t = self.t + 1 - self.rDiv = (self.rDiv + 1) % 256 + self.rDiv = (self.rDiv + RDIV_PER_FRAME) % 256 - -- Title Screen State + -- Title Screen State (auto-advances when intro Pikachu walks off-screen) if self.routine == ROUTINE_TITLE then - if input and (input:wasPressed("start") or input:wasPressed("a")) then + if self.t % 2 == 0 then + self.introPikaX = self.introPikaX + 1 + end + if self.introPikaX >= 192 then self:startFromTitle() end return end - -- Accumulate physical button presses on every frame (only during active run) + -- SurfingPikachu_GetJoypad_3FrameBuffer: hJoy5 = hJoyHeld when hFrameCounter==0, then reload $2; + -- VBlank decrements counter → 1 sample frame + 2 blank frames (3-frame duty cycle). if self.routine == ROUTINE_RUN_GAME then - if input and input:isDown("right") then self.inputAccum = bit.bor(self.inputAccum, 1) end - if input and input:isDown("left") then self.inputAccum = bit.bor(self.inputAccum, 2) end + if (self.joyFrameCounter or 0) == 0 then + self.joy5Left = input and input:isDown("left") + self.joy5Right = input and input:isDown("right") + self.joyFrameCounter = JOY_FRAME_RELOAD + else + self.joy5Left = false + self.joy5Right = false + end else - self.inputAccum = 0 + self.joy5Left = false + self.joy5Right = false + self.joyFrameCounter = 0 end -- Update trick popups @@ -889,40 +1192,59 @@ function SurfingMinigame:tick() if s.timer <= 0 then table.remove(self.waterSprays, i) end end + -- Slide START banner during RunGame (pret animates it while gameplay runs) + if self.routine == ROUTINE_RUN_GAME and self.startBannerX > 80 then + self.startBannerX = math.max(80, self.startBannerX - 4) + end + -- Routine state machine if self.routine == ROUTINE_START_GAME then - if self.startBannerX > 80 then - self.startBannerX = math.max(80, self.startBannerX - 4) - else - self.routine = ROUTINE_RUN_GAME - end + -- SurfingMinigame_StartGame: spawn banner, inc routine; RunGame begins next frame + self.routine = ROUTINE_RUN_GAME + return elseif self.routine == ROUTINE_RUN_GAME then - -- Deduct 1 HP per frame (stamina countdown from 6000 BCD) - if self.hp > 0 then - self.hp = self.hp - 1 - else - -- Game Over when HP hits 0 + -- SurfingMinigame_RunGame: distance check first (cp $18) + if (self.distanceSection or 0) >= TOTAL_SECTIONS then + self.distanceSection = TOTAL_SECTIONS + self.routine = ROUTINE_WAIT_RESULTS + self.routineTimer = COAST_FRAMES + self.scxHi = bit.band(self.hScx or 0, 0xF0) + self.scxLast = self.hScx or 0 + self.waveFn = 0 + self:resetTempo() + return + end + + -- HP dead check before frame logic (pret or [hl] on wSurfingMinigamePikachuHP) + if (self.hp or 0) <= 0 then self.routine = ROUTINE_GAME_OVER - self.routineTimer = 128 + self.routineTimer = GAME_OVER_DELAY self.speedFixed = 0 self.ohNoBanner = true Sound.play(self.game and self.game.data, "Faint_Fall") return end - -- Scroll distance & generate wave columns (authentic 1:1 GB pace: distance += speedFixed) - self.distanceFixed = self.distanceFixed + self.speedFixed - self.cloudOffsetFixed = self.cloudOffsetFixed + math.floor(self.speedFixed * 0.25) - self:generateAhead() + self.waveRandomValue = self:getGBRandom() - -- Check if course goal reached (24 sections) - local distPx = math.floor(self.distanceFixed / 256) - if distPx >= (TOTAL_SECTIONS * 128) then - self.routine = ROUTINE_WAIT_RESULTS - self.routineTimer = 192 - self.waveFn = 0x72 - return + -- Pret RunGame order: SetPikachuHeight, ReadBGMapBuffer, Scroll, Distance, Deduct1HP + local objectHeight = self:pikaObjectHeight(self.bgMapReadTile) + self.pikaObjectHeightPx = objectHeight + if self.pikaState ~= PIKA_STATE_JUMPING then + self.pikaY = pikaSpriteYFromObjectHeight(objectHeight) + self.pikaSubY = 0 end + local preScrollScx = displayScx(self) + self.bgMapReadTile = self:sampleBgTileAt(preScrollScx, objectHeight) + + self:advanceScx(BG_SCROLL_STEP) + self:generateBgMapIfNeeded() + + self:updatePikachuDistance() + self.scrollFixed = self.distanceFixed + + -- SurfingMinigame_Deduct1HP (after UpdatePikachuDistance) + self.hp = self.hp - 1 -- Update Pikachu by state if self.pikaState == PIKA_STATE_RIDING then @@ -936,12 +1258,14 @@ function SurfingMinigame:tick() end elseif self.routine == ROUTINE_WAIT_RESULTS then - -- Coasting past the goal line - self.distanceFixed = self.distanceFixed + (2 * 256) - self.cloudOffsetFixed = self.cloudOffsetFixed + math.floor((2 * 256) * 0.25) - self:generateAhead() + if self.routineTimer > 0 then + -- RunDelayTimer then CoastAfterGoal (192 coast frames, not 193) + self.routineTimer = self.routineTimer - 1 + self:advanceScx(COAST_SPEED_FIXED) + self:generateBgMapIfNeeded() + self:resetTempo() + end - -- Run Pikachu state machine so mid-air jumps and wipeout crashes complete if self.pikaState == PIKA_STATE_JUMPING then self:updateJumping() elseif self.pikaState == PIKA_STATE_LANDING then @@ -949,101 +1273,101 @@ function SurfingMinigame:tick() elseif self.pikaState == PIKA_STATE_CRASHED then self:updateCrashed() else - -- Riding: follow water surface local targetY = self:seaY(80) self.pikaY = math.floor(targetY) - 16 self.pikaSubY = 0 self.frameSet = 4 end - if self.routineTimer > 0 then - self.routineTimer = self.routineTimer - 1 - end - - -- Only advance to scroll results when delay has finished AND Pikachu is upright - if self.routineTimer <= 0 and self.pikaState == PIKA_STATE_RIDING then + if self.routineTimer <= 0 then + -- pret .doneDelay: enter results scroll immediately (no wait for landing/crash) self.routine = ROUTINE_SCROLL_RESULTS - self.routineTimer = 36 + self.hScx = OUTRO_SCROLL_START + self.scxFrac = 0 + self.scxHi = 0 + self.scxLast = 0 + self.waveFn = 0x72 self.pikaState = PIKA_STATE_GAME_END end elseif self.routine == ROUTINE_SCROLL_RESULTS then - self.distanceFixed = self.distanceFixed + (1 * 256) - self.cloudOffsetFixed = self.cloudOffsetFixed + math.floor((1 * 256) * 0.25) - self:generateAhead() - self.pikaY = math.floor(self:seaY(80)) - 16 - self.pikaSubY = 0 - self.frameSet = 4 - self.routineTimer = self.routineTimer - 1 - if self.routineTimer <= 0 then + if (self.hScx or 0) <= 0 then self.routine = ROUTINE_DRAW_RESULTS - self.routineTimer = 64 - self.pikaState = PIKA_STATE_RESULTS + self.pikaState = PIKA_STATE_INIT_RESULTS + else + self.hScx = self.hScx - OUTRO_SCROLL_STEP + self:generateBgMapIfNeeded() + local targetY = self:seaY(80) + self.pikaY = math.floor(targetY) - 16 + self.pikaSubY = 0 + self.frameSet = 4 end elseif self.routine == ROUTINE_DRAW_RESULTS then - self.routineTimer = self.routineTimer - 1 - if self.routineTimer <= 0 then - self.routine = ROUTINE_WRITE_HP_LEFT - self.routineTimer = 32 - end + -- DrawResultsScreenAndWait: one-shot static beach tilemap + textbox frame + self:beginResultsCard() + self.routineTimer = 32 + self.routine = ROUTINE_WRITE_HP_LEFT elseif self.routine == ROUTINE_WRITE_HP_LEFT then - self.routineTimer = self.routineTimer - 1 - if self.routineTimer <= 0 then + if outroDelayExpired(self) then + self.outroLines.hp = true + self.routineTimer = 64 self.routine = ROUTINE_WRITE_RADNESS - self.routineTimer = 32 end elseif self.routine == ROUTINE_WRITE_RADNESS then - self.routineTimer = self.routineTimer - 1 - if self.routineTimer <= 0 then + if outroDelayExpired(self) then + self.outroLines.rad = true + self.routineTimer = 64 self.routine = ROUTINE_WRITE_TOTAL - self.routineTimer = 32 end elseif self.routine == ROUTINE_WRITE_TOTAL then - self.routineTimer = self.routineTimer - 1 - if self.routineTimer <= 0 then + if outroDelayExpired(self) then + self.outroLines.total = true + self.routineTimer = 64 self.routine = ROUTINE_ADD_HP_TOTAL - self.tallyStep = 0 end elseif self.routine == ROUTINE_ADD_HP_TOTAL then - -- Tally remaining HP into total score (99 pts/frame matching ld c, 99) - if self.hp > 0 then + if not outroDelayExpired(self) then + -- waiting before tally starts + elseif self.hp > 0 then local step = math.min(self.hp, 99) self.hp = self.hp - step self.totalScore = self.totalScore + step Sound.play(self.game and self.game.data, "Press_AB") else + self.routineTimer = 64 self.routine = ROUTINE_ADD_RAD_TOTAL end elseif self.routine == ROUTINE_ADD_RAD_TOTAL then - -- Tally Radness into total score (99 pts/frame matching ld c, 99) - if self.radness > 0 then + if not outroDelayExpired(self) then + -- waiting before tally starts + elseif self.radness > 0 then local step = math.min(self.radness, 99) self.radness = self.radness - step self.totalScore = self.totalScore + step Sound.play(self.game and self.game.data, "Press_AB") else - self.routine = ROUTINE_WAIT_LAST - self.routineTimer = 64 - -- High score check self.newRecord = self.totalScore > ((self.game and self.game.save and self.game.save.surfingHighScore) or 0) if self.newRecord then if self.game and self.game.save then self.game.save.surfingHighScore = self.totalScore end + self.outroLines.hiScore = true + self.pikaState = PIKA_STATE_RESULTS Sound.play(self.game and self.game.data, "Get_Item1") Sound.playPikaCry(self.game and self.game.data, 34) else Sound.playPikaCry(self.game and self.game.data, 28) end + self.routineTimer = GAME_OVER_DELAY + self.routine = ROUTINE_WAIT_LAST end elseif self.routine == ROUTINE_WAIT_LAST then - self.routineTimer = self.routineTimer - 1 - if self.routineTimer <= 0 then + if outroDelayExpired(self) then self.routine = ROUTINE_EXIT_ON_PRESS_A end @@ -1060,6 +1384,26 @@ function SurfingMinigame:tick() if self.onDone then self.onDone(0) end end end + + if self.showResultsCard then + if self.pikaState == PIKA_STATE_RESULTS then + self:updateResultsPikachu() + else + self.pikaYOffset = 0 + end + end + + -- Pret SurfingPikachuLoop calls MoveClouds every frame while gameplay is active + if self.routine == ROUTINE_RUN_GAME + or self.routine == ROUTINE_WAIT_RESULTS + or self.routine == ROUTINE_GAME_OVER then + self:moveClouds() + end + + -- hFrameCounter decrements after game logic (VBlank) + if self.routine == ROUTINE_RUN_GAME and (self.joyFrameCounter or 0) > 0 then + self.joyFrameCounter = self.joyFrameCounter - 1 + end end -- Decoupled timestep accumulator for modern multi-refresh-rate displays @@ -1084,31 +1428,36 @@ end -- Draw scrolling wave background with authentic GPU shader HBlank wave distortion function SurfingMinigame:drawBackground() - local scx = math.floor(self.distanceFixed / 256) - local first = math.floor(scx / 16) + local scx = displayScx(self) local function renderTiles() love.graphics.setColor(1, 1, 1, 1) if not self.bg then return end - for c = first, first + 10 do - local col = self.cols[c] + for i = 0, 10 do + local col = self.bgMap[mapColScreen(scx, i)] + local x = i * 16 - (scx % 16) if col then - local x = c * 16 - scx - for i = 1, 8 do - local mt = BG_METATILES[col.pat[i]] + for row = 1, 8 do + local mt = BG_METATILES[col.pat[row]] if mt then - local y = (i - 1) * 16 - love.graphics.draw(self.bg, self.tq[mt[1]], x, y) - love.graphics.draw(self.bg, self.tq[mt[2]], x + 8, y) - love.graphics.draw(self.bg, self.tq[mt[3]], x, y + 8) - love.graphics.draw(self.bg, self.tq[mt[4]], x + 8, y + 8) + local y = (row - 1) * 16 + local function drawTile(tid, tx, ty) + if tid ~= 0x00 and self.tq[tid] then + love.graphics.draw(self.bg, self.tq[tid], tx, ty) + end + end + drawTile(mt[1], x, y) + drawTile(mt[2], x + 8, y) + drawTile(mt[3], x, y + 8) + drawTile(mt[4], x + 8, y + 8) end end end end end - if self.bgCanvas and self.waveShader and love.graphics.setCanvas and love.graphics.getCanvas then + if self.bgCanvas and self.waveShader and not isOutroScroll(self) and not self.showResultsCard + and love.graphics.setCanvas and love.graphics.getCanvas then -- 1. Capture the framework's active canvas local prevCanvas = love.graphics.getCanvas() @@ -1139,6 +1488,27 @@ function SurfingMinigame:drawBackground() end end +-- Draw intro title Pikachu (SurfingPikachu1Graphics3 via .IntroPikachu OAM) +function SurfingMinigame:drawIntroPikachu(cx, cy) + if not (self.intro and self.iq) then return end + local frame = math.floor(self.t / 7) % 4 + 1 + local vramBase = INTRO_PIKA_FRAME_BASE[frame] + local sheetBase = vramBase - 0x80 + for _, sp in ipairs(INTRO_PIKA_OAM) do + local tileId = sheetBase + sp.tile + local q = self.iq[tileId] + if q then + local x = cx + sp.dx + (sp.xflip and 8 or 0) + local y = cy + sp.dy + if sp.xflip then + love.graphics.draw(self.intro, q, x, y, 0, -1, 1) + else + love.graphics.draw(self.intro, q, x, y) + end + end + end +end + -- Draw 3x3 Pikachu sprite (24x24 px centered at cx, cy) function SurfingMinigame:draw3x3(baseTile, cx, cy, flipX, flipY) local sx = flipX and -1 or 1 @@ -1164,48 +1534,38 @@ function SurfingMinigame:drawHUD() love.graphics.setColor(1, 1, 1, 1) love.graphics.rectangle("fill", 0, BG_HEIGHT, 160, 16) - -- Track progress line (tiles $15..$1c) + -- Window tilemap (SurfingPikachuMinigame_DrawStaticTilemapLayout): + -- row 0 cols 1-2: $15,$16 island; row 1 cols 1-9: $17,$18,$19×7; + -- row 1 cols 12-13: $1b,$1c "HP:"; digits are OAM sprites at X=$80. if self.bg and self.tq then - -- Top row (Y=128) love.graphics.draw(self.bg, self.tq[0x15], 8, BG_HEIGHT) love.graphics.draw(self.bg, self.tq[0x16], 16, BG_HEIGHT) - -- Bottom row (Y=136) love.graphics.draw(self.bg, self.tq[0x17], 8, BG_HEIGHT + 8) love.graphics.draw(self.bg, self.tq[0x18], 16, BG_HEIGHT + 8) - local trackTiles = { 0x19, 0x19, 0x19, 0x19, 0x19, 0x19, 0x19, 0x19, 0x19 } - for i = 1, #trackTiles do - love.graphics.draw(self.bg, self.tq[trackTiles[i]], 16 + i * 8, BG_HEIGHT + 8) + for i = 1, 7 do + love.graphics.draw(self.bg, self.tq[0x19], 16 + i * 8, BG_HEIGHT + 8) end love.graphics.draw(self.bg, self.tq[0x1b], 96, BG_HEIGHT + 8) love.graphics.draw(self.bg, self.tq[0x1c], 104, BG_HEIGHT + 8) - - -- "HP:" label tiles on right (X=112, 120) - love.graphics.draw(self.bg, self.tq[0x20], 112, BG_HEIGHT + 8) - love.graphics.draw(self.bg, self.tq[0x21], 120, BG_HEIGHT + 8) end -- Mini-Pikachu progress marker (tile $fe) -- Game Boy: initial OAM X = $50 (80) = screen X 72, decrements by 2 per section. - -- 24 sections × 2 = 48 px total travel, ending at screen X 24. - -- We interpolate continuously across those same 48 pixels. - local distPx = math.floor(self.distanceFixed / 256) - local progressRatio = math.min(1.0, math.max(0, distPx / (TOTAL_SECTIONS * 128))) - local markerStartX = 72 -- OAM X $50 (80) minus 8-pixel OAM offset = screen X 72 - local markerTrack = 48 -- 24 sections × 2 px per section - local markerX = markerStartX - math.floor(progressRatio * markerTrack) + local markerStartX = 72 -- OAM X $50 (80) minus 8-pixel OAM offset + local markerX = markerStartX - (self.distanceSection or 0) * 2 if self.ob and self.oq and self.oq[0xfe] then love.graphics.draw(self.ob, self.oq[0xfe], markerX, BG_HEIGHT + 6) end - -- 4 HP countdown digits starting at X=128 (right after HP:) + -- 4 HP countdown digits (OAM X $80..$98) local s = string.format("%04d", math.max(0, math.floor(self.hp))) for i = 1, 4 do local d = tonumber(s:sub(i, i)) or 0 if self.ob and self.oq and self.oq[0xd0 + d] then - love.graphics.draw(self.ob, self.oq[0xd0 + d], 120 + i * 8, BG_HEIGHT + 8) + love.graphics.draw(self.ob, self.oq[0xd0 + d], 128 + (i - 1) * 8, BG_HEIGHT + 8) else - Font.draw(tostring(d), 120 + i * 8, BG_HEIGHT + 8) + Font.draw(tostring(d), 128 + (i - 1) * 8, BG_HEIGHT + 8) end end end @@ -1248,29 +1608,27 @@ function SurfingMinigame:drawResultsOutro() end drawBoxRow(9, 0x3d, 0x40, 0x3e) - -- Text lines matching Game Boy screen memory coordinates (cols 2, 10, 15; rows 2, 4, 6, 8) - Font.draw(Strings("HP Left"), 16, 16) - if self.routine >= ROUTINE_WRITE_HP_LEFT then + -- Text lines appear as each Write* routine fires in pret + if self.outroLines.hp then + Font.draw(Strings("HP Left"), 16, 16) Font.draw(string.format("%04d", self.hp), 80, 16) Font.draw(Strings("Pts"), 120, 16) end - if self.routine >= ROUTINE_WRITE_RADNESS then + if self.outroLines.rad then Font.draw(Strings("Radness"), 16, 32) Font.draw(string.format("%04d", self.radness), 80, 32) Font.draw(Strings("Pts"), 120, 32) end - if self.routine >= ROUTINE_WRITE_TOTAL then + if self.outroLines.total then Font.draw(Strings("Total"), 16, 48) Font.draw(string.format("%04d", self.totalScore), 80, 48) Font.draw(Strings("Pts"), 120, 48) end - if self.routine >= ROUTINE_WAIT_LAST then - if self.newRecord then - Font.draw(Strings("Hi-Score!!"), 48, 64) - end + if self.outroLines.hiScore then + Font.draw(Strings("Hi-Score!!"), 48, 64) end love.graphics.setColor(1, 1, 1, 1) end @@ -1287,18 +1645,8 @@ function SurfingMinigame:drawTitleScreen() Font.draw("PIKACHU'S BEACH", 20, 32) end - -- 2. Draw 3x3 Pikachu intro sprite on the water using authentic OAM tile indices - if self.ob and self.oq then - local animBase = (math.floor(self.t / 32) % 2 == 0) and 0x0c or 0x09 - self:draw3x3(animBase, 80, 100, false, false) - end - - -- 3. High score display at bottom - Font.draw(string.format("Hi-Score %4d Pt", self.hiScore), 16, 120) - - if math.floor(self.t / 30) % 2 == 0 then - Font.draw("PRESS START", 36, 132) - end + -- 2. Intro Pikachu paddling out (SurfingPikachu1Graphics3 / surf_1c.png) + self:drawIntroPikachu(self.introPikaX, FLAT_WATER_Y) end function SurfingMinigame:draw() @@ -1310,26 +1658,31 @@ function SurfingMinigame:draw() return end - if self.routine >= ROUTINE_DRAW_RESULTS and self.routine <= ROUTINE_EXIT_ON_PRESS_A then + if self.showResultsCard then self:drawResultsOutro() + self:drawResultsPikachu() + self:drawHUD() return end -- Draw scrolling BG waves self:drawBackground() - -- Parallax clouds in sky (scrolling left at uniform 0.25x camera speed) - local cloudOffsetPx = math.floor(self.cloudOffsetFixed / 256) - local c1x = (160 - (cloudOffsetPx % 200)) - 40 - local c2x = (240 - (cloudOffsetPx % 200)) - 40 - if self.ob and self.oq then - -- Wide cloud (5 tiles: $ec, $ed, $ed, $ee, $ef) - for i, tid in ipairs({ 0xec, 0xed, 0xed, 0xee, 0xef }) do - love.graphics.draw(self.ob, self.oq[tid], c1x + (i - 1) * 8, 12) + -- Parallax clouds (SurfingMinigame_MoveClouds: 9 OAM sprites, 8-bit X wrap) + if self.ob and self.oq and self.cloudSpriteX then + local wideTiles = { 0xec, 0xed, 0xed, 0xee, 0xef } + for i, tid in ipairs(wideTiles) do + local x = self.cloudSpriteX[i] + if x < 168 then + love.graphics.draw(self.ob, self.oq[tid], x, 12) + end end - -- Narrow cloud (4 tiles: $ec, $ed, $ee, $ef) - for i, tid in ipairs({ 0xec, 0xed, 0xee, 0xef }) do - love.graphics.draw(self.ob, self.oq[tid], c2x + (i - 1) * 8, 20) + local narrowTiles = { 0xec, 0xed, 0xee, 0xef } + for i, tid in ipairs(narrowTiles) do + local x = self.cloudSpriteX[i + 5] + if x < 168 then + love.graphics.draw(self.ob, self.oq[tid], x, 20) + end end end -- Helper function to draw OAM multi-sprite composite objects with palette and X-flipping @@ -1388,8 +1741,8 @@ function SurfingMinigame:draw() Font.draw(p.text, p.x, p.y) end - -- "START" banner - if self.routine == ROUTINE_START_GAME then + -- "START" banner (slides in during early RunGame frames) + if self.startBannerX > 80 then if self.ob and self.oq then for r = 0, 1 do for c = 0, 5 do @@ -1418,8 +1771,14 @@ end function SurfingMinigame:sgbPalettes(game) local P = require("src.render.PaletteFX") - local pal = (game and game.data and P.pal(game.data, "PIKACHUS_BEACH")) or PIKACHUS_BEACH_PAL - return { P.whole(pal) } + local beach = (game and game.data and P.pal(game.data, "PIKACHUS_BEACH")) or PIKACHUS_BEACH_PAL + if self.routine == ROUTINE_TITLE then + local title = (game and game.data and P.pal(game.data, "PIKACHUS_BEACH_TITLE")) + or PIKACHUS_BEACH_TITLE_PAL + -- SurfingMinigame_TitleTilemap at (4,0), 12x6; ATTR_BLK pal 1 over that rect. + return { P.whole(beach), P.zone(title, 4, 0, 15, 5) } + end + return { P.whole(beach) } end return SurfingMinigame diff --git a/tests/test_surfing_minigame.lua b/tests/test_surfing_minigame.lua index af4cb669..b3d1a10a 100644 --- a/tests/test_surfing_minigame.lua +++ b/tests/test_surfing_minigame.lua @@ -44,18 +44,22 @@ print("Running SurfingMinigame unit tests...") local mg = SurfingMinigame.new(mockGame) assert_eq(mg.routine, -1, "Initial routine must be ROUTINE_TITLE (-1)") mg:startFromTitle() -assert_eq(mg.routine, 0, "Routine must advance to ROUTINE_START_GAME (0) after startFromTitle()") +assert_eq(mg.routine, 0, "Routine must be ROUTINE_START_GAME (0) after startFromTitle()") assert_eq(mg.hp, 6000, "Initial HP must be 6000 (60.00s)") assert_eq(mg.speed, 0.25, "Initial speed must be 0.25") assert_eq(mg.distance, 0, "Initial distance must be 0") assert_eq(mg.pikaState, 0, "Initial Pikachu state must be PIKA_STATE_RIDING (0)") print("✓ Initial state & Title transition test passed") --- Test 2: Start banner transition to RunGame -for _ = 1, 40 do +-- Test 2: StartGame one-shot then RunGame; banner slides during play (pret-accurate) +mg:update() +assert_eq(mg.routine, 1, "First tick should advance to ROUTINE_RUN_GAME (1)") +assert_true(mg.startBannerX > 80, "START banner should begin off-screen") +for _ = 1, 36 do mg:update() end -assert_eq(mg.routine, 1, "Routine should advance to ROUTINE_RUN_GAME (1)") +assert_eq(mg.routine, 1, "Routine should remain ROUTINE_RUN_GAME (1) while banner slides") +assert_eq(mg.startBannerX, 80, "START banner should finish centered after 36 frames") print("✓ Start banner transition test passed") -- Test 3: Automatic acceleration and HP countdown @@ -68,7 +72,7 @@ print("✓ Auto acceleration and HP countdown test passed") -- Test 4: Landing Evaluation Matrix local old_getWaveTile = mg.getWaveTileUnderPika -mg.getWaveTileUnderPika = function() return 0x01 end -- force open water +mg.getWaveTileUnderPika = function() return 0x01 end -- force open water (flat branch) mg.frameSet = 5 assert_eq(mg:evaluateLanding(), "rough", "Angle 5 on open water should be rough landing") mg.frameSet = 6 @@ -82,6 +86,17 @@ for f = 8, 14 do assert_eq(mg:evaluateLanding(), "wipeout", "Upside-down frame " .. f .. " must be wipeout") end mg.getWaveTileUnderPika = old_getWaveTile +-- TileInteraction keys off chr tile ids, not pattern metatile ids +mg.getWaveTileUnderPika = function() return 0x06 end +mg.frameSet = 6 +assert_eq(mg:evaluateLanding(), "clean", "Frame 6 on chr tile $06 rising slope must be clean") +mg.getWaveTileUnderPika = function() return 0x0b end +mg.frameSet = 6 +assert_eq(mg:evaluateLanding(), "hard", "Frame 6 on chr tile $0b open water must be hard") +mg.getWaveTileUnderPika = function() return 0x08 end +mg.frameSet = 6 +assert_eq(mg:evaluateLanding(), "hard", "Frame 6 on chr tile $08 foam must use flat rules (hard)") +mg.getWaveTileUnderPika = old_getWaveTile print("✓ Landing evaluation matrix test passed (including upside-down frames 8..14)") -- Test 5: Stunt Scoring @@ -129,13 +144,15 @@ assert_eq(mg.pikaState, 0, "Pikachu should recover and return to PIKA_STATE_RIDI print("✓ Wipeout crash recovery test passed") -- Test 7: Results tally countdown sequence +mg.showResultsCard = true mg.routine = 7 -- ROUTINE_WRITE_TOTAL mg.hp = 100 mg.radness = 200 mg.totalScore = 0 -mg.routineTimer = 1 +mg.routineTimer = 0 mg:update() assert_eq(mg.routine, 8, "Routine should advance to ROUTINE_ADD_HP_TOTAL (8)") +mg.routineTimer = 0 -- pret waits 64 frames before tally; skip for unit test while mg.routine == 8 do mg:update() @@ -143,6 +160,7 @@ end assert_eq(mg.hp, 0, "HP should be tallied down to 0") assert_eq(mg.totalScore, 100, "Total score should include 100 from HP") assert_eq(mg.routine, 9, "Routine should advance to ROUTINE_ADD_RAD_TOTAL (9)") +mg.routineTimer = 0 while mg.routine == 9 do mg:update() @@ -153,7 +171,8 @@ assert_eq(mg.routine, 10, "Routine should advance to ROUTINE_WAIT_LAST (10)") -- Test 8: Crossing finish line while jumping upside-down crashes into water and rights Pikachu before results local mg8 = SurfingMinigame.new(mockGame, nil, true) mg8.routine = 1 -- ROUTINE_RUN_GAME -mg8.distanceFixed = (24 * 128 - 2) * 256 +mg8.distanceSection = 24 +mg8.distanceAcc = 0 mg8.speedFixed = 512 mg8.pikaState = 1 -- PIKA_STATE_JUMPING mg8.frameSet = 11 -- Upside down @@ -193,7 +212,8 @@ print("✓ Mid-air upside-down finish line crossing crash & recovery test passed -- Test 9: Crossing finish line while upright jumping lands cleanly and proceeds local mg9 = SurfingMinigame.new(mockGame, nil, true) mg9.routine = 1 -mg9.distanceFixed = (24 * 128 - 2) * 256 +mg9.distanceSection = 24 +mg9.distanceAcc = 0 mg9.speedFixed = 512 mg9.pikaState = 1 mg9.frameSet = 4 -- Clean flat @@ -221,7 +241,8 @@ print("✓ Mid-air upright finish line crossing test passed") -- Test 10: Crossing finish line while already crashed recovers before results local mg10 = SurfingMinigame.new(mockGame, nil, true) mg10.routine = 1 -mg10.distanceFixed = (24 * 128 - 2) * 256 +mg10.distanceSection = 24 +mg10.distanceAcc = 0 mg10.speedFixed = 512 mg10.pikaState = 3 -- PIKA_STATE_CRASHED mg10.crashTimer = 50 @@ -260,12 +281,39 @@ mg12.landingTimer = 20 mg12.speedFixed = 256 -- Place on a rising wave pattern mg12.cols[5] = { pat = SurfingMinigame.WAVE_PATTERNS[0x06], hl = 110, hr = 100 } +mg12.bgMap[5] = mg12.cols[5] +mg12.waveHeight[8] = 110 +mg12.waveHeight[9] = 100 +mg12.bgMapReadTile = 0x06 +mg12.advanceScx = function() end +mg12.generateBgMapIfNeeded = function() end mg12.distanceFixed = (5 * 16 - 80) * 256 local startY = mg12.pikaY mg12:update() assert(mg12.pikaY ~= startY, "pikaY must continuously follow wave surface height while in PIKA_STATE_LANDING") print("✓ Landing slope height tracking continuity test passed") +-- Test 12b: Clean flat landing preserves speed; rough/hard use pret penalties +local mg12b = SurfingMinigame.new(mockGame, nil, true) +mg12b.routine = 1 +mg12b.frameSet = 4 +mg12b.bgMapReadTile = 0x0b +mg12b.speedFixed = 320 +local cleanSpeed = mg12b.speedFixed +mg12b:handleLanding() +assert_eq(mg12b.speedFixed, cleanSpeed, "Clean flat landing must not reduce speed") +mg12b.speedFixed = 320 +mg12b.frameSet = 5 +mg12b.bgMapReadTile = 0x0b +mg12b:handleLanding() +assert_eq(mg12b.speedFixed, 320 - 64, "Rough flat landing must reduce speed by 0.25") +mg12b.speedFixed = 96 +mg12b.frameSet = 6 +mg12b.bgMapReadTile = 0x0b +mg12b:handleLanding() +assert_eq(mg12b.speedFixed, 0, "Hard landing below 0.5 must zero speed (pret underflow guard)") +print("✓ Landing speed penalty test passed") + -- Test 13: Fixed speed enforcement (minigames must always run at 1X speed) assert(mg12.isFixedSpeed == true, "SurfingMinigame must have isFixedSpeed flag enabled") assert(mg12.isMinigame == true, "SurfingMinigame must have isMinigame flag enabled") @@ -274,4 +322,91 @@ local Game = require("src.core.Game") assert(Game.isFixedSpeedInStack(mockStack) == true, "Game.isFixedSpeedInStack must return true for SurfingMinigame") print("✓ Minigame fixed speed enforcement test passed") +-- Test 14: Course duration matches pret distance model (~24 sections, 6000 HP cap) +local mg14 = SurfingMinigame.new(mockGame, nil, true) +mg14.routine = 1 +local runFrames = 0 +for _ = 1, 7000 do + mg14:update() + runFrames = runFrames + 1 + if mg14.distanceSection >= 24 then break end +end +assert(runFrames >= 2500 and runFrames <= 5500, + "Full course should finish in pret-like frame window (got " .. runFrames .. ")") +assert(mg14.hp > 0, "Typical run should reach shore before HP timer expires") +print("✓ Course duration window test passed (" .. runFrames .. " frames)") + +-- Test 15: Pret 2-frame joy sampling allows triple flips at max jump arc +mockInput.keysDown = { left = true } +local mg15 = SurfingMinigame.new(mockGame, nil, true) +mg15.routine = 1 +mg15.speedFixed = 512 +mg15.pikaState = 1 +mg15.jumpArcMagnitude = 16 +mg15.jumpArcFraction = 0 +mg15.jumpDescending = false +mg15.pikaY = 84 +mg15.frameSet = 4 +mg15.radnessMeter = 0 +mg15.trickFlags = 0 +local airFrames = 0 +while mg15.pikaState == 1 and airFrames < 200 do + mg15:update() + airFrames = airFrames + 1 +end +assert_true(mg15.radnessMeter >= 3, + "Max-arc jump with held left must register at least 3 flips (got " .. tostring(mg15.radnessMeter) .. ")") +assert_true(airFrames >= 60, "Max-arc jump should stay airborne long enough for triple flips") +print("✓ Triple flip airtime and joy sampling test passed") + +-- Test 16: Pret music tempo tiers (index = high byte of ((speed & $3ff) << 1)) +local mg16 = SurfingMinigame.new(mockGame, nil, true) +mg16.routine = 1 +local tempoCases = { + { speed = 64, tier = 1 }, + { speed = 127, tier = 1 }, + { speed = 128, tier = 2 }, + { speed = 255, tier = 2 }, + { speed = 256, tier = 3 }, + { speed = 383, tier = 3 }, + { speed = 384, tier = 4 }, + { speed = 511, tier = 4 }, + { speed = 512, tier = 5 }, +} +for _, c in ipairs(tempoCases) do + mg16.speedFixed = c.speed + mg16:updateTempo() + local wantPitch = ({ 1.0, 117 / 109, 117 / 101, 117 / 93, 117 / 85 })[c.tier] + assert_eq(mg16.currentPitch, wantPitch, + string.format("Tempo tier %d at speed %d/256", c.tier, c.speed)) +end +print("✓ Pret music tempo tier boundaries test passed") + +-- Test 17: GetJoypad_3FrameBuffer duty cycle (hFrameCounter reload $2 → sample when counter hits 0) +mockInput.keysDown = { left = true } +local mg17 = SurfingMinigame.new(mockGame, nil, true) +mg17.routine = 1 +local samples = 0 +for _ = 1, 9 do + mg17:update() + if mg17.joy5Left then samples = samples + 1 end +end +-- Counter reloads to 2 then VBlank decrements twice → active on frames 1,3,5,7,9 of each 9-frame window +assert_eq(samples, 5, "Held input must register 5 sample frames per 9 ticks (got " .. samples .. ")") +print("✓ Pret joypad 3-frame buffer duty cycle test passed") + +-- Test 18: Results card keeps Pikachu on the beach (pret DrawResultsScreen + UpdateResultsPikachu) +local mg18 = SurfingMinigame.new(mockGame, nil, true) +mg18:beginResultsCard() +assert_eq(mg18.pikaY, 116 - 16, "Results Pikachu Y must anchor at flat waterline") +assert_eq(mg18.frameSet, 4, "Results pose must use flat ride frameset") +assert_true(mg18.showResultsCard, "Results card must be active") +assert_eq(mg18.cloudSpriteX, nil, "Cloud OAM must be cleared on results screen") +mg18.pikaState = 6 -- PIKA_STATE_RESULTS (pret sets this on hi-score) +mg18.resultsBobTimer = 62 +mg18:updateResultsPikachu() +assert_true(mg18.pikaYOffset ~= 0 or mg18.resultsBobTimer >= 64, + "Results bob must run once PIKA_STATE_RESULTS is active") +print("✓ Results beach Pikachu visibility test passed") + print("All SurfingMinigame unit tests passed successfully!") diff --git a/tools/build_rom_data.py b/tools/build_rom_data.py index 31e06cca..2e1f876f 100755 --- a/tools/build_rom_data.py +++ b/tools/build_rom_data.py @@ -2064,10 +2064,14 @@ def extract_field(rom, symbols, manifest, out_dir, assets_dir): raw_2bpp("SurfingPikachu1Graphics2", 128, 128, "minigame/surf_1b.png", transparent=True) raw_2bpp("SurfingPikachu1Graphics3", 96, 96, "minigame/surf_1c.png", transparent=True) - beach_intro = rom.bytes(62, 0x50bc, 240) - use_ctrl_pad = rom.bytes(62, 0x51ac, 15) - to_surf_rad = rom.bytes(62, 0x51bb, 13) - title_map = rom.bytes(62, 0x51c8, 72) + beach_sym = _symbol(symbols, "SurfingMinigame_BeachIntroTilemap") + use_ctrl_sym = _symbol(symbols, "SurfingMinigame_UseControlPadTilemap") + to_surf_sym = _symbol(symbols, "SurfingMinigame_ToSurfRadTilemap") + title_sym = _symbol(symbols, "SurfingMinigame_TitleTilemap") + beach_intro = rom.bytes(beach_sym.bank, beach_sym.address, 240) + use_ctrl_pad = rom.bytes(use_ctrl_sym.bank, use_ctrl_sym.address, 15) + to_surf_rad = rom.bytes(to_surf_sym.bank, to_surf_sym.address, 13) + title_map = rom.bytes(title_sym.bank, title_sym.address, 72) screen = [0xff] * (20 * 18) for i in range(240): screen[6 * 20 + i] = beach_intro[i] diff --git a/tools/make_yellow_manifest.py b/tools/make_yellow_manifest.py index 843f7d36..bcf70752 100755 --- a/tools/make_yellow_manifest.py +++ b/tools/make_yellow_manifest.py @@ -98,6 +98,12 @@ YELLOW_EXTRA_SYMBOLS = ( "SurfingPikachu1Graphics1", "SurfingPikachu1Graphics2", "SurfingPikachu1Graphics3", + # Surfing Pikachu title screen tilemaps + # (engine/minigame/surfing_pikachu.asm DrawSurfingPikachuMinigameIntroBackground) + "SurfingMinigame_BeachIntroTilemap", + "SurfingMinigame_TitleTilemap", + "SurfingMinigame_ToSurfRadTilemap", + "SurfingMinigame_UseControlPadTilemap", # Oak's own battle back pic. LoadPlayerBackPic (engine/battle/core.asm) # picks OldManPicBack for BATTLE_TYPE_OLD_MAN but ProfOakPicBack for # BATTLE_TYPE_PIKACHU, the Pallet Town catch scene (#557). @@ -505,6 +511,41 @@ def derive(red, pokeyellow, symbols_path): if "SUMMER_BEACH_HOUSE" not in locations and "ROUTE_19" in locations: locations["SUMMER_BEACH_HOUSE"] = dict(locations["ROUTE_19"]) + # Surfing Pikachu minigame asset index (src/ui/SurfingMinigame.lua). + yellow["field"]["surfingPikachu"] = { + "music": "Music_SurfingPikachu", + "sheets": { + "bg": { + "path": "assets/generated/minigame/surf_1a.png", + "width": 40, "height": 104, + }, + "oam": { + "path": "assets/generated/minigame/surf_1b.png", + "width": 128, "height": 128, + }, + "intro": { + "path": "assets/generated/minigame/surf_1c.png", + "width": 96, "height": 96, + "introPikaFrames": [ + "assets/generated/minigame/intro_pika_0.png", + "assets/generated/minigame/intro_pika_1.png", + "assets/generated/minigame/intro_pika_2.png", + "assets/generated/minigame/intro_pika_3.png", + ], + "source": "data/sprite_anims/surfing_pikachu_oam.asm .IntroPikachu", + }, + "titleBg": { + "path": "assets/generated/minigame/title_bg.png", + "width": 160, "height": 144, + }, + }, + "source": ( + "engine/minigame/surfing_pikachu.asm " + "(DrawSurfingPikachuMinigameIntroBackground), " + "gfx/surfing_pikachu.asm" + ), + } + meta = { "aliasCount": alias_hits, "omittedIntro": list(OMIT_INTRO_SYMBOLS), diff --git a/tools/rom_manifest_yellow.json b/tools/rom_manifest_yellow.json index 9fa9e696..ed4d489c 100644 --- a/tools/rom_manifest_yellow.json +++ b/tools/rom_manifest_yellow.json @@ -8861,6 +8861,39 @@ } ] }, + "surfingPikachu": { + "music": "Music_SurfingPikachu", + "sheets": { + "bg": { + "height": 104, + "path": "assets/generated/minigame/surf_1a.png", + "width": 40 + }, + "intro": { + "height": 96, + "introPikaFrames": [ + "assets/generated/minigame/intro_pika_0.png", + "assets/generated/minigame/intro_pika_1.png", + "assets/generated/minigame/intro_pika_2.png", + "assets/generated/minigame/intro_pika_3.png" + ], + "path": "assets/generated/minigame/surf_1c.png", + "source": "data/sprite_anims/surfing_pikachu_oam.asm .IntroPikachu", + "width": 96 + }, + "oam": { + "height": 128, + "path": "assets/generated/minigame/surf_1b.png", + "width": 128 + }, + "titleBg": { + "height": 144, + "path": "assets/generated/minigame/title_bg.png", + "width": 160 + } + }, + "source": "engine/minigame/surfing_pikachu.asm (DrawSurfingPikachuMinigameIntroBackground), gfx/surfing_pikachu.asm" + }, "tilePairs": { "land": [ { @@ -26288,6 +26321,22 @@ 32, 25380 ], + "SurfingMinigame_BeachIntroTilemap": [ + 62, + 20668 + ], + "SurfingMinigame_TitleTilemap": [ + 62, + 20936 + ], + "SurfingMinigame_ToSurfRadTilemap": [ + 62, + 20923 + ], + "SurfingMinigame_UseControlPadTilemap": [ + 62, + 20908 + ], "SurfingPikachuSprite": [ 63, 28143 From 1ad6c810fab90713d5bffb9b2b5a34ca4334fea8 Mon Sep 17 00:00:00 2001 From: 1jamie Date: Mon, 24 Aug 2026 17:30:09 -0500 Subject: [PATCH 10/16] fix SurfingMinigame column definitions --- src/ui/SurfingMinigame.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/SurfingMinigame.lua b/src/ui/SurfingMinigame.lua index 5eac657a..846bd1a3 100644 --- a/src/ui/SurfingMinigame.lua +++ b/src/ui/SurfingMinigame.lua @@ -484,7 +484,7 @@ function SurfingMinigame.new(game, onDone, skipTitle) -- Linear cols mirror kept for unit tests only self.cols = {} for c = 0, 10 do - self.cols[c] = flatCol + self.cols[c] = { pat = WAVE_PATTERNS[0x00], hl = FLAT_WATER_Y, hr = FLAT_WATER_Y } end self.colTail = 10 From 7d6566fa51c75dc6e3b393de735f657751b7c88b Mon Sep 17 00:00:00 2001 From: "DESKTOP-8SRFDDM\\cam95" Date: Mon, 24 Aug 2026 21:06:07 -0500 Subject: [PATCH 11/16] engine: catch.party_full custody for a full-party catch (RFC 0018) A capture the party cannot hold silently falls through to the box; the new partyFullDestination seam lets a mode claim custody at that moment instead, and pokemon.caught reports destination "mod" so the mode can find the mon again. Guarded call site, file-local vanilla, docs and a public-API modkit case. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01N1DVpYXGabigHqMwviDoKV --- docs/modding.md | 23 +++++ docs/rfcs/0018-catch-party-full.md | 113 ++++++++++++++++++++++++ src/battle/BattleState.lua | 44 ++++++--- tests/modkit/cases/catch_party_full.lua | 98 ++++++++++++++++++++ 4 files changed, 265 insertions(+), 13 deletions(-) create mode 100644 docs/rfcs/0018-catch-party-full.md create mode 100644 tests/modkit/cases/catch_party_full.lua diff --git a/docs/modding.md b/docs/modding.md index 28a38c1d..3d1329bf 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -451,6 +451,29 @@ Menu choices and moves use the same engine methods as the native controls; their mutable logic. Tutorial, link, forced, stale, and covered battle states refuse core intents. Use `mod.input` for ordinary text advance. +## Party-full custody at a catch + +When a capture lands on a full party, the cart deposits the mon in storage +without a question. `catch.party_full` (RFC 0018) lets a mode stand in front +of `SendNewMonToBox` and take custody instead: + +```lua +mod.hooks:wrap("catch.party_full", function(next, ctx) + -- ctx = { battle = , mon = , name = , + -- game = } + if myMode.active then + takeCustody(ctx.mon) -- the mon is the mod's problem now + return true -- nothing is deposited + end + return next(ctx) -- false: deposit, as today +end) +``` + +A truthy return skips the box entirely -- the mon is neither in the party nor +in any box, and `pokemon.caught` reports `destination = "mod"` so the mode can +find its own custody again. Anything falsy deposits as always, "But every BOX +is full!" included. + ## Rendering pipelines Most registries hand the engine *content*. `render_pipelines` hands it diff --git a/docs/rfcs/0018-catch-party-full.md b/docs/rfcs/0018-catch-party-full.md new file mode 100644 index 00000000..6658c434 --- /dev/null +++ b/docs/rfcs/0018-catch-party-full.md @@ -0,0 +1,113 @@ +# RFC 0018: `catch.party_full` — custody of a catch the party cannot hold + +## Status + +Proposed. + +## Motivation + +When a capture lands and the party already holds six Pokémon, the cart does +not ask the player anything: `AddPartyMon` fails and the mon goes to +`SendNewMonToBox` without a stop. The engine mirrors that — +`BattleState:storeCaughtMon` falls through to `Boxes.deposit` — and a game +mode has no way to stand in front of it. + +That silence is wrong for any mode where the box is not a thing. A battle +royale locks the Pokémon Center PC for the whole match (the box is a second +health bar: deposit the healthy ones, fight with one, withdraw fresh ones), +so a seventh catch is deposited into storage the player cannot reach — the +mon is gone and everyone saw the fanfare. A Nuzlocke that counts a boxed mon +as lost, a randomizer that wants to hand out a replacement instead, a +challenge run that makes the player release someone for the catch — all want +the decision, and today there is no seam to catch it at. + +The immediate consumer is a battle-royale mode, where the fix is the game's +own rule: at 6/6 you choose who makes room, and whoever leaves hits the +ground as a ball. Neither the decision nor the spill is specific to it. + +## The decision it extends + +This extends the **additive, guarded seam convention** Route B in +`CONTRIBUTING-mods.md` documents, and is gated by the parity guarantee +`tests/engine/gate_meta_coverage.lua` enforces. It sits next to +`catch.nickname` (RFC 0015) on the same capture path: that hook answers +*what the mon is called* once its home is decided; this one decides the home +when the party cannot hold it. + +There is no in-repo D-number registry to amend. + +## Exact API delta + +### New hook: `catch.party_full` + +```lua +mod.hooks:wrap("catch.party_full", function(next, ctx) + -- ctx = { battle = , mon = , name = , + -- game = } + if myMode.active then + takeCustody(ctx.mon) -- the mon is the mod's problem now + return true -- nothing is deposited + end + return next(ctx) -- false: deposit, as today +end) +``` + +Call site: `BattleState:partyFullDestination(mon)`, called from the capture +path at the moment `AddPartyMon` has failed and `SendNewMonToBox` would run. +The vanilla link returns `false`. A truthy return skips the box entirely — +the mon is neither in the party nor in any box, and `pokemon.caught` reports +`destination = "mod"` so the mode can find its own custody again. Anything +falsy deposits as always, "But every BOX is full!" included. + +The method returns `"box"` or `"mod"`, and is a *method* on purpose: the +compatibility seam for engines that predate this RFC needs a name to ask for +(see below), and `battleStyle`/`offerNickname` set the precedent. + +### One event value, already emitted + +`pokemon.caught`'s `destination` gains a third value, `"mod"`, next to +`"party"` and `"box"`. Nothing that reads the event today matches on it. + +### No other surface changes + +The call site is guarded by `Runtime.wantsHook`, and the vanilla link is a +file-local, so a build with nothing wrapped runs the branch exactly as +before and allocates nothing it did not allocate before. + +## Migration + +Nothing changes for existing mods. A mode that was fighting the box after +the fact — withdrawing the deposit it could not prevent, or eating the loss — +should wrap `catch.party_full` and take the mon at the moment of the catch. + +## Verification + +- `tests/modkit/cases/catch_party_full.lua` — through the public mod API: + with no mod a 6/6 catch lands in the box with the transfer text; a wrapped + mod that claims custody leaves the mon out of both party and boxes; a + fall-through deposits; the hook's ctx carries the battle, the mon, the + display name and the game. +- `tests/engine/gate_hooks.lua` — the name is in the live catalog and passes + the no-mod parity gate (vanilla called exactly once, result unchanged, + nothing allocated). +- `tests/engine/gate_meta_coverage.lua` — the name is covered by the unit + corpus. + +## Backward compatibility + +Additive. No existing hook, event, registry or manifest field changes shape. +`storeCaughtMon`'s box branch keeps its text, its nickname offer and its +`pokemon.caught` emit; the only reader-visible difference with no subscriber +is one extra method on `BattleState`. + +## Compatibility seam for older engines + +The call site is mid-function, so a mod on a stock engine cannot see it — +the same problem `world.talk` has. The battle-royale mod's shim covers the +gap by wrapping `Boxes.deposit` (the one call the old branch makes that a +patch can reach): during a match it raises `catch.party_full` first, and a +claim refuses the deposit, so nothing reaches a box even there. The mod then +takes custody from the `pokemon.caught` emit, which carries the mon. What +the shim cannot repair is the text: the old branch answers a refused deposit +with "But every BOX is full!" before the mode's own prompt opens — the wrong +reason for the right decision, and the argument for the seam. diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 300cc345..835d6da7 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -5086,6 +5086,22 @@ end -- "New POKéDEX data will be added" + the dex entry page, then -- AddPartyMon or SendNewMonToBox (both call AskName), then the PC -- transfer text when the party was full. +-- Where a caught mon goes when the party has no room for it (RFC 0018): +-- "box", as AddPartyMon falling through to SendNewMonToBox always did, or +-- "mod" when the catch.party_full hook claims it -- a game mode that has +-- done away with storage hands the decision to the player instead of +-- laundering the catch through a PC it has locked. A method rather than +-- an inline read, so a mod or a compatibility shim can tell a seam engine +-- from a stock one by name. +function BattleState:partyFullDestination(mon) + if not Runtime.wantsHook("catch.party_full") then return "box" end + local claimed = Runtime.call("catch.party_full", function() return false end, + { battle = self, mon = mon, name = self.enemy and self.enemy.name, + game = self.game }) + if claimed then return "mod" end + return "box" +end + function BattleState:storeCaughtMon() -- ItemUseBall reloads the caught mon via LoadEnemyMonData -- (item_effects.asm:472-501), regenerating its move list from the @@ -5126,19 +5142,21 @@ function BattleState:storeCaughtMon() if Party.add(game.save.party, self.enemy.mon) then askCaughtNickname() else - destination = "box" - local boxNum = require("src.pokemon.Boxes").deposit(game.save, self.enemy.mon) - if boxNum then - askCaughtNickname() - -- _ItemUseBallText07/08 keyed on EVENT_MET_BILL - local metBill = game.save.flags and game.save.flags.EVENT_MET_BILL - self:sayNext(self:romText( - metBill and "_ItemUseBallText07" or "_ItemUseBallText08", - metBill and "%s was\ntransferred to\nBILL's PC!" - or "%s was\ntransferred to\nsomeone's PC!", - self.enemy.name)) - else - self:sayNext(Strings("But every BOX\nis full!")) + destination = self:partyFullDestination(self.enemy.mon) + if destination == "box" then + local boxNum = require("src.pokemon.Boxes").deposit(game.save, self.enemy.mon) + if boxNum then + askCaughtNickname() + -- _ItemUseBallText07/08 keyed on EVENT_MET_BILL + local metBill = game.save.flags and game.save.flags.EVENT_MET_BILL + self:sayNext(self:romText( + metBill and "_ItemUseBallText07" or "_ItemUseBallText08", + metBill and "%s was\ntransferred to\nBILL's PC!" + or "%s was\ntransferred to\nsomeone's PC!", + self.enemy.name)) + else + self:sayNext(Strings("But every BOX\nis full!")) + end end end Runtime.emit("pokemon.caught", { diff --git a/tests/modkit/cases/catch_party_full.lua b/tests/modkit/cases/catch_party_full.lua new file mode 100644 index 00000000..5dbdd918 --- /dev/null +++ b/tests/modkit/cases/catch_party_full.lua @@ -0,0 +1,98 @@ +-- A sandboxed mod can take custody of a catch the party cannot hold +-- (catch.party_full): the mon goes to the mod instead of a PC box, which is +-- the difference between "choose who to release" and a catch that vanishes +-- into storage the mode has locked away. + +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") + +local T = require("tests.modkit") +local BattleState = require("src.battle.BattleState") +local Boxes = require("src.pokemon.Boxes") + +local FIXTURE = { + ["mods/catch_probe/manifest.json"] = [[{ + "id": "catch_probe", + "name": "Catch Probe", + "version": "1.0.0", + "entry": "main.lua", + "api": 2 + }]], + ["mods/catch_probe/main.lua"] = [[ + local mod = ... + mod.exports.answer = nil + mod.exports.ctx = nil + mod.hooks:wrap("catch.party_full", function(next, ctx) + mod.exports.ctx = ctx + if mod.exports.answer == nil then return next(ctx) end + return mod.exports.answer + end) + ]], +} + +local function fullBattle(species) + local data = { pokemon = {}, text = {} } + local party = {} + for _ = 1, 6 do party[#party + 1] = { species = "RATTATA", moves = {} } end + local save = { + party = party, + player = { name = "RED" }, + options = { battleStyle = "shift" }, + flags = {}, + } + return setmetatable({ + game = { save = save, stack = { push = function() end }, data = data }, + data = data, + queue = {}, nextInsert = 0, + enemy = { mon = { species = species or "PIDGEY", level = 5, moves = {} }, + name = species or "PIDGEY" }, + }, { __index = BattleState }) +end + +local function boxTotal(save) + local n = 0 + for _, box in ipairs(Boxes.ensure(save)) do n = n + #box end + return n +end + +-- ------- no mod: the cart's silence, reproduced + +local vanilla = T.sdk.loadNone({}) +local battle = fullBattle() +battle:storeCaughtMon() +T.eq(battle.result, "caught", "no mod: the catch still lands") +T.eq(#battle.game.save.party, 6, "no mod: the party is untouched") +T.eq(boxTotal(battle.game.save), 1, "no mod: the mon was deposited") +T.eq(battle.queue[#battle.queue].text, "PIDGEY was\ntransferred to\nsomeone's PC!", + "no mod: with the transfer text") +vanilla.release() + +-- ------- a mod claims custody + +local run = T.sdk.loadMods({ "mods/catch_probe" }, { fs = T.sdk.memfs(FIXTURE) }) +T.eq(#run.errors, 0, "the catch probe loads clean (" .. tostring(run.errors[1]) .. ")") +local probe = run.loader.exports.catch_probe + +probe.answer = true +local claimed = fullBattle() +claimed:storeCaughtMon() +T.eq(claimed.result, "caught", "claimed: the catch still lands") +T.eq(#claimed.game.save.party, 6, "claimed: the party is untouched") +T.eq(boxTotal(claimed.game.save), 0, "claimed: nothing reached a box") +T.eq(probe.ctx and probe.ctx.name, "PIDGEY", "the hook was handed the display name") +T.check(probe.ctx and probe.ctx.battle == claimed, "and the battle") +T.check(probe.ctx and probe.ctx.mon == claimed.enemy.mon, "and the caught mon") +T.check(probe.ctx and probe.ctx.game == claimed.game, "and the game") + +probe.answer = false +local declined = fullBattle() +declined:storeCaughtMon() +T.eq(boxTotal(declined.game.save), 1, "declined: the box path runs as always") + +probe.answer = nil +local fell = fullBattle() +fell:storeCaughtMon() +T.eq(boxTotal(fell.game.save), 1, "falling through deposits, as today") + +run.release() +T.finish("catch party full") From ba201aa4adb648fadbadeef84f6d47435ca3d2e6 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Tue, 25 Aug 2026 06:33:08 -0400 Subject: [PATCH 12/16] issue template: add Silver and Crystal to the version dropdown --- .github/ISSUE_TEMPLATE/bug_report.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index bd82d7fc..1aeb4cd3 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -33,6 +33,8 @@ body: - Blue - Yellow - Gold + - Silver + - Crystal - N/A validations: required: true From 1850b50ee605b686edaf9a3c9baa8f6ab637d986 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Tue, 25 Aug 2026 07:55:52 -0400 Subject: [PATCH 13/16] CLOSES #941, CLOSES #1081, CLOSES #1349, CLOSES #1602, CLOSES #1618, CLOSES #1624, CLOSES #1630, CLOSES #1633, CLOSES #1661, CLOSES #1669, CLOSES #1676, CLOSES #1731, CLOSES #1739, CLOSES #1740, CLOSES #1742, CLOSES #1744, CLOSES #1753, CLOSES #1757, CLOSES #1758, CLOSES #1760, CLOSES #1761, CLOSES #1762, CLOSES #1763, CLOSES #1765, CLOSES #1770, CLOSES #1777, CLOSES #1781, CLOSES #1783, CLOSES #1784, CLOSES #1785, CLOSES #1787, CLOSES #1788, CLOSES #1789, CLOSES #1790, CLOSES #1792, CLOSES #1797, CLOSES #1803 --- data/scripts/celadon_eevee.lua | 46 ++++---- data/scripts/flavor/celadon_city.lua | 6 +- data/scripts/flavor/celadon_mansion_1f.lua | 18 +-- data/scripts/flavor/lavender_cubone_house.lua | 5 +- data/scripts/flavor/mr_fujis_house.lua | 9 +- data/scripts/flavor/route_16_fly_house.lua | 5 +- data/scripts/story.lua | 1 + data/scripts/story4.lua | 14 ++- main.lua | 40 ++++++- src/battle/BattleState.lua | 105 +++++++++++++----- src/battle/Damage.lua | 38 +++++-- src/battle/MoveEffects.lua | 11 ++ src/battle/TurnOrder.lua | 10 +- src/battle/WideBattle.lua | 15 ++- src/battle/gen2/Battle.lua | 71 ++++++++++-- src/battle/gen2/Effects.lua | 8 ++ src/battle/gen2/Prize.lua | 7 +- src/battle/rulesets/gen1_faithful.lua | 2 + src/core/BattleCheckpoint.lua | 2 +- src/core/FixedStep.lua | 20 +++- src/core/Game.lua | 4 +- src/core/Game2.lua | 4 + src/core/SaveData.lua | 9 +- src/core/TouchControls.lua | 4 - src/core/Version.lua | 2 +- src/core/chip_worker.lua | 11 +- src/core/gen2/MomShopping.lua | 14 +-- src/core/gen2/Save.lua | 10 +- src/import/CacheContract.lua | 2 +- src/import/LauncherSettings.lua | 36 ++---- src/import/RomExtractorGen2.lua | 19 +++- src/inventory/ItemEffects.lua | 5 + src/render/Renderer.lua | 2 +- src/script/Commands.lua | 29 ++++- src/script/gen2/CallAsm.lua | 64 ++++++++++- src/ui/Diploma.lua | 49 +++++--- src/ui/EvolutionState.lua | 19 +++- src/ui/OptionsMenu.lua | 26 ++--- src/ui/PlayerPC.lua | 32 +++--- src/ui/SkinStudio.lua | 4 +- src/ui/TownMap.lua | 13 +-- src/ui/gen2/BattleState.lua | 29 ++++- src/ui/gen2/ItemPcMenu.lua | 38 ++++--- src/ui/gen2/TitleState.lua | 19 +++- src/world/OverworldController.lua | 20 +++- src/world/gen2/World.lua | 8 +- .../drivers/fill_extended_auto_menu_test.lua | 12 +- .../exp_traded_ot_survives_reload_bug1265.lua | 2 +- tests/parity_trade_gift.lua | 18 +-- tests/pet_cries_test.lua | 12 +- 50 files changed, 659 insertions(+), 290 deletions(-) diff --git a/data/scripts/celadon_eevee.lua b/data/scripts/celadon_eevee.lua index d7982e97..c34e38d2 100644 --- a/data/scripts/celadon_eevee.lua +++ b/data/scripts/celadon_eevee.lua @@ -42,27 +42,29 @@ local LINK_HEADINGS = { "HOW TO LINK", "COLOSSEUM", "TRADE CENTER" } local function linkCableHelp(game) local text = game.data.text or {} - local items, showMenu, askHeading - function showMenu() - game.stack:push(Menu.new(game, items, - { tx = 0, ty = 0, tw = 15, th = 10, rowStep = 1 })) - end - function askHeading() - game.stack:push(TextBox.new(game, - text._LinkCableHelpText2 or "Which heading do\nyou want to read?", - showMenu)) + local items, menu, openMenu + local function closeAll() + game.stack:pop() end items = {} for i, label in ipairs(LINK_HEADINGS) do - items[i] = { label = label, onSelect = function() + items[i] = { label = label, keepOpen = true, onSelect = function() game.stack:push(TextBox.new(game, - text["_LinkCableInfoText" .. i] or label, askHeading)) + text["_LinkCableInfoText" .. i] or label)) end } end - items[#items + 1] = { label = "STOP READING" } + items[#items + 1] = { label = "STOP READING", onSelect = closeAll } + menu = Menu.new(game, items, + { tx = 0, ty = 0, tw = 15, th = 10, rowStep = 2, itemY = 2, + onCancel = closeAll }) + function openMenu() game.stack:push(menu) end game.stack:push(TextBox.new(game, text._LinkCableHelpText1 or "TRAINER TIPS\fUsing a Game Link\nCable", - askHeading)) + function() + game.stack:push(TextBox.new(game, + text._LinkCableHelpText2 or "Which heading do\nyou want to read?", + nil, { stay = { onShown = openMenu } })) + end)) end return { @@ -84,21 +86,15 @@ return { { "jump_if_false", 5 }, -- 2 { "hide_object", "CELADON_MANSION_ROOF_HOUSE", "CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL" }, -- 3 (old saves) - { "jump", 13 }, -- 4 - { "give_pokemon", "EEVEE", 25 }, -- 5 - { "jump_if_false", 12 }, -- 6 (party+box full) - -- flag + HideObject before the jingle and GotMonText: the nickname - -- prompt inside give_pokemon and the text row both yield, and a - -- script that dies there would leave the EEVEE taken with the ball - -- still on the table and the gift claimable again (#426). The row - -- count is unchanged, so the numeric jump targets still hold. + { "jump", 11 }, -- 4 + { "give_pokemon", "EEVEE", 25, false, true }, -- 5 + { "jump_if_false", 10 }, -- 6 (party+box full) + -- scripts/CeladonMansionRoofHouse.asm:17-20 (#426) { "set_flag", "EVENT_GOT_EEVEE" }, -- 7 { "hide_object", "CELADON_MANSION_ROOF_HOUSE", "CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL" }, -- 8 - { "text_sound", "Get_Item1" }, -- 9 (GotMonText jingle) - { "show_text", "_GotMonText", { RAM = "EEVEE" } }, -- 10 - { "jump", 13 }, -- 11 - { "show_text", "_BoxIsFullText" }, -- 12 + { "jump", 11 }, -- 9 + { "show_text", "_BoxIsFullText" }, -- 10 }, }, } diff --git a/data/scripts/flavor/celadon_city.lua b/data/scripts/flavor/celadon_city.lua index 5d0cb328..06add996 100644 --- a/data/scripts/flavor/celadon_city.lua +++ b/data/scripts/flavor/celadon_city.lua @@ -2,12 +2,10 @@ return { CELADON_CITY = { talk = { - -- CeladonCityPoliwrathText (scripts/CeladonCity.asm): text_far - -- _CeladonCityPoliwrathText, then plays the POLIWRATH cry and ends. - -- The cry playback has no port-side equivalent command, so we just - -- show the flavor line. TEXT_CELADONCITY_POLIWRATH = { {"face_player"}, + -- pokered/scripts/CeladonCity.asm:90 + {"play_cry", "POLIWRATH", true}, {"show_text", "_CeladonCityPoliwrathText"}, }, }, diff --git a/data/scripts/flavor/celadon_mansion_1f.lua b/data/scripts/flavor/celadon_mansion_1f.lua index 9e8c3f3b..3e77b45c 100644 --- a/data/scripts/flavor/celadon_mansion_1f.lua +++ b/data/scripts/flavor/celadon_mansion_1f.lua @@ -2,30 +2,24 @@ return { CELADON_MANSION_1F = { talk = { - -- CeladonMansion1FClefairyText (scripts/CeladonMansion1F.asm): text_far - -- _CeladonMansion1FClefairyText, then plays the CLEFAIRY cry and ends. - -- The cry playback has no port-side equivalent command, so we just - -- show the flavor line. TEXT_CELADONMANSION1F_CLEFAIRY = { {"face_player"}, + -- pokered/scripts/CeladonMansion1F.asm:27 + {"play_cry", "CLEFAIRY", true}, {"show_text", "_CeladonMansion1FClefairyText"}, }, - -- CeladonMansion1FMeowthText (scripts/CeladonMansion1F.asm): text_far - -- _CeladonMansion1FMeowthText, then plays the MEOWTH cry and ends. - -- The cry playback has no port-side equivalent command, so we just - -- show the flavor line. TEXT_CELADONMANSION1F_MEOWTH = { {"face_player"}, + -- pokered/scripts/CeladonMansion1F.asm:18 + {"play_cry", "MEOWTH", true}, {"show_text", "_CeladonMansion1FMeowthText"}, }, - -- CeladonMansion1FNidoranFText (scripts/CeladonMansion1F.asm): text_far - -- _CeladonMansion1FNidoranFText, then plays the NIDORAN_F cry and ends. - -- The cry playback has no port-side equivalent command, so we just - -- show the flavor line. TEXT_CELADONMANSION1F_NIDORANF = { {"face_player"}, + -- pokered/scripts/CeladonMansion1F.asm:33 + {"play_cry", "NIDORAN_F", true}, {"show_text", "_CeladonMansion1FNidoranFText"}, }, }, diff --git a/data/scripts/flavor/lavender_cubone_house.lua b/data/scripts/flavor/lavender_cubone_house.lua index a5981cbe..54aa3d04 100644 --- a/data/scripts/flavor/lavender_cubone_house.lua +++ b/data/scripts/flavor/lavender_cubone_house.lua @@ -4,11 +4,10 @@ return { LAVENDER_CUBONE_HOUSE = { talk = { - -- LavenderCuboneHouseCuboneText: text_far _LavenderCuboneHouseCuboneText, - -- then text_asm plays the CUBONE cry. Cry playback has no Commands - -- equivalent in this port, so just show the line. + -- LavenderCuboneHouse.asm:10 TEXT_LAVENDERCUBONEHOUSE_CUBONE = { { "face_player" }, + { "play_cry", "CUBONE", true }, { "show_text", "_LavenderCuboneHouseCuboneText" }, }, diff --git a/data/scripts/flavor/mr_fujis_house.lua b/data/scripts/flavor/mr_fujis_house.lua index 6e1a7ae0..4022add5 100644 --- a/data/scripts/flavor/mr_fujis_house.lua +++ b/data/scripts/flavor/mr_fujis_house.lua @@ -31,16 +31,15 @@ M.MR_FUJIS_HOUSE = { { "show_text", "_MrFujisHouseLittleGirlPokemonAreNiceToHugText" }, -- 6 }, - -- scripts/MrFujisHouse.asm MrFujisHousePsyduckText: text_far then - -- PlayCry(PSYDUCK). Cry playback isn't modeled by Commands, so just - -- show the flavor text. + -- scripts/MrFujisHouse.asm:56 TEXT_MRFUJISHOUSE_PSYDUCK = { + { "play_cry", "PSYDUCK", true }, { "show_text", "_MrFujisHousePsyduckText" }, }, - -- scripts/MrFujisHouse.asm MrFujisHouseNidorinoText: text_far then - -- PlayCry(NIDORINO). + -- scripts/MrFujisHouse.asm:63 TEXT_MRFUJISHOUSE_NIDORINO = { + { "play_cry", "NIDORINO", true }, { "show_text", "_MrFujisHouseNidorinoText" }, }, }, diff --git a/data/scripts/flavor/route_16_fly_house.lua b/data/scripts/flavor/route_16_fly_house.lua index 563c1a30..6f348382 100644 --- a/data/scripts/flavor/route_16_fly_house.lua +++ b/data/scripts/flavor/route_16_fly_house.lua @@ -4,10 +4,9 @@ return { ROUTE_16_FLY_HOUSE = { talk = { - -- Route16FlyHouseFearowText: text_asm just prints the one line and - -- plays the FEAROW cry (no cry-playback command exists in this - -- port's Commands vocabulary, so only the text is ported). + -- scripts/Route16FlyHouse.asm:45-51 TEXT_ROUTE16FLYHOUSE_FEAROW = { + { "play_cry", "FEAROW", true }, { "show_text", "_Route16FlyHouseFearowText" }, }, }, diff --git a/data/scripts/story.lua b/data/scripts/story.lua index 372a1836..ec5435a2 100644 --- a/data/scripts/story.lua +++ b/data/scripts/story.lua @@ -1047,6 +1047,7 @@ local championsRoomRivalScript = { -- numeric 26 into a jump ONTO the closing HALL_OF_FAME warp instead of past -- it, so a returning champion warped straight into the induction. { "jump_if_true", "end" }, -- 3 + { "set_option", "animations", true }, -- scripts/ChampionsRoom.asm:57 { "show_text", "_ChampionsRoomRivalIntroText" }, -- 4 -- ChampionsRoomRivalReadyToBattleScript plays MUSIC_FINAL_BATTLE after -- the intro text, before the battle itself (#706); pushBattle's wipe-time diff --git a/data/scripts/story4.lua b/data/scripts/story4.lua index 18c6658c..8069bf09 100644 --- a/data/scripts/story4.lua +++ b/data/scripts/story4.lua @@ -359,18 +359,22 @@ M.ROUTE_16_FLY_HOUSE = { TEXT_ROUTE16FLYHOUSE_BRUNETTE_GIRL = function(game, ow, npc, done) local t = text(game) if game.save.flags.EVENT_GOT_HM02 then - push(game, t._Route16FlyHouseBrunetteGirlHm02ExplanationText - or "HM02 is FLY!\fIt will whisk you\nback to any town!", done) + push(game, t._Route16FlyHouseBrunetteGirlHM02ExplanationText + or "HM02 is FLY.\nIt will take you\vback to any town.\fPut it to good\nuse!", done) return end push(game, t._Route16FlyHouseBrunetteGirlText - or "Shh! It's a\nsecret!\fMy POKéMON's\nHM02, take it!", function() + or "Oh, you found my\nsecret retreat!", function() if not require("src.inventory.Bag").add(game.save, "HM_FLY", 1) then - push(game, "You don't have\nroom for HM02!", done) + push(game, t._Route16FlyHouseBrunetteGirlHM02NoRoomText + or "You don't have any\nroom for this.", done) return end game.save.flags.EVENT_GOT_HM02 = true - push(game, ("%s got\nHM02!"):format(game.save.player.name), done) + -- scripts/Route16FlyHouse.asm:32-35 + push(game, fill(t._Route16FlyHouseBrunetteGirlReceivedHM02Text + or "{PLAYER} received\nHM02!", { player = game.save.player.name }), + done, require("src.render.TextBox").soundOpts(game, "Get_Key_Item")) end) end, }, diff --git a/main.lua b/main.lua index 8220c9c2..fe9a6357 100644 --- a/main.lua +++ b/main.lua @@ -1170,6 +1170,17 @@ function love.run() -- per-frame sleep-granularity jitter. local nextFrame = love.timer and love.timer.getTime() or 0 local dt = 0 + local idleFor = 0 + local SLEEP_FLOOR = 0.001 + local WAKE = { + keypressed = true, keyreleased = true, textinput = true, + mousepressed = true, mousereleased = true, mousemoved = true, + wheelmoved = true, touchpressed = true, touchreleased = true, + touchmoved = true, joystickpressed = true, joystickreleased = true, + joystickhat = true, gamepadpressed = true, gamepadreleased = true, + joystickadded = true, joystickremoved = true, filedropped = true, + directorydropped = true, focus = true, visible = true, resize = true, + } return function() -- process events @@ -1188,17 +1199,34 @@ function love.run() return a or 0 end end + if WAKE[name] then + idleFor = 0 + elseif name == "joystickaxis" and type(c) == "number" and math.abs(c) > 0.5 then + idleFor = 0 + end love.handlers[name](a, b, c, d, e, f) end end -- update dt if love.timer then dt = love.timer.step() end + idleFor = idleFor + dt -- call update and draw if love.update then love.update(dt) end - if love.graphics and love.graphics.isActive() then + local visible = not (love.window and love.window.isVisible) + or love.window.isVisible() + local focused = not (love.window and love.window.hasFocus) + or love.window.hasFocus() + local cap = FrameCap.current + if not visible then + cap = 10 + elseif Importer and (not focused or idleFor > 30) then + cap = 15 + end + + if visible and love.graphics and love.graphics.isActive() then love.graphics.origin() love.graphics.clear(love.graphics.getBackgroundColor()) if love.draw then love.draw() end @@ -1209,9 +1237,9 @@ function love.run() if paced then -- Sleep out the remainder of the frame budget, measured from the -- carried deadline, in small chunks so the OS timer stays - -- responsive. vsync is untouched: when it already paces slower - -- than the cap the remainder is <= 0 and this rounds to a no-op. - local budget = 1 / FrameCap.current + -- responsive. The pacer yields to vsync inside a 1ms dead band, so + -- when the panel already paces at or below the cap it is a no-op. + local budget = 1 / cap nextFrame = nextFrame + budget local now = love.timer.getTime() -- A stall (alt-tab, a GC pause, a blocked import) can leave the @@ -1222,8 +1250,8 @@ function love.run() end while true do local remaining = nextFrame - love.timer.getTime() - if remaining <= 0 then break end - love.timer.sleep(remaining < 0.001 and remaining or 0.001) + if remaining <= SLEEP_FLOOR then break end + love.timer.sleep(0.001) end else love.timer.sleep(0.001) diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 300cc345..f2a4d0ae 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -97,7 +97,7 @@ end -- space. function BattleState:extendedHUD() local options = self.game and self.game.save and self.game.save.options - local bg = options and options.battleBg + local bg = options and self:bgMode() return self:wideLayout() and options and options.battleHud == "extended" and ((options.battleFit == "fixed" @@ -108,12 +108,12 @@ end function BattleState:extendedWorldHUD() local options = self.game and self.game.save and self.game.save.options return self:extendedHUD() and options - and options.battleFit == "fixed" and options.battleBg == "world" + and options.battleFit == "fixed" and self:bgMode() == "world" end function BattleState:extendedBlackHUD() local options = self.game and self.game.save and self.game.save.options - return self:extendedHUD() and options and options.battleBg == "black" + return self:extendedHUD() and options and self:bgMode() == "black" end -- BATTLE BG: what fills the screen AROUND the battle -- the letterbox voids @@ -130,6 +130,10 @@ end -- opaque 160x144 field over the top, so only the surround changes. function BattleState:bgMode() local options = self.game and self.game.save and self.game.save.options + if options and self:wideLayout() and options.battleFit == "fill" + and options.battleHud == "extended" then + return "white" + end local mode = options and options.battleBg if mode == "black" or mode == "world" then return mode end return "white" @@ -212,6 +216,13 @@ local BALL_ANIMS = { SHAKE_ANIM = true, SHOWPIC_ANIM = true, } +-- engine/battle/effects.asm:552 +local ENEMY_STAT_DOWN_MISS = { + ATTACK_DOWN1_EFFECT = true, DEFENSE_DOWN1_EFFECT = true, + DEFENSE_DOWN2_EFFECT = true, SPEED_DOWN1_EFFECT = true, + ACCURACY_DOWN1_EFFECT = true, +} + local imageCache = {} -- The three tables below are keyed by the Image OBJECT, not by a path, and a -- running battle holds the pics it built at enter() (battler.sprite, @@ -229,6 +240,11 @@ local imagePadLeft = setmetatable({}, WEAK_KEYS) -- image -> { path, pal } so palette-fade variants (see fadeImage) can be -- rebuilt for any battle pic, whatever code loaded it local imageMeta = setmetatable({}, WEAK_KEYS) +local function mattedPic(path) + return path:sub(1, 17) == "assets/generated/" + or path:sub(1, 17) == "save/mod-derived/" +end + -- pal = { name, colors } recolors the 4 GB shades like the Super Game Boy. -- trueColor art (14 §the 4-shade contract) opts out of the quantize -- entirely, so its palette variant collapses back onto the plain path. @@ -254,29 +270,31 @@ local function getImage(path, pal, trueColor) end) end local w, h = id:getDimensions() - local bottom = h - 1 - while bottom >= 0 do - local opaque = false - for x = 0, w - 1 do - local _, _, _, a = id:getPixel(x, bottom) - if a > 0 then opaque = true break end + if mattedPic(Assets.resolve(path)) then + local bottom = h - 1 + while bottom >= 0 do + local opaque = false + for x = 0, w - 1 do + local _, _, _, a = id:getPixel(x, bottom) + if a > 0 then opaque = true break end + end + if opaque then break end + bottom = bottom - 1 end - if opaque then break end - bottom = bottom - 1 - end - local left = 0 - while left < w do - local opaque = false - for y = 0, h - 1 do - local _, _, _, a = id:getPixel(left, y) - if a > 0 then opaque = true break end + local left = 0 + while left < w do + local opaque = false + for y = 0, h - 1 do + local _, _, _, a = id:getPixel(left, y) + if a > 0 then opaque = true break end + end + if opaque then break end + left = left + 1 end - if opaque then break end - left = left + 1 + pad = h - 1 - bottom + padL = left end img = love.graphics.newImage(id) - pad = h - 1 - bottom - padL = left else img = Assets.image(path) -- headless stub: no pixel access end @@ -2214,6 +2232,9 @@ function BattleState:update(dt) self.waitFrames = nil if destination == "menu" then self.introSlide = nil + -- engine/battle/core.asm:2007 + self.msgHold = nil + self.shown = nil self.phase = "menu" elseif destination == "finish" then self:finish() @@ -3041,18 +3062,33 @@ local BGP_INVERT = { [0] = 3, 2, 1, 0 } -- $1b (flash phase 1) local BGP_WHITE = { [0] = 0, 0, 0, 0 } -- $00 (flash phase 2) local BGP_DARK = { [0] = 3, 3, 2, 1 } -- $6f DarkScreenPalette local BGP_LIGHT = { [0] = 0, 0, 1, 2 } -- $90 LightScreenPalette -local BGP_DARKEN = { [0] = 0, 1, 3, 3 } -- $f4 DarkenMonPalette (SGB) +local BGP_DARKEN_SGB = { [0] = 0, 1, 3, 3 } -- $f4 +local BGP_DARKEN_MONO = { [0] = 1, 2, 3, 3 } -- $f9 + +-- engine/battle/animations.asm:1090 +local function onSgb() + local m = require("src.render.PaletteFX").mode + return m == "gbc" or m == "gbc_inv" +end -- FlashScreenLongSGB (animations.asm:1010): 12 BGP values per cycle, -- 3 cycles; the first cycle holds each for 2 frames, the rest for 1 -- (FlashScreenLongDelay) -local FLASH_LONG_MAPS = { +local FLASH_LONG_SGB = { { [0] = 0, 2, 3, 3 }, { [0] = 0, 3, 3, 3 }, { [0] = 3, 3, 3, 3 }, { [0] = 0, 3, 3, 3 }, { [0] = 0, 2, 3, 3 }, { [0] = 0, 1, 2, 3 }, { [0] = 0, 0, 1, 2 }, { [0] = 0, 0, 0, 1 }, { [0] = 0, 0, 0, 0 }, { [0] = 0, 0, 0, 1 }, { [0] = 0, 0, 1, 2 }, { [0] = 0, 1, 2, 3 }, } +-- engine/battle/animations.asm:992 +local FLASH_LONG_MONO = { + { [0] = 1, 2, 3, 3 }, { [0] = 2, 3, 3, 3 }, { [0] = 3, 3, 3, 3 }, + { [0] = 2, 3, 3, 3 }, { [0] = 1, 2, 3, 3 }, { [0] = 0, 1, 2, 3 }, + { [0] = 0, 0, 1, 2 }, { [0] = 0, 0, 0, 1 }, { [0] = 0, 0, 0, 0 }, + { [0] = 0, 0, 0, 1 }, { [0] = 0, 0, 1, 2 }, { [0] = 0, 1, 2, 3 }, +} + -- the shade map in force this frame (a running flash wins over the -- persistent palette) function BattleState:activeBgp() @@ -3186,7 +3222,7 @@ function BattleState:applyAnimEffect(ev) elseif e == "SE_LIGHT_SCREEN_PALETTE" then fx.bgp = BGP_LIGHT elseif e == "SE_DARKEN_MON_PALETTE" then - fx.bgp = BGP_DARKEN + fx.bgp = onSgb() and BGP_DARKEN_SGB or BGP_DARKEN_MONO elseif e == "SE_RESET_SCREEN_PALETTE" then fx.bgp = nil elseif e == "SE_DARK_SCREEN_FLASH" then @@ -3196,8 +3232,9 @@ function BattleState:applyAnimEffect(ev) idx = 1, left = 2 } elseif e == "SE_FLASH_SCREEN_LONG" then local steps = {} + local maps = onSgb() and FLASH_LONG_SGB or FLASH_LONG_MONO for cycle = 1, 3 do - for _, m in ipairs(FLASH_LONG_MAPS) do + for _, m in ipairs(maps) do steps[#steps + 1] = { map = m, frames = (cycle == 1) and 2 or 1 } end end @@ -4139,6 +4176,12 @@ function BattleState:performMove(user, target, moveInst, isCalled) -- pure status moves if move.power == 0 and record and record.kind == "primary" and record.run then + if ENEMY_STAT_DOWN_MISS[move.effect] and not user.isPlayer + and self.kind ~= "link" and self.rng(0, 255) < 64 then + self:cancelMoveAnim() + self:sayNext(self:romText("_AttackMissedText", "%s's\nattack missed!", displayName(user))) + return + end -- accuracy-checked status effects run MoveHitTest, which has no -- 100%-accuracy early-out (even Thunder Wave misses on the 255 -- roll) and misses outright against a mid-Fly/Dig target; the @@ -4417,7 +4460,11 @@ function BattleState:awardExp() -- new current HP (house convention: potions drain the bar too, see -- itemUsed) so the bar grows instead. Only the active player battler -- shares its table with the HUD; other party mons (EXP.ALL) have no bar. - if mon == self.player.mon then self:drainNext() end + if mon == self.player.mon then + -- engine/battle/experience.asm:236 + self.player.badgeExtraBoosts = nil + self:drainNext() + end for _, moveId in ipairs(Experience.movesLearnedAt( self.data.pokemon[mon.species], lv)) do self:learnMove(mon, moveId) @@ -5920,7 +5967,9 @@ function BattleState:animSpriteColors(s, px, py) local P -- engine/battle/animations.asm:551 (.notSGB) if PaletteFX.usesSpriteObp() then - P = PaletteFX.ogObj() + -- engine/battle/init_battle_variables.asm:18 + P = require("src.core.GameVersion").isBlue() and PaletteFX.GBC_OBJ_BLUE + or PaletteFX.GBC_OBJ if key == "f0" then key = "e4" elseif key == "f0x" then key = "e4x" end else P = self:zoneColorsAt(px or (s.x - 8 + 4), py or (s.y - 16 + 4)) diff --git a/src/battle/Damage.lua b/src/battle/Damage.lua index 894bfd44..929fff89 100644 --- a/src/battle/Damage.lua +++ b/src/battle/Damage.lua @@ -41,6 +41,34 @@ local function badgeBoost(battler, stat) return nil end +-- engine/battle/core.asm:6454 +function Damage.applyBadgeBoost(battler, stat, value) + if battler.hazeStatReset then return value end + local row = badgeBoost(battler, stat) + if not row then return value end + local extra = battler.badgeExtraBoosts and battler.badgeExtraBoosts[stat] or 0 + for _ = 1, 1 + extra do + value = math.min(999, math.floor(value * (row.num or 9) / (row.den or 8))) + end + return value +end + +-- engine/battle/effects.asm:498,689 +function Damage.reapplyBadgeBoosts(battler, changedStat) + if not battler or not battler.badges then return end + local extra = battler.badgeExtraBoosts + if not extra then extra = {} battler.badgeExtraBoosts = extra end + for _, row in ipairs(battler.badgeBoosts or Damage.BADGE_BOOSTS) do + if battler.badges[row.badge] then + if row.stat == changedStat then + extra[row.stat] = 0 + else + extra[row.stat] = (extra[row.stat] or 0) + 1 + end + end + end +end + -- the merged status record for a battler's persistent condition, or nil local function statusRecord(battler) return Status.recordFor(battler.statuses, battler.mon.status) @@ -178,14 +206,8 @@ function Damage.compute(ruleset, attacker, defender, move, opts) -- badge boosts (x9/8), engine/battle/core.asm ApplyBadgeStatBoosts: -- Boulder -> attack, Thunder -> defense, Soul -> speed (TurnOrder), -- Volcano -> special - local atkBoost = badgeBoost(attacker, atkStat) - if atkBoost then - atk = math.floor(atk * (atkBoost.num or 9) / (atkBoost.den or 8)) - end - local defBoost = badgeBoost(defender, defStat) - if defBoost then - dfn = math.floor(dfn * (defBoost.num or 9) / (defBoost.den or 8)) - end + atk = Damage.applyBadgeBoost(attacker, atkStat, atk) + dfn = Damage.applyBadgeBoost(defender, defStat, dfn) -- burn halves physical attack (applied as part of the stat in Gen 1; -- the status record's statPenalty names the stat it cuts). -- hazeStatReset suppresses it: Haze (haze.asm ResetStats) copied the diff --git a/src/battle/MoveEffects.lua b/src/battle/MoveEffects.lua index 39d96387..d6abf537 100644 --- a/src/battle/MoveEffects.lua +++ b/src/battle/MoveEffects.lua @@ -11,6 +11,7 @@ -- is the registry view of all three -- the merged Data.move_effects a -- battle dispatches on serves these same objects. +local Damage = require("src.battle.Damage") local Logger = require("src.core.Logger") local StatusRegistry = require("src.battle.StatusRegistry") local TurnOrder = require("src.battle.TurnOrder") @@ -57,6 +58,10 @@ local function changeStage(battle, who, stat, delta, fromEnemy) -- recomputed and QuarterSpeedDueToParalysis/HalveAttackDueToBurn re-run, -- re-baking the burn/para penalty and ending Haze's temporary lift. who.hazeStatReset = nil + if battle.ruleset and battle.ruleset.badgeBoostReapplyBug + and battle.kind ~= "link" and who == battle.player then + Damage.reapplyBadgeBoosts(who, stat) + end -- _MonsStatsRoseText/_MonsStatsFellText: "X's / STAT rose!"; the -- two-stage variants scroll "greatly" onto a third line local label = Strings(STAT_LABEL[stat]) -- looked up here, not at require (#811) @@ -132,6 +137,11 @@ end local function statDownSide(stat) return function(battle, user, target) + -- engine/battle/effects.asm:552 + if not user.isPlayer and battle.kind ~= "link" + and battle.rng(0, 255) < 64 then + return {} + end if target.substituteHP then return {} end if battle.rng(0, 255) >= 85 then return {} end -- 33 percent + 1 (85/256) -- StatModifierDownEffect's side-effect branch never runs MoveHitTest, @@ -254,6 +264,7 @@ MoveEffects.primary = { -- Attack-halving and paralysis Speed-quartering on BOTH battlers -- until the next stat recompute (a stage change or switch-in). b.hazeStatReset = true + b.badgeExtraBoosts = nil end -- Gen 1 also removes the enemy's major status; if that cured sleep -- or freeze, the target forfeits its move this turn (haze.asm diff --git a/src/battle/TurnOrder.lua b/src/battle/TurnOrder.lua index 499093e8..d681ecf5 100644 --- a/src/battle/TurnOrder.lua +++ b/src/battle/TurnOrder.lua @@ -14,15 +14,7 @@ local function effectiveSpeed(battler) battler.stages and battler.stages.speed or 0) -- ApplyBadgeStatBoosts: the SOULBADGE boosts speed; the rows come from -- the battler's merged badgeBoosts with the vanilla list as fallback - local badges = battler.badges - if badges then - for _, row in ipairs(battler.badgeBoosts or Damage.BADGE_BOOSTS) do - if row.stat == "speed" and badges[row.badge] then - spd = math.floor(spd * (row.num or 9) / (row.den or 8)) - break - end - end - end + spd = Damage.applyBadgeBoost(battler, "speed", spd) -- paralysis quarters speed (the status record's statPenalty); -- hazeStatReset suppresses it because Haze (haze.asm ResetStats) -- copied the unmodified speed over the quartered battle stat, lifting diff --git a/src/battle/WideBattle.lua b/src/battle/WideBattle.lua index ad459574..32508356 100644 --- a/src/battle/WideBattle.lua +++ b/src/battle/WideBattle.lua @@ -97,6 +97,19 @@ local function battleIsTopState(battle) return not (stack and stack.top) or stack:top() == battle end +-- engine/menus/party_menu.asm:4 +local function coveredByOpaqueState(battle) + local stack = battle.game and battle.game.stack + local states = stack and stack.states + if not states then return false end + local above = false + for i = 1, #states do + if above and states[i] and states[i].isOpaque then return true end + if states[i] == battle then above = true end + end + return false +end + local function anchorHUD(battle, x, y, w, h, anchor) if not battle:extendedHUD() or not battleIsTopState(battle) then return end local renderer = battle.game and battle.game.renderer @@ -351,7 +364,7 @@ function WideBattle.draw(battle) g.rectangle("fill", 0, 0, WideBattle.WIDTH, WideBattle.HEIGHT) end -- AskName clears the field the same way the classic layout does - if battle.blankForAskName then return end + if battle.blankForAskName or coveredByOpaqueState(battle) then return end local fx = battle.fx local sx = (fx and fx.shakeX) or 0 diff --git a/src/battle/gen2/Battle.lua b/src/battle/gen2/Battle.lua index 2b0b0543..b3efe15b 100644 --- a/src/battle/gen2/Battle.lua +++ b/src/battle/gen2/Battle.lua @@ -645,6 +645,7 @@ function Battle:smartAiState() -- wPlayerSubStatus5 & SUBSTATUS_LOCK_ON: the enemy's OWN Lock-On, since -- BattleCommand_LockOn sets the bit on the target it was aimed at. playerLockOn = playerState.lockOn or nil, + playerIdentified = playerState.identified or nil, playerPhysicalMoves = physical, enemyRage = enemyState.rage, enemyRageCount = enemyState.rageCount, @@ -1082,7 +1083,7 @@ function Battle:hitOnce(attacker, defender, def, opts) local attackerStages = self.stages[self:sideOf(attacker)] local defenderStages = self.stages[self:sideOf(defender)] local types = self.data.type_chart and self.data.type_chart.types - local matchups = self.data.type_chart and self.data.type_chart.matchups + local matchups = self:matchupsAgainst(defender) local heldEffect, heldParam = self:heldEffect(attacker, "damage") -- BattleCommand_Critical: SUBSTATUS_FOCUS_ENERGY (Focus Energy or a @@ -1202,6 +1203,8 @@ function Battle:hitOnce(attacker, defender, def, opts) if def.effect == "EFFECT_FALSE_SWIPE" and damage >= (defender.hp or 0) then damage = math.max(0, (defender.hp or 0) - 1) end + -- engine/battle_anims/anim_commands.asm:1200 + if self.moveEvent then self.moveEvent.effectiveness = info.effectiveness end return self:dealDamage(attacker, defender, damage, { critical = critical, effectiveness = info.effectiveness, -- Counter answers physical damage and Mirror Coat special, so what kind @@ -1690,6 +1693,13 @@ function Battle:useMove(attacker, defender, moveId) text = ("Magnitude %d!"):format(number) }) end + -- data/moves/effects.asm:1607, :1649 + if def.effect == "EFFECT_RETURN" then + powerOverride = Effects.happinessPower(attacker.happiness) + elseif def.effect == "EFFECT_FRUSTRATION" then + powerOverride = Effects.happinessPower(attacker.happiness, true) + end + if not sureHit and not self:accuracyRoll(def, attacker, defender) then -- data/moves/effects.asm:148-151: `selfdestruct` sits between checkhit and @@ -1744,7 +1754,7 @@ function Battle:useMove(attacker, defender, moveId) -- thing that stops SONIC BOOM, NIGHT SHADE or SUPER FANG. local defenderTypes = (self:speciesDef(defender) or {}).types or defender.types - local matchups = self.data.type_chart and self.data.type_chart.matchups + local matchups = self:matchupsAgainst(defender) if Damage.typeMultiplier(def.type, defenderTypes, matchups) == 0 then self:markMissed() self:emit({ kind = "message", @@ -2119,6 +2129,21 @@ Battle.MOVE_EFFECTS.EFFECT_LOCK_ON = function(self, attacker, defender) text = self:monName(attacker) .. " took aim!" }) end +-- engine/battle/move_effects/foresight.asm +Battle.MOVE_EFFECTS.EFFECT_FORESIGHT = function(self, attacker, defender, + def, _, sureHit) + if not sureHit + and not self:accuracyRoll(def, attacker, defender) then + return fail(self) + end + local target = self:volatile(defender) + if target.vanished or target.identified then return fail(self) end + target.identified = true + self:emit({ kind = "message", + text = self:monName(attacker) .. " identified " + .. self:monName(defender) .. "!" }) +end + -- BattleCommand_CheckHit's .LockOn: the flag is read AND cleared by the next -- move aimed at the mon carrying it, whether or not that move was the one the -- lock-on was meant for, and whether or not the exception at :1683-1688 then @@ -2166,9 +2191,15 @@ function Battle:accuracyRoll(def, attacker, defender, accuracy) end function Battle:vanillaAccuracyRoll(accuracy, attacker, defender) - return Damage.rollHit(self:moveAccuracy(accuracy, defender), - self.stages[self:sideOf(attacker)].accuracy, - self.stages[self:sideOf(defender)].evasion, self.random) + local acc = self.stages[self:sideOf(attacker)].accuracy + local eva = self.stages[self:sideOf(defender)].evasion + -- engine/battle/effect_commands.asm:1786 + if defender and self:volatile(defender).identified + and (eva or 0) >= (acc or 0) then + acc, eva = 0, 0 + end + return Damage.rollHit(self:moveAccuracy(accuracy, defender), acc, eva, + self.random) end -- BattleCommand_StatDown's SUBSTATUS_MIST arm (a GUARD SPEC): a drop the FOE @@ -2354,7 +2385,7 @@ Battle.MOVE_EFFECTS.EFFECT_FUTURE_SIGHT = function(self, attacker, defender, def stages = self.stages[self:sideOf(defender)], }, types = self.data.type_chart and self.data.type_chart.types, - matchups = self.data.type_chart and self.data.type_chart.matchups, + matchups = self:matchupsAgainst(defender), random = self.random, }) state.futureSight = Effects.FUTURE_SIGHT_TURNS @@ -3072,6 +3103,30 @@ function Battle:safeguarded(mon) return (self.screens[self:sideOf(mon)].safeguard or 0) > 0 end +-- engine/battle/effect_commands.asm:1305 +function Battle:matchupsAgainst(defender) + local chart = self.data.type_chart + local rows = chart and chart.matchups + if not rows or not defender then return rows end + if not self:volatile(defender).identified then return rows end + local skipped = chart.foresightMatchups + if not skipped or #skipped == 0 then return rows end + if not self.identifiedMatchups then + local drop = {} + for _, row in ipairs(skipped) do + drop[tostring(row.attacker) .. "/" .. tostring(row.defender)] = true + end + local out = {} + for _, row in ipairs(rows) do + if not drop[tostring(row.attacker) .. "/" .. tostring(row.defender)] then + out[#out + 1] = row + end + end + self.identifiedMatchups = out + end + return self.identifiedMatchups +end + -- `source` is the battler that inflicted it, carried only so -- battle.status_inflicted can name it the way Gen 1's does. -- BattleCommand_Paralyze and BattleCommand_Poison refuse on a zero matchup, @@ -3084,7 +3139,7 @@ function Battle:statusRefusedByType(defender, moveType, status) end local types = (self:speciesDef(defender) or {}).types or defender.types or {} if moveType then - local matchups = self.data.type_chart and self.data.type_chart.matchups + local matchups = self:matchupsAgainst(defender) if Damage.typeMultiplier(moveType, types, matchups) == 0 then return true end end if status == "poison" or status == "toxic" then @@ -3917,6 +3972,8 @@ end -- check. Split out because playerAttack needs the same answer. function Battle:lockedInMove(mon) local state = self:volatile(mon) + -- engine/battle/core.asm:543 + if state.chargeMove then return state.chargeMove end if state.rolloutLock then return state.rolloutLock end if state.rampageMove and (state.rampageTurns or 0) > 0 then return state.rampageMove diff --git a/src/battle/gen2/Effects.lua b/src/battle/gen2/Effects.lua index a36f8ea3..2e2b87a4 100644 --- a/src/battle/gen2/Effects.lua +++ b/src/battle/gen2/Effects.lua @@ -284,6 +284,14 @@ function Effects.magnitudePower(random) return last[2], last[3] end +-- engine/battle/move_effects/return.asm:1-24, frustration.asm:1-25 +function Effects.happinessPower(happiness, frustration) + local h = happiness or 0 + if h < 0 then h = 0 elseif h > 255 then h = 255 end + if frustration then h = 255 - h end + return math.floor(h * 10 / 25) +end + -- ------------------------------------------------------------------- weather -- -- BattleCommand_StartRain / StartSun / StartSandstorm all set wWeatherCount to diff --git a/src/battle/gen2/Prize.lua b/src/battle/gen2/Prize.lua index 54fef6ae..cdd92e95 100644 --- a/src/battle/gen2/Prize.lua +++ b/src/battle/gen2/Prize.lua @@ -58,9 +58,12 @@ Prize.AMULET_COIN = "AMULET_COIN" -- data/text/battle.asm. Declared here and formatted at the call site so -- Strings.source is what registers them, the same way Decorations declares -- its own five. No line markers: every battle message in this port is one --- flowing string that Chrome.wrap breaks to the box. +-- flowing string that Chrome.wrap breaks to the box, except SentSomeToMomText, +-- which keeps the cart's own `line`/`cont` breaks because it does not fit two +-- rows. local GOT_MONEY = Strings.source("%s got %s%d for winning!") -local SENT_SOME = Strings.source("%s got %s%d for winning! Sent some to MOM!") +-- data/text/battle.asm:179-185 +local SENT_SOME = Strings.source("%s got %s%d\nfor winning!\vSent some to MOM!") -- The half and all texts really are this short on the cart: they replace the -- money line rather than following it, which is a quirk no Gold player can -- see because BankOfMom only ever writes MOM_SAVING_SOME_MONEY_F. diff --git a/src/battle/rulesets/gen1_faithful.lua b/src/battle/rulesets/gen1_faithful.lua index 68f171ab..7c0c57ba 100644 --- a/src/battle/rulesets/gen1_faithful.lua +++ b/src/battle/rulesets/gen1_faithful.lua @@ -23,4 +23,6 @@ return { -- (core.asm:426-464): poison/burn/leech seed tick before the slower -- mon acts, not in an end-of-round sweep like Gen 3+. residualAfterMove = true, + -- engine/battle/effects.asm:498,689 + badgeBoostReapplyBug = true, } diff --git a/src/core/BattleCheckpoint.lua b/src/core/BattleCheckpoint.lua index b933bd7c..8a62eb4e 100644 --- a/src/core/BattleCheckpoint.lua +++ b/src/core/BattleCheckpoint.lua @@ -27,7 +27,7 @@ local BATTLER_FIELDS = { "chargeReady", "invulnerable", "mustRecharge", "thrashTurns", "thrashAnnounced", "focusEnergy", "leechSeeded", "lightScreen", "reflect", "mist", "xAccuracy", "lastMove", "flinched", - "skipMove", "hazeStatReset", "drainFloor", "drainHold", "trappingTurns", + "skipMove", "hazeStatReset", "badgeExtraBoosts", "drainFloor", "drainHold", "trappingTurns", "trapMove", "trapDamage", "fainted", "aiLayer2", } diff --git a/src/core/FixedStep.lua b/src/core/FixedStep.lua index 792df70a..6a03da6a 100644 --- a/src/core/FixedStep.lua +++ b/src/core/FixedStep.lua @@ -6,6 +6,9 @@ local FixedStep = {} FixedStep.STEP = 1 / 60 local MAX_ACCUM = 0.25 -- avoid spiral of death after a stall +local SMOOTH_FRAMES = 4 +local SMOOTH_MAX = 1 / 60 * 2.5 +local STEP_EPS = 1 / 60 * 0.02 -- Phase the accumulator is re-seeded with once an absorbed hitch frame has -- been paid for. Half a step is the balanced point: a frame has to come in @@ -23,6 +26,7 @@ function FixedStep:init(callback) self.accum = 0 self.callback = callback self.suppressCatchup = false + self.dtHistory, self.dtSum = nil, 0 end -- The anti-spiral clamp doubles as a steps-per-frame ceiling (0.25s = 15 @@ -39,12 +43,26 @@ function FixedStep:update(dt) -- the burst it would otherwise release doesn't play out as a slide. if self.suppressCatchup then self.suppressCatchup = false + self.dtHistory, self.dtSum = nil, 0 self.accum = self.STEP * RESEED_PHASE self.callback(self.STEP) return end + if dt > 0 and dt <= SMOOTH_MAX then + local hist = self.dtHistory + if not hist then hist = {}; self.dtHistory = hist; self.dtSum = 0 end + hist[#hist + 1] = dt + self.dtSum = self.dtSum + dt + if #hist > SMOOTH_FRAMES then + self.dtSum = self.dtSum - hist[1] + table.remove(hist, 1) + end + dt = self.dtSum / #hist + else + self.dtHistory, self.dtSum = nil, 0 + end self.accum = math.min(self.accum + dt, self.maxAccum or MAX_ACCUM) - while self.accum >= self.STEP do + while self.accum >= self.STEP - STEP_EPS do self.accum = self.accum - self.STEP self.callback(self.STEP) end diff --git a/src/core/Game.lua b/src/core/Game.lua index 2cc890d7..ce95bea9 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -1324,9 +1324,7 @@ function Game:restoreSave(loaded, recovered, opts) self:adoptSave(loaded) -- SaveData.load already attached the standalone options.lua table self:applyOptions(loaded.options) - -- saves from before OT/ID stamping: backfill with the player's (after - -- the scrub, so every mon the stamp loop sees is known) - SaveData.repairTradedOtIds(loaded) + -- saves from before OT/ID stamping: backfill with the player's local stamp = require("src.battle.BattleState").stampOT for _, mon in ipairs(loaded.party or {}) do stamp(loaded, mon) end for _, box in ipairs(loaded.boxes or {}) do diff --git a/src/core/Game2.lua b/src/core/Game2.lua index 22d914ff..252c2a37 100644 --- a/src/core/Game2.lua +++ b/src/core/Game2.lua @@ -350,6 +350,10 @@ function Game2:showTitle() onContinue = function() self:showMainMenu() end, + -- engine/menus/intro_menu.asm:848-889 + onTimeout = function() + self:showCopyright() + end, }) end diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index 7263d2b0..dde0f760 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -1983,6 +1983,11 @@ SaveData.addCoreMigration(3, function(save) end end) +-- #1461 repair, one-shot on pre-format-5 saves +SaveData.addCoreMigration(4, function(save) + SaveData.repairTradedOtIds(save) +end) + -- ------- write -- Game progress only; options are written separately via saveOptions. @@ -2394,8 +2399,8 @@ function SaveData.applyPostGameHome(save, boot) end -- 0.1.82-0.1.9x loads stamped the player's own id onto traded mons and saved --- it, which reads back as home-caught. A caught mon can never carry --- traded=true, so clearing that pair is safe on every load. #1461 +-- it, which reads back as home-caught. Run only as the pre-format-5 +-- migration: a same-id trade partner makes that pair legitimate. #1461 function SaveData.repairTradedOtIds(save) local playerId = save and save.player and save.player.id if playerId == nil then return 0 end diff --git a/src/core/TouchControls.lua b/src/core/TouchControls.lua index 50c28048..3df41e2c 100644 --- a/src/core/TouchControls.lua +++ b/src/core/TouchControls.lua @@ -217,10 +217,6 @@ function TouchControls.defaultLayout(ww, wh, ox, oy, scale) local abW = dpadW * 0.46 local ssW = dpadW * 0.30 local margin = dpadW * 0.12 - local ok, GameVersion = pcall(require, "src.core.GameVersion") - if ok and GameVersion.generation and GameVersion.generation() == 2 then - margin = math.max(margin, math.min(ww * 0.10, 72)) - end return { dpad = { cx = ox + margin + dpadW / 2, cy = oy + wh - margin - dpadW / 2, w = dpadW }, a = { cx = ox + ww - margin - abW * 0.55, cy = oy + wh - margin - abW * 1.75, w = abW }, diff --git a/src/core/Version.lua b/src/core/Version.lua index 0424bc82..184d8431 100644 --- a/src/core/Version.lua +++ b/src/core/Version.lua @@ -18,7 +18,7 @@ local Version = { -- exceeds the shell it provides. modApi = 2, -- mod API major (manifest `api`) linkProtocol = 2, -- link handshake wire version (Handshake.PROTOCOL) - saveFormat = 4, -- save.meta.format + saveFormat = 5, -- save.meta.format cache = "rom-cache-v5", -- ROM import cache generation (RomImporter marker) } diff --git a/src/core/chip_worker.lua b/src/core/chip_worker.lua index 21c59f8b..88de0199 100644 --- a/src/core/chip_worker.lua +++ b/src/core/chip_worker.lua @@ -89,10 +89,12 @@ local function handle(cmd) return false end +local idleWait = false + while true do -- drain every pending command first, so a stop/new-play is seen promptly local quit = false - local cmd = cmdCh:pop() + local cmd = idleWait and cmdCh:demand(0.05) or cmdCh:pop() while cmd do if handle(cmd) then quit = true end cmd = cmdCh:pop() @@ -100,6 +102,7 @@ while true do if quit then break end if engine and not finished and gen and outCh:getCount() < LOOKAHEAD then + idleWait = false local activeGen = gen local ok, sd = pcall(ChipSynth.soundData, engine, BUF, 2) if not ok then @@ -113,8 +116,10 @@ while true do finished = true end end + elseif engine and not finished and gen then + idleWait = false + love.timer.sleep(0.005) else - -- nothing to do (idle, or the look-ahead is full): yield the core - love.timer.sleep(0.001) + idleWait = true end end diff --git a/src/core/gen2/MomShopping.lua b/src/core/gen2/MomShopping.lua index a061f9b5..1af7cad0 100644 --- a/src/core/gen2/MomShopping.lua +++ b/src/core/gen2/MomShopping.lua @@ -164,14 +164,12 @@ local function receiveItemToPc(save, id, data) save.pcItems = save.pcItems or {} local pc = save.pcItems local held = pc[id] or 0 - if held == 0 then - local cap = (data and data.field and data.field.pcItemCap) or PC_ITEM_CAPACITY - local stacks = 0 - for _ in pairs(pc) do stacks = stacks + 1 end - if stacks >= cap then return false end - elseif held + 1 > MAX_STACK then - return false - end + local cap = (data and data.field and data.field.pcItemCap) or PC_ITEM_CAPACITY + local function stacksFor(n) return math.ceil((n or 0) / MAX_STACK) end + local used = 0 + for _, count in pairs(pc) do used = used + stacksFor(count) end + -- engine/items/items.asm:156 PutItemInPocket + if used + stacksFor(held + 1) - stacksFor(held) > cap then return false end pc[id] = held + 1 return true end diff --git a/src/core/gen2/Save.lua b/src/core/gen2/Save.lua index dd10851c..ad84293a 100644 --- a/src/core/gen2/Save.lua +++ b/src/core/gen2/Save.lua @@ -343,6 +343,7 @@ Save.OPTIONS_KEY = "gold" local SHARED_KEYS = { touchControls = true, haptics = true, screenPos = true, + videoMode = true, mods = true, modsByVersion = true, modsGen2 = true, modOptions = true, modProfiles = true, modProfilesSeeded = true, activeProfile = true, @@ -361,12 +362,9 @@ function Save.loadOptions(fs) end if type(loaded) == "table" then for key in pairs(SHARED_KEYS) do - if loaded[key] ~= nil then options[key] = loaded[key] end - end - end - if type(stored) == "table" then - for key in pairs(SHARED_KEYS) do - if options[key] == nil and stored[key] ~= nil then + if loaded[key] ~= nil then + options[key] = loaded[key] + elseif type(stored) == "table" and stored[key] ~= nil then options[key] = stored[key] end end diff --git a/src/import/CacheContract.lua b/src/import/CacheContract.lua index 1937cb21..e9f9bca9 100644 --- a/src/import/CacheContract.lua +++ b/src/import/CacheContract.lua @@ -10,7 +10,7 @@ local CacheContract = {} CacheContract.FORMAT = "rom-cache-v10:" CacheContract.VERSION_FORMAT = { - crystal = "rom-cache-v10-crystal2:", + crystal = "rom-cache-v10-crystal3:", } CacheContract.MARKER_PATH = "rom-cache.complete" diff --git a/src/import/LauncherSettings.lua b/src/import/LauncherSettings.lua index 3739dd8a..ecba935c 100644 --- a/src/import/LauncherSettings.lua +++ b/src/import/LauncherSettings.lua @@ -23,6 +23,11 @@ local SaveData = require("src.core.SaveData") local LauncherSettings = {} +local function bgLocked(opts) + return opts.battleLayout == "wide" and opts.battleFit == "fill" + and opts.battleHud == "extended" +end + local function wrapIndex(i, n) i = i % n if i < 0 then i = i + n end @@ -156,8 +161,6 @@ local function coreRows(opts, hooks) opts.battleLayout = opts.battleLayout == "wide" and "og" or "wide" if opts.battleLayout ~= "wide" then opts.battleHud = "standard" - elseif opts.battleFit == "fill" and opts.battleHud == "extended" then - opts.battleBg = "white" end return true end) @@ -167,10 +170,6 @@ local function coreRows(opts, hooks) end, function() opts.battleFit = opts.battleFit == "fill" and "fixed" or "fill" - if opts.battleFit == "fill" and opts.battleLayout == "wide" - and opts.battleHud == "extended" then - opts.battleBg = "white" - end return true end) add(Strings("BATTLE HUD"), @@ -185,28 +184,17 @@ local function coreRows(opts, hooks) return false end opts.battleHud = opts.battleHud == "extended" and "standard" or "extended" - if opts.battleHud == "extended" and opts.battleFit == "fill" then - opts.battleBg = "white" - end return true end) add(Strings("BATTLE BG"), function() - if opts.battleLayout == "wide" and opts.battleFit == "fill" - and opts.battleHud == "extended" then - opts.battleBg = "white" - return Strings("AUTO") - end + if bgLocked(opts) then return Strings("AUTO (FILL HUD)") end if opts.battleBg == "black" then return Strings("BLACK") end if opts.battleBg == "world" then return Strings("WORLD") end return Strings("WHITE") end, function(dir) - if opts.battleLayout == "wide" and opts.battleFit == "fill" - and opts.battleHud == "extended" then - opts.battleBg = "white" - return false - end + if bgLocked(opts) then return false end local order = { "white", "black", "world" } local cur = 1 for i, mode in ipairs(order) do @@ -563,7 +551,7 @@ end -- src/ui/gen2/OptionsMenu.lua's ROWS; when editing one, keep the two in sync. local GEN2_KEY = "gold" -local function gen2Rows(opts, hooks) +local function gen2Rows(opts, hooks, shared) local rows = {} local function add(label, value, step) rows[#rows + 1] = { label = label, value = value, step = step } @@ -669,9 +657,9 @@ local function gen2Rows(opts, hooks) local okVm, VideoMode = pcall(require, "src.core.VideoMode") if okVm then add(Strings("VIDEO MODE"), - function() return VideoMode.modeLabel(opts.videoMode) end, + function() return VideoMode.modeLabel(shared.videoMode) end, function(dir) - opts.videoMode = VideoMode.cycle(opts.videoMode, dir) + shared.videoMode = VideoMode.cycle(shared.videoMode, dir) return true end) end @@ -690,7 +678,7 @@ local function gen2Rows(opts, hooks) add(Strings("BATTLE BG"), ladder(opts, "battleBg", { { "white", "WHITE" }, { "black", "BLACK" } }, "white")) - addTouchRows(rows, add, opts, hooks) + addTouchRows(rows, add, shared, hooks) return rows end @@ -718,7 +706,7 @@ function LauncherSettings.open(hooks, version) opts[GEN2_KEY] = block end sections = { - { title = Strings("OPTIONS"), rows = gen2Rows(block, hooks) }, + { title = Strings("OPTIONS"), rows = gen2Rows(block, hooks, opts) }, } else sections = { diff --git a/src/import/RomExtractorGen2.lua b/src/import/RomExtractorGen2.lua index 151ffd71..2719dc76 100644 --- a/src/import/RomExtractorGen2.lua +++ b/src/import/RomExtractorGen2.lua @@ -151,6 +151,19 @@ local TEXT_BUFFERS = { [0xc602] = "wOTTrademonSpeciesName", [0xc618] = "wOTTrademonSenderName", } +-- ../pokecrystal/ram/wram.asm:1925,2333 +local TEXT_BUFFERS_CRYSTAL = { + [0xd050] = "wMonOrItemNameBuffer", + [0xd073] = "wStringBuffer1", + [0xd086] = "wStringBuffer2", + [0xd099] = "wStringBuffer3", + [0xd0ac] = "wStringBuffer4", + [0xd0bf] = "wStringBuffer5", + [0xc6d1] = "wPlayerTrademonSpeciesName", + [0xc6e7] = "wPlayerTrademonSenderName", + [0xc703] = "wOTTrademonSpeciesName", + [0xc719] = "wOTTrademonSenderName", +} -- The text commands that print nothing and carry no argument -- (macros/scripts/text.asm, in TextCommands order): TX_LOW, TX_SCROLL, -- TX_PAUSE, TX_WAIT_BUTTON, TX_DAY, and the six TX_SOUND_* jingles. @@ -2295,6 +2308,8 @@ function RomExtractorGen2:extractTitle() trailBobAmplitude = silver and 3 or 2, trailPhaseStep = silver and 7 or 3, trailPhase = silver and 0 or nil, + -- TitleScreenTimer (engine/menus/intro_menu.asm:951-966). + timeoutFrames = silver and (73 * 60 + 36) or (84 * 60 + 16), } self:write("title", data) return data @@ -2805,6 +2820,8 @@ end -- is which alongside the text lets a caller fill them in order without -- changing a marker every screen already reads. function RomExtractorGen2:decodeGen2Text(bank, address, charmap, buffers) + local textBuffers = (self.edition == "crystal") and TEXT_BUFFERS_CRYSTAL + or TEXT_BUFFERS local out = {} local i = 0 local hops = 0 @@ -2840,7 +2857,7 @@ function RomExtractorGen2:decodeGen2Text(bank, address, charmap, buffers) out[#out + 1] = "{STRBUF}" if buffers then local target = self.rom:word(bank, address + i + 1) - buffers[#buffers + 1] = TEXT_BUFFERS[target] or target + buffers[#buffers + 1] = textBuffers[target] or target end i = i + 2 elseif b == 0x4e or b == 0x4f then diff --git a/src/inventory/ItemEffects.lua b/src/inventory/ItemEffects.lua index 2915b4eb..f0ecadcd 100644 --- a/src/inventory/ItemEffects.lua +++ b/src/inventory/ItemEffects.lua @@ -310,6 +310,11 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) "Nothing happened!") } end b.stages[stat] = cur + 1 + b.hazeStatReset = nil + if battle.ruleset and battle.ruleset.badgeBoostReapplyBug + and battle.kind ~= "link" then + require("src.battle.Damage").reapplyBadgeBoosts(b, stat) + end return "consumed", { Strings("%s's\n%s rose!", b.name, Strings(STAT_LABEL[stat])) } end -- ItemUseDireHit/ItemUseGuardSpec always set the bit and consume diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index fa9250df..6b956889 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -817,7 +817,7 @@ function Renderer:frameRects() -- GB pixel stops being a whole number of screen pixels, which is the trade -- the setting exists to offer. Clamped on the horizontal too, so a narrow -- window scales to fit instead of overflowing off both sides. - if self.uiFill then + if self.uiFill and not FaithfulRes.scaleCap() then Up = math.min(ph / uih, pw / uiw) end if uiw * Up > pw or uih * Up > ph then diff --git a/src/script/Commands.lua b/src/script/Commands.lua index 3646939a..627e701a 100644 --- a/src/script/Commands.lua +++ b/src/script/Commands.lua @@ -529,6 +529,16 @@ function Commands.set_field(ctx, key, value) ctx.save[key] = value end +-- scripts/ChampionsRoom.asm:57 +function Commands.set_option(ctx, key, value) + local o = ctx.save.options + if not o then + o = {} + ctx.save.options = o + end + o[key] = value +end + function Commands.load_player_starter_name(ctx) local flags = ctx.save.flags or {} local species = flags.EVENT_CHOSE_PIKACHU and "PIKACHU" @@ -720,7 +730,8 @@ end -- Box deposits also print SentToBoxText (give_pokemon.asm:36-37). -- skipNickname suppresses AskName for callers that name the gift themselves; -- no vanilla script uses it (pokeyellow scripts/OaksLab.asm, #1013) -function Commands.give_pokemon(ctx, species, level, skipNickname) +-- gotText prints GotMonText ahead of AskName (give_pokemon.asm:46). +function Commands.give_pokemon(ctx, species, level, skipNickname, gotText) -- Native mods can transform a gift before the Pokémon object is created. -- This is intentionally an event rather than a special-case starter hook: -- mods can use the same seam for story gifts, fossils, or custom scripts. @@ -754,6 +765,11 @@ function Commands.give_pokemon(ctx, species, level, skipNickname) ctx.lastCheck = true ctx.addedToParty = addedToParty ctx.boxNum = boxNum + -- engine/events/give_pokemon.asm:46 + if gotText and ctx.runner then + Commands.text_sound(ctx, "Get_Item1") + Commands.show_text(ctx, "_GotMonText", { RAM = species }) + end -- AskName: both AddPartyMon and SendNewMonToBox; skip mod-set nicks -- and callback-style callers with no script runner to yield on. if not gift.nickname and not skipNickname and ctx.runner then @@ -1303,11 +1319,14 @@ function FadeOverlay:update() end function FadeOverlay:draw() - if self.color == "white" then - love.graphics.setColor(1, 1, 1, self.alpha) - else - love.graphics.setColor(0, 0, 0, self.alpha) + local shade = (self.color == "white") and 1 or 0 + -- home/fade.asm:26 + local r = self.game and self.game.renderer + if r then + r.screenVeil = { shade, self.alpha } + return end + love.graphics.setColor(shade, shade, shade, self.alpha) love.graphics.rectangle("fill", 0, 0, 160, 144) love.graphics.setColor(1, 1, 1, 1) end diff --git a/src/script/gen2/CallAsm.lua b/src/script/gen2/CallAsm.lua index 4feb0155..57472ba2 100644 --- a/src/script/gen2/CallAsm.lua +++ b/src/script/gen2/CallAsm.lua @@ -120,6 +120,67 @@ CallAsm.SITES = { ["04:66d1"] = "TryReceiveItem", } +-- pokegold-symbols/pokesilver.sym: bank $03 sits two bytes earlier in Silver. +CallAsm.SITES_SILVER = { + ["03:4749"] = "GetPartyNickname", + ["03:4853"] = "CutDownTreeOrGrass", + ["03:4b47"] = "CheckContinueWaterfall", + ["03:4d13"] = "SetStrengthFlag", + ["03:4d79"] = "TryStrengthOW", + ["03:4e1e"] = "DisappearWhirlpool", + ["03:4f7d"] = "HasRockSmash", + ["03:5094"] = "PutTheRodAway", + ["03:506b"] = "Fishing_CheckFacingUp", + ["03:51c5"] = "AskCutScript_CheckMap", +} + +-- pokecrystal-symbols/pokecrystal.sym: Crystal's own addresses for the same sites. +CallAsm.SITES_CRYSTAL = { + ["25:6f76"] = "GiveItemScript_DummyFunction", + ["04:65cd"] = "StartMenu", + ["04:7327"] = "SelectMenu", + ["05:6f5e"] = "OverworldHatchEgg", + ["25:6706"] = "EnableWildEncounters", + ["24:426f"] = "RingTwice_StartCall", + ["24:42eb"] = "HangUp", + ["04:53e5"] = "InitCallReceiveDelay", + ["24:425c"] = "LoadBillScript", + ["24:426a"] = "LoadElmScript", + ["3f:5017"] = "MomTriesToBuySomething_ASMFunction", + ["04:6599"] = "ItemfinderSound", + ["14:46ef"] = "SweetScentEncounter", + ["02:431e"] = "TrainerWalkToPlayer", + ["14:4753"] = "CheckCanUseSquirtbottle", + ["04:764f"] = "SetMemEvent", + ["14:4658"] = "PlayPoisonSFX", + ["14:467b"] = "CheckWhitedOut", + ["04:64fa"] = "OverworldBGMap", + ["04:650a"] = "BattleBGMap", + ["04:6513"] = "HalveMoney", + ["04:6527"] = "GetWhiteoutSpawn", + ["03:4706"] = "GetPartyNickname", + ["03:4810"] = "CutDownTreeOrGrass", + ["23:47e1"] = "BlindingFlash", + ["00:3016"] = "HideSprites", + ["23:4aed"] = "FlyFromAnim", + ["05:54f1"] = "SkipUpdateMapSprites", + ["23:4b33"] = "FlyToAnim", + ["05:4157"] = "LoadWalkingSpritesGFX", + ["03:4b38"] = "CheckContinueWaterfall", + ["03:4d12"] = "SetStrengthFlag", + ["03:4d78"] = "TryStrengthOW", + ["03:4e1d"] = "DisappearWhirlpool", + ["23:480a"] = "ShakeHeadbuttTree", + ["03:4f7c"] = "HasRockSmash", + ["03:5095"] = "PutTheRodAway", + ["03:506c"] = "Fishing_CheckFacingUp", + ["2e:44b3"] = "LoadFishingGFX", + ["03:51ba"] = "AskCutScript_CheckMap", + ["2e:41ea"] = "TreeMonEncounter", + ["2e:4219"] = "RockMonEncounter", + ["04:62f8"] = "TryReceiveItem", +} + -- The three WRAM addresses `memcall` / `memcallasm` / `memjump` take instead of -- a routine. Not sites: the pointer AT the address is written at run time -- (LoadMemScript for the queued script, the phone engine for the other two), @@ -714,7 +775,8 @@ end -- at import time), and the address pair is the fallback that always works. function CallAsm.nameFor(label, bank, addr) if label and CallAsm.ALL[label] then return label end - return CallAsm.SITES[CallAsm.key(bank, addr)] + local key = CallAsm.key(bank, addr) + return CallAsm.SITES[key] or CallAsm.SITES_SILVER[key] or CallAsm.SITES_CRYSTAL[key] end -- Run a routine by name. Returns the byte the asm leaves in wScriptVar, or diff --git a/src/ui/Diploma.lua b/src/ui/Diploma.lua index ddcd6c73..e957e12c 100644 --- a/src/ui/Diploma.lua +++ b/src/ui/Diploma.lua @@ -8,8 +8,13 @@ local Assets = require("src.render.Assets") local Font = require("src.render.Font") local PaletteFX = require("src.render.PaletteFX") local Sprites = require("src.pokemon.Sprites") +local SpriteRenderer = require("src.render.SpriteRenderer") local Strings = require("src.core.Strings") +-- engine/events/diploma.asm:65 +local OBP0_90 = { { 255, 255, 255 }, { 255, 255, 255 }, + { 170, 170, 170 }, { 85, 85, 85 } } + local Diploma = {} Diploma.__index = Diploma Diploma.isOpaque = true @@ -49,20 +54,18 @@ local function drawFrameBox(frame, tx, ty, tw, th) local img = frame.img local q = frame.quads love.graphics.setColor(1, 1, 1, 1) - -- corners - love.graphics.draw(img, q[0], tx * 8, ty * 8) - love.graphics.draw(img, q[2], (tx + tw - 1) * 8, ty * 8) + -- engine/link/cable_club.asm:937 + love.graphics.draw(img, q[2], tx * 8, ty * 8) + love.graphics.draw(img, q[4], (tx + tw - 1) * 8, ty * 8) love.graphics.draw(img, q[6], tx * 8, (ty + th - 1) * 8) - love.graphics.draw(img, q[8], (tx + tw - 1) * 8, (ty + th - 1) * 8) - -- horizontal edges + love.graphics.draw(img, q[7], (tx + tw - 1) * 8, (ty + th - 1) * 8) for x = 1, tw - 2 do - love.graphics.draw(img, q[1], (tx + x) * 8, ty * 8) - love.graphics.draw(img, q[7], (tx + x) * 8, (ty + th - 1) * 8) + love.graphics.draw(img, q[3], (tx + x) * 8, ty * 8) + love.graphics.draw(img, q[0], (tx + x) * 8, (ty + th - 1) * 8) end - -- vertical edges for y = 1, th - 2 do - love.graphics.draw(img, q[3], tx * 8, (ty + y) * 8) - love.graphics.draw(img, q[5], (tx + tw - 1) * 8, (ty + y) * 8) + love.graphics.draw(img, q[5], tx * 8, (ty + y) * 8) + love.graphics.draw(img, q[1], (tx + tw - 1) * 8, (ty + y) * 8) end end @@ -91,13 +94,31 @@ function Diploma.render(game) drawFrameBox(frame, 0, 0, 20, 18) -- 2. Draw Player character sprite: farcall DrawPlayerCharacter - -- Shifted +33 px right from title screen base (82 + 33 = 115, y = 80) - local picPath, picTrueColor = Sprites.playerPath( - game.data, "front", { kind = "diploma" }) - local pic = tryImage(picPath) + -- engine/movie/title.asm:321 + local title = (game.data and game.data.field and game.data.field.title) or {} + local titlePlayer = title.player + if type(titlePlayer) == "table" then titlePlayer = titlePlayer.path end + local picPath, picTrueColor = Sprites.playerPic( + titlePlayer or "assets/generated/title/player.png", + { side = "front", kind = "diploma", data = game.data }) + local pic + if picPath then + if picTrueColor then + pic = tryImage(picPath) + else + local ok, faded = pcall(SpriteRenderer.obpImage, picPath, OBP0_90, + "diploma") + pic = (ok and faded) or tryImage(picPath) + end + end if pic then love.graphics.setColor(1, 1, 1, 1) + local sx, sy, sw, sh = love.graphics.getScissor() + -- engine/events/diploma.asm:44 + love.graphics.setScissor(8, 8, 144, 128) love.graphics.draw(pic, 115, 80) + if sx then love.graphics.setScissor(sx, sy, sw, sh) + else love.graphics.setScissor() end if picTrueColor then PaletteFX.markTrueColor(115, 80, pic:getDimensions()) end diff --git a/src/ui/EvolutionState.lua b/src/ui/EvolutionState.lua index 5f4fc75f..1ca02256 100644 --- a/src/ui/EvolutionState.lua +++ b/src/ui/EvolutionState.lua @@ -95,11 +95,28 @@ function EvolutionState.new(game, mon, newSpecies, onDone, via) self.t = 0 self.done = false self.canceled = false - Music.play(game.data, Music.special(game.data, "evolution")) + -- engine/movie/evolution.asm:41-46 + Music.stop() + self.crySrc = require("src.core.Sound").playCry(game.data, mon.species) + self.cryT = 0 + self.cryWait = self.crySrc ~= nil + if not self.cryWait then + Music.play(game.data, Music.special(game.data, "evolution")) + end return self end function EvolutionState:update(dt) + if self.cryWait then + self.cryT = self.cryT + 1 + local src = self.crySrc + local playing = src and src.isPlaying and src:isPlaying() + if self.cryT >= 3 and (not playing or self.cryT > 180) then + self.cryWait, self.crySrc = false, nil + Music.play(self.game.data, Music.special(self.game.data, "evolution")) + end + return + end self.t = self.t + 1 if self.done then return end local game = self.game diff --git a/src/ui/OptionsMenu.lua b/src/ui/OptionsMenu.lua index 366cfb86..7e2fdc3d 100644 --- a/src/ui/OptionsMenu.lua +++ b/src/ui/OptionsMenu.lua @@ -157,6 +157,11 @@ end -- new SHADER FX 2 row below it -- when both are set, ShaderFX.render() runs -- main's chain into secondary's, same as stacking two RetroArch presets. +local function bgLocked(o) + return o.battleLayout == "wide" and o.battleFit == "fill" + and o.battleHud == "extended" +end + -- the vanilla rows as descriptors; each step body is the old per-index -- ladder's, so the save.options mutations are unchanged local function buildRows(game) @@ -197,8 +202,6 @@ local function buildRows(game) o.battleLayout = o.battleLayout == "wide" and "og" or "wide" if o.battleLayout ~= "wide" then o.battleHud = "standard" - elseif o.battleFit == "fill" and o.battleHud == "extended" then - o.battleBg = "white" end return true end }, @@ -215,10 +218,6 @@ local function buildRows(game) step = function(g) local o = g.save.options o.battleFit = o.battleFit == "fill" and "fixed" or "fill" - if o.battleFit == "fill" and o.battleLayout == "wide" - and o.battleHud == "extended" then - o.battleBg = "white" - end return true end }, { id = "battleHud", label = Strings("BATTLE HUD"), @@ -237,9 +236,6 @@ local function buildRows(game) return false end o.battleHud = o.battleHud == "extended" and "standard" or "extended" - if o.battleHud == "extended" and o.battleFit == "fill" then - o.battleBg = "white" - end return true end }, -- What sits behind and around the battle. WHITE is the classic paper @@ -249,11 +245,7 @@ local function buildRows(game) { id = "battleBg", label = Strings("BATTLE BG"), value = function(g) local o = g.save.options - if o.battleLayout == "wide" and o.battleFit == "fill" - and o.battleHud == "extended" then - o.battleBg = "white" - return Strings("AUTO") - end + if bgLocked(o) then return Strings("AUTO (FILL HUD)") end local m = o.battleBg if m == "black" then return Strings("BLACK") end if m == "world" then return Strings("WORLD") end @@ -261,11 +253,7 @@ local function buildRows(game) end, step = function(g, dir) local o = g.save.options - if o.battleLayout == "wide" and o.battleFit == "fill" - and o.battleHud == "extended" then - o.battleBg = "white" - return false - end + if bgLocked(o) then return false end local order = { "white", "black", "world" } local cur = 1 for i, m in ipairs(order) do if o.battleBg == m then cur = i break end end diff --git a/src/ui/PlayerPC.lua b/src/ui/PlayerPC.lua index bc951f98..207d4aaf 100644 --- a/src/ui/PlayerPC.lua +++ b/src/ui/PlayerPC.lua @@ -19,17 +19,22 @@ local function itemName(game, id) return def and def.name or id end -local function buildItems(game, store) +local function buildItems(game, store, order) local items = {} - local ids = {} - for id in pairs(store) do table.insert(ids, id) end - table.sort(ids) + local ids = order + if not ids then + ids = {} + for id in pairs(store) do table.insert(ids, id) end + table.sort(ids) + end for _, id in ipairs(ids) do - table.insert(items, { - value = id, - label = itemName(game, id), - right = "x" .. store[id], - }) + if store[id] then + table.insert(items, { + value = id, + label = itemName(game, id), + right = "x" .. store[id], + }) + end end return items end @@ -105,12 +110,9 @@ local function deposit(game) local pc = game.save.pcItems local inv = game.save.inventory local Bag = require("src.inventory.Bag") - -- badges live in save.inventory alongside items but are not depositable - local depositable = {} - for id, count in pairs(inv) do - if not Bag.isBadge(id) then depositable[id] = count end - end - game.stack:push(ListMenu.new(game, "DEPOSIT ITEM", buildItems(game, depositable), { + -- engine/menus/players_pc.asm:99 wListPointer = wNumBagItems, so deposit order == bag order + local order = Bag.order(game.save, game.data) + game.stack:push(ListMenu.new(game, "DEPOSIT ITEM", buildItems(game, inv, order), { kind = "pc_item_deposit", messageBox = true, noSound = true, -- PlayerPCMenu holds BIT_NO_MENU_BUTTON_SOUND (#570) diff --git a/src/ui/SkinStudio.lua b/src/ui/SkinStudio.lua index d7a111d8..4787f13c 100644 --- a/src/ui/SkinStudio.lua +++ b/src/ui/SkinStudio.lua @@ -687,8 +687,10 @@ function Studio.deleteEntry(id) local opts = SaveData.loadOptions() local tc = type(opts.touchControls) == "table" and opts.touchControls or {} if tc.skin == id then - tc.enabled, tc.skin = false, nil + tc.enabled, tc.skin = true, nil + opts.touchControls = tc SaveData.saveOptions(opts) + TouchControls:applyOptions(opts) end Studio.refreshAvailable() setStatus("Deleted " .. id) diff --git a/src/ui/TownMap.lua b/src/ui/TownMap.lua index 323958ee..38157f41 100644 --- a/src/ui/TownMap.lua +++ b/src/ui/TownMap.lua @@ -334,10 +334,9 @@ function TownMap:update(dt) end end --- OG RED bakes the boot-ROM OBJ palette in, so the marker has to be replayed --- over the screen-wide TOWNMAP zone pass the way every other OBJ is (#301) +-- OG RED and ADVANCED bake an OBJ palette in, so the marker replays over the TOWNMAP zone pass (#301) function TownMap:markPlayerRedraw(x, y) - if not PaletteFX.usesSpriteObp() then return end + if not (PaletteFX.usesSpriteObp() or PaletteFX.usesGbcPack()) then return end PaletteFX.markUiSpriteRedraw(self.playerSheet, self.playerQuad, x, y) end @@ -402,11 +401,7 @@ function TownMap:draw() love.graphics.setColor(1, 1, 1, 1) end end - -- blinking cursor on the selected location. markerXY is the 8x8 cell's - -- top-left; the cursor asset is a 16x16 hollow frame centered on its own - -- (8,8), so draw it -4,-4 to enclose the cell (engine/menus/town_map.asm - -- draws the box cursor CENTERED on the selected location). Drawing it at - -- the cell top-left put the square in the frame's top-left quadrant (#152). + -- WriteTownMapSpriteOAM carry quirk: -4 X, -3 Y for cursor and player alike -- engine/items/town_map.asm:454 local showCursor = true if GameVersion.generation() == 1 then showCursor = self.blink < 25 @@ -416,7 +411,7 @@ function TownMap:draw() if selected and showCursor then local x, y = markerXY(selected) if self.bg.cursor then - love.graphics.draw(self.bg.cursor, x - 4, y - 4) + love.graphics.draw(self.bg.cursor, x - 4, y - 3) else love.graphics.setColor(0, 0, 0, 1) love.graphics.rectangle("line", x + 0.5, y + 0.5, 7, 7) diff --git a/src/ui/gen2/BattleState.lua b/src/ui/gen2/BattleState.lua index 64eed26f..cabda30f 100644 --- a/src/ui/gen2/BattleState.lua +++ b/src/ui/gen2/BattleState.lua @@ -826,7 +826,10 @@ function BattleState:drawPic(mon, back) if trainerBack then -- PAL_BATTLE_OB_PLAYER: the player's own colours, which are row 0 of -- TrainerPalettes (Chris shares Cal's). - colors = Palettes.trainerColors(self.palettes, "PLAYER") or colors + -- engine/gfx/color.asm:683-696 + local row = Gen2Save.isFemale(self.save) and "FALKNER" or "PLAYER" + colors = Palettes.trainerColors(self.palettes, row) + or Palettes.trainerColors(self.palettes, "PLAYER") or colors elseif enemyTrainer then -- The opponent's class row out of the same TrainerPalettes table. colors = Palettes.trainerColors(self.palettes, self.enemyTrainerClass) @@ -1258,7 +1261,15 @@ function BattleState:afterAnimFor(side) return "ANIM_PLAYER_DAMAGE" end -function BattleState:animForMove(moveId, side, param) +-- engine/battle_anims/anim_commands.asm:1200 PlayHitSound +function BattleState:playHitSound(effectiveness) + if not effectiveness or effectiveness == 0 then return end + if effectiveness > 10 then self:playSfx("Sfx_SuperEffective") + elseif effectiveness < 10 then self:playSfx("Sfx_NotVeryEffective") + else self:playSfx("Sfx_Damage") end +end + +function BattleState:animForMove(moveId, side, param, effectiveness) local key = self.anims and self.anims.moves and self.anims.moves[moveId] local started = self:startAnim(key, { turn = self:turnFor(side), animId = moveId, isMove = true, param = param, @@ -1267,7 +1278,8 @@ function BattleState:animForMove(moveId, side, param) -- BattleAnimRunScript (anim_commands.asm:55-72): after the move script -- restores HUDs it immediately runs wBattleAfterAnim (the hit shake). -- Queue it so stepAnim chains without waiting on the next event. - self.pendingAfterAnim = { name = self:afterAnimFor(side), side = side } + self.pendingAfterAnim = { name = self:afterAnimFor(side), side = side, + effectiveness = effectiveness } end return started end @@ -1278,6 +1290,7 @@ function BattleState:startPendingAfterAnim() if not pending then return false end self.pendingAfterAnim = nil if self:animForId(pending.name, pending.side) then + self:playHitSound(pending.effectiveness) -- dealDamage's default ANIM_x_DAMAGE is this same shake; skip it there. self.afterAnimPlayed = true return true @@ -1575,6 +1588,11 @@ function BattleState:advanceQueue() self:showPages(text) return end + -- data/text/battle.asm:184 + if event.kind == "money" then + self:showPages(event.text or "") + return + end -- The shiny sparkle: hBattleTurn 1 and wBattleAnimParam 1 pick -- BattleAnim_SendOutMon's `.Shiny` arm on the enemy (core.asm:8708-8715). if event.kind == "shiny-flash" then @@ -1692,12 +1710,14 @@ function BattleState:advanceQueue() if event.kind == "move" and not event.missed then self.afterAnimPlayed = nil self.pendingAfterAnim = nil - if not self:animForMove(event.move, event.side, event.animParam) then + if not self:animForMove(event.move, event.side, event.animParam, + event.effectiveness) then -- BATTLE SCENE off skips the move script but still runs wBattleAfterAnim -- (anim_commands.asm:55-72 .disabled fallthrough). local options = self.game and self.game.options if options and options.battleScene == false then if self:animForId(self:afterAnimFor(event.side), event.side) then + self:playHitSound(event.effectiveness) self.afterAnimPlayed = true end end @@ -1725,6 +1745,7 @@ function BattleState:advanceQueue() and (hit == "ANIM_ENEMY_DAMAGE" or hit == "ANIM_PLAYER_DAMAGE") then self.afterAnimPlayed = nil else + self:playHitSound(event.effectiveness) self:animForId(hit, from) end end diff --git a/src/ui/gen2/ItemPcMenu.lua b/src/ui/gen2/ItemPcMenu.lua index 10c5ab1b..0bdfaec8 100644 --- a/src/ui/gen2/ItemPcMenu.lua +++ b/src/ui/gen2/ItemPcMenu.lua @@ -41,6 +41,8 @@ ItemPcMenu.isOpaque = true local PC_ITEM_CAPACITY = 50 local MAX_STACK = 99 +local function stacksFor(n) return math.ceil((n or 0) / MAX_STACK) end + -- PlayersPCMenuData .PlayersPCMenuPointers strings, verbatim. .WhichPC picks -- which rows a caller sees: PLAYERSPC_NORMAL ends on LOG OFF, PLAYERSPC_HOUSE -- carries DECORATION and ends on TURN OFF. @@ -167,18 +169,24 @@ function ItemPcMenu:rebuild() for id, count in pairs(pc) do if (count or 0) > 0 then local def = self:def(id) - rows[#rows + 1] = { - id = id, count = count, - name = (def and def.name) or id, - index = def and def.index or math.huge, - } + local remaining = count + while remaining > 0 do + local n = math.min(remaining, MAX_STACK) + rows[#rows + 1] = { + id = id, count = n, + name = (def and def.name) or id, + index = def and def.index or math.huge, + } + remaining = remaining - n + end end end -- wPCItems keeps acquisition order; without that recorded, item id order is -- the stable choice, the same sort the PACK uses. table.sort(rows, function(a, b) if a.index ~= b.index then return a.index < b.index end - return a.id < b.id + if a.id ~= b.id then return a.id < b.id end + return a.count > b.count end) self.rows = rows if self.listIndex > #rows + 1 then self.listIndex = #rows + 1 end @@ -200,20 +208,18 @@ function ItemPcMenu:ensureVisible() math.max(0, self:listTotal() - VISIBLE_ROWS))) end --- ReceiveItem over wPCItems: a new id needs one of the fifty stacks, a grown --- one may not pass 99. False is the no-carry the deposit turns into +-- ReceiveItem over wPCItems: the add tops up every existing stack of that id +-- and spills the rest into a new one, so it only needs a free stack when the +-- room in place is short. False is the no-carry the deposit turns into -- _PlayersPCNoRoomDepositText. +-- engine/items/items.asm:156 PutItemInPocket function ItemPcMenu:pcAdd(id, qty) local pc = self.save.pcItems local held = pc[id] or 0 - if held == 0 then - local stacks = 0 - for _, count in pairs(pc) do - if (count or 0) > 0 then stacks = stacks + 1 end - end - if stacks >= PC_ITEM_CAPACITY then return false end - end - if held + qty > MAX_STACK then return false end + local used = 0 + for _, count in pairs(pc) do used = used + stacksFor(count) end + local need = stacksFor(held + qty) - stacksFor(held) + if used + need > PC_ITEM_CAPACITY then return false end pc[id] = held + qty return true end diff --git a/src/ui/gen2/TitleState.lua b/src/ui/gen2/TitleState.lua index 23d33dc5..1dfdbe75 100644 --- a/src/ui/gen2/TitleState.lua +++ b/src/ui/gen2/TitleState.lua @@ -156,7 +156,9 @@ function TitleState.new(game, opts) self.entranceHideBelow = entrance and tonumber(entrance.hideBelow) or nil self.gemY = entrance and (tonumber(title.gemFromY) or -50) or self.gemRestY self.entranceSfx = title.entranceSfx + -- engine/menus/intro_menu.asm:951-966 self.timeoutFrames = tonumber(title.timeoutFrames) + or ((self.trailMode == "silver") and (73 * 60 + 36) or (84 * 60 + 16)) self.onTimeout = opts.onTimeout -- The copyright window line is pal 7 (engine/movie/title.asm:40-43); its -- colour 0 backs the whole band. @@ -295,12 +297,17 @@ function TitleState:update(_dt) return end - -- TitleScreenTimer / TitleScreenMain's run-down back into the attract - -- loop (engine/menus/intro_menu.asm:1125-1236). - if self.timeoutFrames and self.onTimeout - and self.frameCounter - (self.timeoutStart or 0) >= self.timeoutFrames then - self.onTimeout() - return + -- engine/menus/intro_menu.asm:1023-1059 + if self.timeoutFrames and self.onTimeout then + if self.fadeStart then + if self.frameCounter - self.fadeStart >= 60 then self.onTimeout() end + return + end + if self.frameCounter - (self.timeoutStart or 0) >= self.timeoutFrames then + self.fadeStart = self.frameCounter + Music.fadeOut(8) + return + end end local input = self.game.input diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 3c503fc9..4554dc5d 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -1232,6 +1232,10 @@ function OverworldState:update(dt) self.player.inputLocked = false end end + if self.spinArrive and not self.player.spinFrames then + self.spinArrive = nil + self.player.inputLocked = false + end -- EnterMapAnim's .done tail re-enables the companion once the swoop or the -- spin-down has landed (player_animations.asm:40) @@ -1311,7 +1315,7 @@ function OverworldState:update(dt) local scripted = self.runner:isRunning() or #self.scriptMoves > 0 or (self.hopLand or 0) > 0 or self.engaging or self.emote or self.teleportOut - or self.flyAnim or self.flyArrive + or self.flyAnim or self.flyArrive or self.spinArrive if not scripted and not self.transitioning then self:checkTrainerSight() -- CheckFightingMapTrainers (home/trainers.asm) zeroes hJoyHeld and @@ -1321,7 +1325,7 @@ function OverworldState:update(dt) scripted = self.runner:isRunning() or #self.scriptMoves > 0 or (self.hopLand or 0) > 0 or self.engaging or self.emote or self.teleportOut - or self.flyAnim or self.flyArrive + or self.flyAnim or self.flyArrive or self.spinArrive end -- a scriptMove's onDone can push a text box on the frame it retires, and -- DisplayTextID owns the loop from there (home/text_script.asm:3) @@ -4802,6 +4806,10 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts) self.doorWarp = nil local arriveWarp = self.arriveWarp self.arriveWarp = nil + if self.spinArrive then + self.spinArrive = nil + self.player.inputLocked = false + end -- PlayMapChangeSound (home/overworld.asm) plays before the tail-called -- GBFadeOutToBlack, so the SFX starts with the fade (#961) if doorWarp then @@ -4849,6 +4857,9 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts) self.player.spinFrames = 48 self.player.spinTotal = 48 self.player.spinDrop = true + -- engine/overworld/player_animations.asm:19 + self.spinArrive = true + self.player.inputLocked = true end if doorWarp then -- PlayerStepOutFromDoor (engine/overworld/auto_movement.asm): any @@ -5329,8 +5340,9 @@ function OverworldState:drawWorld() -- cry with no bubble still pauses the world for its beat) if self.emote.bubble == false then return end local npc = self.emote.npc - local ex = npc.px - cam.x + 4 - local ey = npc.py - cam.y - 14 + -- engine/overworld/emotion_bubbles.asm:41 + local ex = npc.px - cam.x + local ey = npc.py - cam.y - 20 local bubble = Game.data.field.emotionBubbles local drawn = false if bubble and bubble.path then diff --git a/src/world/gen2/World.lua b/src/world/gen2/World.lua index c6c9a58f..ce3b82ec 100644 --- a/src/world/gen2/World.lua +++ b/src/world/gen2/World.lua @@ -551,6 +551,7 @@ function World.new(game) lastSfx = nil, pokePic = nil, pendingSceneScript = false, + startedOverworld = false, -- GBC color state (engine/gfx/color.asm). `daytime` is the resolved -- MORN/DAY/NITE/DARK the map is currently lit by; clockHour overrides -- World:hour for drivers and tests, so the palette, the hour windows and @@ -9045,10 +9046,9 @@ function World:setMap(mapId, cx, cy, facing, opts) if not opts.seamless then self.pendingSceneScript = true end - -- StartMap (engine/overworld/events.asm): `farcall InitCallReceiveDelay` on - -- every map entry, connections included -- which is why a player who keeps - -- warping is never rung (src/core/gen2/Phone.lua's receive timer). - if self.game and self.game.save then + -- engine/overworld/events.asm:98 + if not self.startedOverworld and self.game and self.game.save then + self.startedOverworld = true require("src.core.gen2.Phone").onMapLoad(self.game.save, self:stepContext().phone) end diff --git a/tests/drivers/fill_extended_auto_menu_test.lua b/tests/drivers/fill_extended_auto_menu_test.lua index 94311bab..c9745fc1 100644 --- a/tests/drivers/fill_extended_auto_menu_test.lua +++ b/tests/drivers/fill_extended_auto_menu_test.lua @@ -23,10 +23,10 @@ return function(game) fitRow.step(game, 1) assert(options.battleFit == "fill", "battle size switched to FILL") - assert(options.battleBg == "white", "FILL + EXTENDED normalized background to WHITE") - assert(bgRow.value(game) == "AUTO", "adaptive background is labeled AUTO") + assert(options.battleBg == "black", "FILL + EXTENDED preserves the stored background") + assert(bgRow.value(game) == "AUTO (FILL HUD)", "adaptive background is labeled AUTO") assert(bgRow.step(game, 1) == false, "AUTO background row is locked") - assert(options.battleBg == "white", "locked AUTO retains the WHITE value") + assert(options.battleBg == "black", "locked AUTO does not overwrite the stored value") menu.index = bgIndex menu.scroll = math.max(0, bgIndex - 5) @@ -37,11 +37,11 @@ return function(game) fitRow.step(game, -1) assert(options.battleFit == "fixed", "battle size switched back to FIXED") - assert(bgRow.value(game) == "WHITE", "FIXED exposes the stored WHITE choice") - assert(bgRow.step(game, 1) == true and options.battleBg == "black", - "FIXED can select BLACK") + assert(bgRow.value(game) == "BLACK", "FIXED exposes the stored BLACK choice") assert(bgRow.step(game, 1) == true and options.battleBg == "world", "FIXED can select WORLD") + assert(bgRow.step(game, 1) == true and options.battleBg == "white", + "FIXED can select WHITE") U.wait(2) local fixedPath = DIR .. "/fixed_extended_background_choices.png" os.remove(fixedPath) diff --git a/tests/engine/exp_traded_ot_survives_reload_bug1265.lua b/tests/engine/exp_traded_ot_survives_reload_bug1265.lua index 8cdd3917..73436f5c 100644 --- a/tests/engine/exp_traded_ot_survives_reload_bug1265.lua +++ b/tests/engine/exp_traded_ot_survives_reload_bug1265.lua @@ -48,7 +48,7 @@ end -- #1461: saves that passed through 0.1.82-0.1.9x already have the player's -- own id written onto traded mons, so stamping correctly from now on does --- not help them. The repair runs on every load, not behind a format gate. +-- not help them. The repair is a one-shot pre-format-5 migration. do local SaveData = require("src.core.SaveData") local save = newSave() diff --git a/tests/parity_trade_gift.lua b/tests/parity_trade_gift.lua index 6ed91552..5fb84893 100644 --- a/tests/parity_trade_gift.lua +++ b/tests/parity_trade_gift.lua @@ -229,16 +229,16 @@ check(runScript("VERMILION_TRADE_HOUSE", "TEXT_VERMILIONTRADEHOUSE_LITTLE_GIRL") "DUX post-trade script completes") shownIs({ "_AfterTrade3Text" }, "DUX uses the happy dialogset") --- === 9) Celadon Eevee: no confirm prompt, AskName then GotMonText, --- ball hidden (GivePokemon -> AddPartyMon AskName; script still --- prints GotMonText after the silent give_pokemon row) === +-- === 9) Celadon Eevee: no confirm prompt, GotMonText then AskName, +-- ball hidden (GivePokemon -> SetPokedexOwnedFlag prints GotMonText +-- before AddPartyMon's AskName) === local EEVEE_MAP, EEVEE_BALL = "CELADON_MANSION_ROOF_HOUSE", "CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL" local EEVEE_TEXT = "TEXT_CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL" Game.save = SaveData.newGame() check(runScript(EEVEE_MAP, EEVEE_TEXT), "Eevee ball script completes") -shownIs({ "_DoYouWantToNicknameText", "_GotMonText" }, - "Eevee gives immediately (nickname ask, then GotMonText)") +shownIs({ "_GotMonText", "_DoYouWantToNicknameText" }, + "Eevee gives immediately (GotMonText, then nickname ask)") eq(#Game.save.party, 1, "Eevee joins the party") eq(Game.save.party[1].species, "EEVEE", "gift species is EEVEE") eq(Game.save.party[1].level, 25, "Eevee is level 25") @@ -261,13 +261,13 @@ shownIs({}, "old save: silent") eq(#Game.save.party, 0, "old save: no Eevee re-gift") eq(toggleOf(EEVEE_MAP, EEVEE_BALL), false, "old save: leftover ball hidden") --- === 11) GivePokemon party full, box has room: AskName + SentToBoxText --- (SendNewMonToBox), then script GotMonText; ball hidden === +-- === 11) GivePokemon party full, box has room: GotMonText, then AskName +-- + SentToBoxText (SendNewMonToBox); ball hidden === Game.save = SaveData.newGame() for i = 1, 6 do Game.save.party[i] = Pokemon.new(Data, "PIDGEY", 5) end check(runScript(EEVEE_MAP, EEVEE_TEXT), "full-party Eevee script completes") -shownIs({ "_DoYouWantToNicknameText", "_SentToBoxText", "_GotMonText" }, - "full-party gift: nickname, sent-to-box, then GotMonText") +shownIs({ "_GotMonText", "_DoYouWantToNicknameText", "_SentToBoxText" }, + "full-party gift: GotMonText, nickname, then sent-to-box") eq(#Game.save.party, 6, "full-party gift leaves party size at 6") local Boxes = require("src.pokemon.Boxes") local boxed = false diff --git a/tests/pet_cries_test.lua b/tests/pet_cries_test.lua index 89f07aaa..ee53b278 100644 --- a/tests/pet_cries_test.lua +++ b/tests/pet_cries_test.lua @@ -20,7 +20,8 @@ local yellow = GameVersion.isYellow() -- pokered/scripts/*.asm, PlayCry line in brackets: -- SSAnne1FRooms.asm:66 [:70], SSAnneB1FRooms.asm:82 [:86], -- VermilionPidgeyHouse.asm:15 [:19], VermilionCity.asm:224 [:228], --- PokemonFanClub.asm:71 [:76] and :84 [:89]. +-- PokemonFanClub.asm:71 [:76] and :84 [:89], +-- MrFujisHouse.asm:56 [:60] and :63 [:67], LavenderCuboneHouse.asm:10 [:14]. local PETS = { { map = "SS_ANNE_1F_ROOMS", const = "TEXT_SSANNE1FROOMS_WIGGLYTUFF", object = "SSANNE1FROOMS_WIGGLYTUFF", species = "WIGGLYTUFF", @@ -40,6 +41,15 @@ local PETS = { { map = "POKEMON_FAN_CLUB", const = "TEXT_POKEMONFANCLUB_SEEL", object = "POKEMONFANCLUB_SEEL", species = "SEEL", label = "_PokemonFanClubSeelText" }, + { map = "MR_FUJIS_HOUSE", const = "TEXT_MRFUJISHOUSE_PSYDUCK", + object = "MRFUJISHOUSE_PSYDUCK", species = "PSYDUCK", + label = "_MrFujisHousePsyduckText" }, + { map = "MR_FUJIS_HOUSE", const = "TEXT_MRFUJISHOUSE_NIDORINO", + object = "MRFUJISHOUSE_NIDORINO", species = "NIDORINO", + label = "_MrFujisHouseNidorinoText" }, + { map = "LAVENDER_CUBONE_HOUSE", const = "TEXT_LAVENDERCUBONEHOUSE_CUBONE", + object = "LAVENDERCUBONEHOUSE_CUBONE", species = "CUBONE", + label = "_LavenderCuboneHouseCuboneText" }, } if yellow then From d622e83a8e48b73ba82cf9b923ec7419a6527d65 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Tue, 25 Aug 2026 09:16:59 -0400 Subject: [PATCH 14/16] shaderfix --- docs/shaderfx.md | 51 +++++++- mobile/ANDROID.md | 5 + mobile/ios/README.md | 1 + scripts/build_android.sh | 110 ++++++++++++++++ scripts/build_ios.sh | 152 ++++++++++++++++++++++- src/core/Sensors.lua | 9 +- src/import/LauncherView.lua | 25 ++-- src/import/RomImporter.lua | 1 + src/render/ShaderFX.lua | 20 ++- tests/engine/shaderfx_ffi_c_fallback.lua | 85 +++++++++++++ tools/shaderfx-bridge/Cargo.toml | 2 +- 11 files changed, 445 insertions(+), 16 deletions(-) create mode 100644 tests/engine/shaderfx_ffi_c_fallback.lua diff --git a/docs/shaderfx.md b/docs/shaderfx.md index 03e34c5b..3fe60c70 100644 --- a/docs/shaderfx.md +++ b/docs/shaderfx.md @@ -220,6 +220,54 @@ output directory; nothing packages it next to a shipped game yet. and `ShaderFX.bridgeError()` says why not. Activating an already-converted preset never needs any of this. +**Statically linked bridges.** On iOS there is no loadable library at all: +the bridge is linked straight into the app binary, so its symbols live in the +main image and are reachable only through `ffi.C`. The candidate list is empty +on iOS and, on every platform, a failed candidate walk falls back to probing +`ffi.C` for `librashader_translate_preset` after the `ffi.cdef`. If the symbol +is there, `ffi.C` becomes the library handle and `ShaderFX.translate` uses it +unchanged. If it is not, `ShaderFX.bridgeError()` reports that `ffi.C` was +probed as well, followed by the file paths that were tried (omitted on iOS, +where none are). + +**Android ships the bridge inside the APK.** `scripts/build_android.sh` +stages `liblibrashader_bridge.so` into +`mobile/android/app/src/main/jniLibs//` for the two ABIs the APK ships, +arm64-v8a and armeabi-v7a, so `ffi.load("liblibrashader_bridge.so")` finds it +by bare name in the app's native library directory. The build cross-compiles +the crate with `cargo ndk` against the same NDK the gradle project uses +(25.2.9519653), which needs `cargo install cargo-ndk` and +`rustup target add aarch64-linux-android armv7-linux-androideabi`; the script +names the exact command for any target that is missing. Set +`SHADERFX_BRIDGE_ANDROID_DIR` to a directory holding +`/liblibrashader_bridge.so` to bundle prebuilt libraries instead. As on +desktop, the bridge is only needed to CONVERT a preset: a build without it +still runs presets converted elsewhere, and the packager warns and continues +rather than failing. + +**iOS links the bridge instead of loading it.** An iOS app cannot `dlopen` a +dylib shipped beside its binary, so `tools/shaderfx-bridge` also builds as a +`staticlib` and `scripts/build_ios.sh` links it into the app executable +(`bundle_shader_bridge_ios`, mirroring `bundle_shader_bridge` in +`scripts/build.sh`). `SHADERFX_BRIDGE_IOS` points at a prebuilt archive; +otherwise cargo builds `aarch64-apple-ios` for device builds and every +installed simulator target (`aarch64-apple-ios-sim`, `x86_64-apple-ios`) for +the Simulator, with `IPHONEOS_DEPLOYMENT_TARGET=15.0`, and `ARCHS` is pinned to +what got built. The archive is never linked directly: LÖVE 12 carries its own +glslang for shader validation and the crate drags in a second, incompatible +one, and linking both crashed inside `Shader::validateInternal` at startup. +Each slice is therefore prelinked with `ld -r -all_load +-exported_symbols_list` so only `_librashader_translate_preset` and +`_librashader_free_string` stay external and every other symbol (glslang, +spirv-cross, Rust std) becomes private to the object, then `rust-objcopy` +drops the `__LLVM` bitcode sections rustup's prebuilt std carries, since +Apple's `nm` cannot read them. `OTHER_LDFLAGS` adds `-Wl,-u` on both entry +points to keep them past dead-stripping and `-lc++` for the C++ the crate +needs; no Objective-C framework is involved. A build that linked the object +fails if `nm` cannot find `_librashader_translate_preset` in the finished +binary; a build that could not produce one only warns and lands where the +desktop packages without cargo land: converted presets run, CONVERT does not. + ### Fixup `ShaderFixup.lua` mechanically rewrites the emitted GLSL into something LOVE @@ -656,7 +704,8 @@ None of these are theoretical. `bundle_shader_bridge`, building it with cargo when a prebuilt one is not supplied through `SHADERFX_BRIDGE`. A build host without cargo produces a package that can run converted presets but cannot CONVERT new ones, and says - so rather than failing. Android ships the `.so` via `jniLibs`. + so rather than failing. `scripts/build_android.sh` and `scripts/build_ios.sh` + follow the same rule with `cargo ndk` and the iOS static archive. - **The buildbot shortlist is a temporary trim.** `KEPT_PRESETS` reflects one manual pass over `handheld/` and is expected to change, most likely to shrink. - **Tilt direction is unverified.** Which way forward and back rocking moves the diff --git a/mobile/ANDROID.md b/mobile/ANDROID.md index 8718896b..0b29e2db 100644 --- a/mobile/ANDROID.md +++ b/mobile/ANDROID.md @@ -100,6 +100,11 @@ love-android 11.5a expects: Set `ANDROID_SDK_ROOT` (or `ANDROID_HOME`), or let the script write `local.properties` when it finds `~/Library/Android/sdk`. +**ShaderFX bridge**: `scripts/build_android.sh` bundles +`liblibrashader_bridge.so` for arm64-v8a and armeabi-v7a via `cargo ndk` (or +from `SHADERFX_BRIDGE_ANDROID_DIR`), and warns and continues when neither is +available; see `docs/shaderfx.md`. + Gradle flavor used: **`embedNoRecord`** (game fused into the APK, no microphone). Build task: `assembleEmbedNoRecordDebug`. diff --git a/mobile/ios/README.md b/mobile/ios/README.md index 37f48b4e..3eb908e8 100644 --- a/mobile/ios/README.md +++ b/mobile/ios/README.md @@ -52,6 +52,7 @@ The script verifies the final app before packaging it: - the public Documents plist settings are present - the native picker bridge is present +- the SHADER FX librashader bridge is linked into the app binary, when this build produced one - `game.love` exists and is non-empty If the payload is missing, the build fails instead of producing a blank app. diff --git a/scripts/build_android.sh b/scripts/build_android.sh index 7451e87a..49917a19 100755 --- a/scripts/build_android.sh +++ b/scripts/build_android.sh @@ -461,6 +461,115 @@ pack_game_love() { fi } +# --------------------------------------------------------------- ShaderFX bridge +SHADER_BRIDGE_LIB="liblibrashader_bridge.so" +SHADER_BRIDGE_ABIS="arm64-v8a armeabi-v7a" + +shader_bridge_rust_target() { + case "$1" in + arm64-v8a) printf 'aarch64-linux-android' ;; + armeabi-v7a) printf 'armv7-linux-androideabi' ;; + x86_64) printf 'x86_64-linux-android' ;; + x86) printf 'i686-linux-android' ;; + *) printf '' ;; + esac +} + +shader_bridge_staged_count() { + local jni="$1" abi count=0 + for abi in $SHADER_BRIDGE_ABIS; do + [ -f "$jni/$abi/$SHADER_BRIDGE_LIB" ] && count=$((count + 1)) + done + printf '%s' "$count" +} + +bundle_shader_bridge_android() { + local jni="$ANDROID_DIR/app/src/main/jniLibs" + local crate="$ROOT/tools/shaderfx-bridge" + local abi target + + for abi in $SHADER_BRIDGE_ABIS; do + rm -f "$jni/$abi/$SHADER_BRIDGE_LIB" + done + + local prebuilt="${SHADERFX_BRIDGE_ANDROID_DIR:-}" + if [ -n "$prebuilt" ]; then + for abi in $SHADER_BRIDGE_ABIS; do + if [ -f "$prebuilt/$abi/$SHADER_BRIDGE_LIB" ]; then + mkdir -p "$jni/$abi" + cp "$prebuilt/$abi/$SHADER_BRIDGE_LIB" "$jni/$abi/$SHADER_BRIDGE_LIB" + else + warn "SHADERFX_BRIDGE_ANDROID_DIR has no $abi/$SHADER_BRIDGE_LIB" + fi + done + if [ "$(shader_bridge_staged_count "$jni")" -gt 0 ]; then + say "bundled $SHADER_BRIDGE_LIB for SHADER FX preset conversion (prebuilt)" + return + fi + fi + + if [ ! -f "$crate/Cargo.toml" ]; then + warn "$SHADER_BRIDGE_LIB not found: this build can run converted presets but not CONVERT new ones (tools/shaderfx-bridge is missing)" + return + fi + + if ! command -v cargo >/dev/null 2>&1 || ! cargo ndk --version >/dev/null 2>&1; then + warn "$SHADER_BRIDGE_LIB not found: this build can run converted presets but not CONVERT new ones (set SHADERFX_BRIDGE_ANDROID_DIR or run 'cargo install cargo-ndk')" + return + fi + + local ndk="${ANDROID_NDK_HOME:-}" + if [ -z "$ndk" ] || [ ! -d "$ndk" ]; then + ndk="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-$HOME/Library/Android/sdk}}/ndk/$NDK_VERSION" + fi + if [ ! -d "$ndk" ]; then + warn "$SHADER_BRIDGE_LIB not built: NDK $NDK_VERSION not found (set ANDROID_NDK_HOME)" + return + fi + + local installed missing="" buildable="" + installed="$(rustup target list --installed 2>/dev/null || true)" + for abi in $SHADER_BRIDGE_ABIS; do + target="$(shader_bridge_rust_target "$abi")" + if grep -qx "$target" <<< "$installed"; then + buildable="$buildable $abi" + else + missing="$missing $target" + fi + done + if [ -n "$missing" ]; then + warn "$SHADER_BRIDGE_LIB: skipping$missing. Run: rustup target add$missing" + fi + if [ -z "$buildable" ]; then + warn "$SHADER_BRIDGE_LIB not built: this build can run converted presets but not CONVERT new ones (no Android Rust targets installed)" + return + fi + + local args=() + for abi in $buildable; do + args+=(-t "$abi") + done + + say "building the ShaderFX bridge with cargo-ndk (${buildable# })" + mkdir -p "$jni" + if ! ( + cd "$crate" + export ANDROID_NDK_HOME="$ndk" + export ANDROID_NDK_ROOT="$ndk" + export CARGO_PROFILE_RELEASE_STRIP="symbols" + cargo ndk "${args[@]}" -o "$jni" build --release + ); then + warn "$SHADER_BRIDGE_LIB failed to cross-compile: this build can run converted presets but not CONVERT new ones" + return + fi + + if [ "$(shader_bridge_staged_count "$jni")" -gt 0 ]; then + say "bundled $SHADER_BRIDGE_LIB for SHADER FX preset conversion" + else + warn "$SHADER_BRIDGE_LIB not found after cargo-ndk: this build can run converted presets but not CONVERT new ones" + fi +} + # --------------------------------------------------------------- SDK check require_android_sdk() { local sdk="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-}}" @@ -575,5 +684,6 @@ if $PACKAGE_ONLY; then fi require_android_sdk +bundle_shader_bridge_android run_gradle say "done" diff --git a/scripts/build_ios.sh b/scripts/build_ios.sh index 9c7fac93..55688193 100755 --- a/scripts/build_ios.sh +++ b/scripts/build_ios.sh @@ -615,6 +615,138 @@ verify_game_payload() { say "game.love present ($(du -h "$app/game.love" | cut -f1))" } +SHADER_BRIDGE_LIB="" +SHADER_BRIDGE_NAME="liblibrashader_bridge.a" +SHADER_BRIDGE_OBJ="librashader_bridge.o" + +SHADER_BRIDGE_ARCHS="" + +rust_targets_for_sdk() { + if [ "$1" = "iphoneos" ]; then + printf 'aarch64-apple-ios' + else + printf 'aarch64-apple-ios-sim x86_64-apple-ios' + fi +} + +build_shader_bridge_slice() { + local rust_target="$1" + local crate="$ROOT/tools/shaderfx-bridge" + local built="$crate/target/$rust_target/release/$SHADER_BRIDGE_NAME" + if [ -f "$built" ]; then + printf '%s' "$built" + return 0 + fi + if command -v cargo >/dev/null 2>&1 \ + && rustup target list --installed 2>/dev/null \ + | grep -x "$rust_target" >/dev/null; then + say "building the ShaderFX bridge with cargo ($rust_target)" >&2 + if (cd "$crate" && IPHONEOS_DEPLOYMENT_TARGET=15.0 \ + cargo build --release --target "$rust_target" >/dev/null 2>&1); then + printf '%s' "$built" + return 0 + fi + fi + return 1 +} + +prelink_shader_bridge_slice() { + local archive="$1" sdk="$2" out="$3" + local arch platform sdk_version syms objcopy tmp + arch="$(lipo -archs "$archive" 2>/dev/null | awk '{print $1}')" + [ -n "$arch" ] || return 1 + if [ "$sdk" = "iphoneos" ]; then platform="ios"; else platform="ios-simulator"; fi + sdk_version="$(xcrun --sdk "$sdk" --show-sdk-version 2>/dev/null)" + [ -n "$sdk_version" ] || return 1 + syms="$LIBS_DIR/librashader_bridge.exports" + printf '_librashader_translate_preset\n_librashader_free_string\n' > "$syms" + tmp="$out.tmp" + xcrun ld -r -arch "$arch" -platform_version "$platform" 15.0 "$sdk_version" \ + -all_load -exported_symbols_list "$syms" -o "$tmp" "$archive" || return 1 + objcopy="$(ls "$(rustc --print sysroot 2>/dev/null)"/lib/rustlib/*/bin/rust-objcopy 2>/dev/null | head -1)" + if [ -n "$objcopy" ] && "$objcopy" --remove-section __LLVM,__bitcode \ + --remove-section __LLVM,__cmdline "$tmp" "$out" 2>/dev/null; then + rm -f "$tmp" + else + mv "$tmp" "$out" + fi +} + +bundle_shader_bridge_ios() { + local rust_targets="$1" sdk="$2" + local src="${SHADERFX_BRIDGE_IOS:-}" + local slices=() objs=() target slice obj i + SHADER_BRIDGE_LIB="" + SHADER_BRIDGE_ARCHS="" + rm -f "$LIBS_DIR/$SHADER_BRIDGE_NAME" "$LIBS_DIR/$SHADER_BRIDGE_OBJ" "$LIBS_DIR"/librashader_bridge.*.o + if [ -n "$src" ]; then + if [ -f "$src" ]; then + slices+=("$src") + else + warn "SHADERFX_BRIDGE_IOS=$src does not exist" + fi + else + for target in $rust_targets; do + if slice="$(build_shader_bridge_slice "$target")"; then + slices+=("$slice") + fi + done + fi + if [ "${#slices[@]}" -gt 0 ]; then + mkdir -p "$LIBS_DIR" + i=0 + for slice in "${slices[@]}"; do + i=$((i + 1)) + obj="$LIBS_DIR/librashader_bridge.$i.o" + if prelink_shader_bridge_slice "$slice" "$sdk" "$obj"; then + objs+=("$obj") + else + warn "could not prelink $(basename "$slice") for $sdk" + fi + done + fi + if [ "${#objs[@]}" -eq 1 ]; then + mv "${objs[0]}" "$LIBS_DIR/$SHADER_BRIDGE_OBJ" + elif [ "${#objs[@]}" -gt 1 ]; then + lipo -create "${objs[@]}" -output "$LIBS_DIR/$SHADER_BRIDGE_OBJ" + rm -f "${objs[@]}" + fi + if [ -f "$LIBS_DIR/$SHADER_BRIDGE_OBJ" ]; then + SHADER_BRIDGE_LIB="$LIBS_DIR/$SHADER_BRIDGE_OBJ" + SHADER_BRIDGE_ARCHS="$(lipo -archs "$SHADER_BRIDGE_LIB" 2>/dev/null || true)" + say "linking $SHADER_BRIDGE_OBJ for SHADER FX preset conversion (${SHADER_BRIDGE_ARCHS:-unknown arch})" + else + warn "$SHADER_BRIDGE_NAME not found: this build can run converted presets but not CONVERT new ones (set SHADERFX_BRIDGE_IOS, or install cargo plus one of: rustup target add $rust_targets)" + fi +} + +verify_shader_bridge() { + local app="$1" + local exe bin + if [ -z "$SHADER_BRIDGE_LIB" ]; then + warn "no SHADER FX bridge in this build: CONVERT stays unavailable, converted presets still run" + return 0 + fi + exe="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' \ + "$app/Info.plist" 2>/dev/null || true)" + bin="$app/${exe:-love}" + [ -f "$bin" ] || bin="$app/love" + if [ ! -f "$bin" ]; then + warn "no executable inside $(basename "$app"); skipping SHADER FX bridge check" + return 0 + fi + if nm "$bin" 2>/dev/null | grep -E ' _librashader_translate_preset$' >/dev/null \ + || xcrun dyld_info -exports "$bin" 2>/dev/null \ + | grep -E ' _librashader_translate_preset$' >/dev/null; then + say "SHADER FX bridge present (librashader_translate_preset)" + return 0 + fi + fail "built app does not carry the SHADER FX bridge symbols. + $SHADER_BRIDGE_OBJ was linked but librashader_translate_preset is absent, + so SHADER FX CONVERT would fail at runtime. + Rebuild after: rm -rf tools/shaderfx-bridge/target" +} + run_xcodebuild() { local config sdk destination if $RELEASE; then @@ -633,6 +765,8 @@ run_xcodebuild() { mkdir -p "$BUILD_DIR" + bundle_shader_bridge_ios "$(rust_targets_for_sdk "$sdk")" "$sdk" + # Prefer -target + SYMROOT over -derivedDataPath: modern Xcode requires # -scheme whenever -derivedDataPath is set, and love-ios ships no shared schemes. # Always stamp both: the overlay plist expands $(MARKETING_VERSION) / @@ -661,6 +795,13 @@ run_xcodebuild() { ONLY_ACTIVE_ARCH=NO DISABLE_MANUAL_TARGET_ORDER_BUILD_WARNING=YES ) + if [ -n "$SHADER_BRIDGE_LIB" ]; then + args+=(OTHER_LDFLAGS="-Wl,-u,_librashader_translate_preset -Wl,-u,_librashader_free_string \"$SHADER_BRIDGE_LIB\" -lc++") + if [ -n "$SHADER_BRIDGE_ARCHS" ]; then + args+=(ARCHS="$SHADER_BRIDGE_ARCHS") + fi + fi + if ! $DEVICE; then # Simulator: ad-hoc signing (no certificate needed). A plain unsigned # build would drop the entitlements file, and HealthKit refuses to run @@ -723,11 +864,14 @@ run_xcodebuild() { local products="$BUILD_DIR/Build/Products/${config}-${sdk}" local app="" - local candidate + local candidate newest=0 mtime for candidate in "$products/$PRODUCT_NAME.app" "$products/$APP_NAME.app" "$products/love.app"; do if [ -d "$candidate" ]; then - app="$candidate" - break + mtime="$(stat -f %m "$candidate" 2>/dev/null || echo 0)" + if [ "$mtime" -gt "$newest" ]; then + newest="$mtime" + app="$candidate" + fi fi done if [ -z "$app" ]; then @@ -736,6 +880,7 @@ run_xcodebuild() { return 0 fi if [ "$app" != "$products/$APP_NAME.app" ]; then + rm -rf "$products/$APP_NAME.app" mv "$app" "$products/$APP_NAME.app" app="$products/$APP_NAME.app" fi @@ -754,6 +899,7 @@ run_xcodebuild() { verify_game_payload "$app" verify_native_bridge "$app" + verify_shader_bridge "$app" local dist_dir="$DIST/${config}-${sdk}" rm -rf "$dist_dir" diff --git a/src/core/Sensors.lua b/src/core/Sensors.lua index 446eb283..940f71f6 100644 --- a/src/core/Sensors.lua +++ b/src/core/Sensors.lua @@ -76,7 +76,14 @@ local function sdlFfi() ]]) if sdlCdefOk then local okLoad, lib = pcall(ffi.load, "SDL2") - sdlLib = okLoad and lib or ffi.C + lib = okLoad and lib or ffi.C + local okSyms = pcall(function() + return lib.SDL_InitSubSystem, lib.SDL_NumSensors, lib.SDL_SensorGetDeviceType, + lib.SDL_SensorOpen, lib.SDL_SensorUpdate, lib.SDL_SensorGetData, + lib.SDL_GL_GetCurrentWindow, lib.SDL_GetWindowDisplayIndex, + lib.SDL_GetDisplayOrientation + end) + sdlLib = okSyms and lib or false end end if not sdlCdefOk or not sdlLib then return nil end diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index dbe4778e..1c54d58b 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -984,6 +984,17 @@ local function modScopeChipsWidth(options, gap, m) return need end +local function profileState(imp) + if imp._profileCache == nil then + local LauncherMods = require("src.mods.LauncherMods") + local SaveData = require("src.core.SaveData") + local options = SaveData.loadOptions() + local list, cur = LauncherMods.getProfiles(options) + imp._profileCache = { options = options, list = list, active = cur } + end + return imp._profileCache +end + local function buildModScopeRow(imp, x, y, w, m) local LauncherMods = require("src.mods.LauncherMods") local h = math.max(Kit.tapMin(), math.floor(26 * m.s)) @@ -994,7 +1005,7 @@ local function buildModScopeRow(imp, x, y, w, m) local options = modScopeOptions(imp) -- Dedicated Profile control section (cycle button + gear icon button) on right side of Scope Bar - local _, activeProf = LauncherMods.getProfiles() + local activeProf = profileState(imp).active local isCompact = (w < math.floor(500 * m.s)) local nameText = tostring(activeProf or "Default") local profLabel = isCompact and nameText or Strings("Profile: %s", nameText) @@ -3698,9 +3709,8 @@ local function buildSingleProfileActionsModal(imp, m) if not pName then imp._singleProfileActions = nil return end local LauncherMods = require("src.mods.LauncherMods") - local SaveData = require("src.core.SaveData") - local options = SaveData.loadOptions() - local profiles, active = LauncherMods.getProfiles(options) + local prof = profileState(imp) + local options, profiles = prof.options, prof.list local pad = math.floor(18 * m.s) local w = math.min(math.floor(380 * m.s), m.w - 2 * m.pad) @@ -3715,6 +3725,7 @@ local function buildSingleProfileActionsModal(imp, m) action = function() LauncherMods.duplicateProfile(pName, options) imp._singleProfileActions = nil + if imp._refreshMods then imp:_refreshMods() end end }, { @@ -3736,6 +3747,7 @@ local function buildSingleProfileActionsModal(imp, m) imp:pressDelete("profile", pName, nil, function() LauncherMods.deleteProfile(pName, options) imp._singleProfileActions = nil + if imp._refreshMods then imp:_refreshMods() end end) end } @@ -3771,9 +3783,8 @@ end -- Modal for Mod Profiles (#593) - interactive profile manager (switch, edit, duplicate, delete) local function buildProfilesModal(imp, m) local LauncherMods = require("src.mods.LauncherMods") - local SaveData = require("src.core.SaveData") - local options = SaveData.loadOptions() - local profiles, active = LauncherMods.getProfiles(options) + local prof = profileState(imp) + local options, profiles, active = prof.options, prof.list, prof.active local pad = math.floor(18 * m.s) local w = math.min(math.floor(460 * m.s), m.w - 2 * m.pad) diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index cff73978..c385786b 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -4803,6 +4803,7 @@ function RomImporter:_refreshMods() local LauncherMods = require("src.mods.LauncherMods") local SaveData = require("src.core.SaveData") self._cartPlan = nil + self._profileCache = nil self.findInstalled = nil self.safeMode = SaveData.isSafeMode(SaveData.loadOptions()) -- Once per session, ahead of the first listing: pull in any mod the player diff --git a/src/render/ShaderFX.lua b/src/render/ShaderFX.lua index 5862e66d..4a4a7739 100644 --- a/src/render/ShaderFX.lua +++ b/src/render/ShaderFX.lua @@ -264,7 +264,9 @@ ShaderFX.BRIDGE_DIR = "tools/shaderfx-bridge" local function libNames() local osName = (love and love.system and love.system.getOS and love.system.getOS()) or "" - if osName == "Windows" then + if osName == "iOS" then + return {} + elseif osName == "Windows" then return { "librashader_bridge.dll" } elseif osName == "OS X" then return { "liblibrashader_bridge.dylib", "librashader_bridge.dylib" } @@ -313,11 +315,13 @@ end -- Every place the bridge may sit, most specific first. local function libCandidates() + local names = libNames() + if #names == 0 then return {} end local out = {} local override = os.getenv("LIBRASHADER_BRIDGE_DLL") if override and override ~= "" then out[#out + 1] = override end local dirs, save = sourceDirs(), saveDir() - for _, name in ipairs(libNames()) do + for _, name in ipairs(names) do for _, dir in ipairs(dirs) do out[#out + 1] = dir .. "/" .. name out[#out + 1] = dir .. "/" .. ShaderFX.BRIDGE_DIR .. "/target/release/" .. name @@ -353,7 +357,17 @@ local function ensureLib() end tried[#tried + 1] = path end - libError = "librashader bridge not found; looked in " .. table.concat(tried, ", ") + local okSym, sym = pcall(function() + return ffi.C and ffi.C.librashader_translate_preset + end) + if okSym and sym ~= nil then + lib = ffi.C + return lib + end + libError = "librashader bridge not found; ffi.C has no librashader_translate_preset" + if #tried > 0 then + libError = libError .. "; looked in " .. table.concat(tried, ", ") + end return nil, libError end diff --git a/tests/engine/shaderfx_ffi_c_fallback.lua b/tests/engine/shaderfx_ffi_c_fallback.lua new file mode 100644 index 00000000..25d7e49d --- /dev/null +++ b/tests/engine/shaderfx_ffi_c_fallback.lua @@ -0,0 +1,85 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") + +love = love or require("tests.love_stub") + +local NAME = "src.render.ShaderFX" + +local function withFfi(fakeFfi, os, fn) + local oldModule = package.loaded[NAME] + local oldFfi = package.loaded.ffi + local oldPreload = package.preload.ffi + local oldGetOS = love.system.getOS + if os then love.system.getOS = function() return os end end + package.loaded[NAME] = nil + package.loaded.ffi = nil + package.preload.ffi = function() return fakeFfi end + local ShaderFX = require(NAME) + local can, err = ShaderFX.canConvert(), ShaderFX.bridgeError() + if fn then fn(ShaderFX) end + package.loaded[NAME] = oldModule + package.loaded.ffi = oldFfi + package.preload.ffi = oldPreload + love.system.getOS = oldGetOS + return can, err, ShaderFX +end + +local function baseFfi() + return { + cdef = function() end, + load = function() error("no such library") end, + string = function(value) return value end, + } +end + +local staticFfi = baseFfi() +staticFfi.C = { + librashader_translate_preset = function() return "{}" end, + librashader_free_string = function() end, +} + +local can, err = withFfi(staticFfi) +T.eq(can, true, "a statically linked bridge is found through ffi.C") +T.eq(err, nil, "the ffi.C fallback leaves no bridge error behind") + +local emptyFfi = baseFfi() +emptyFfi.C = setmetatable({}, { + __index = function() error("undefined symbol") end, +}) + +local missing, missingErr = withFfi(emptyFfi) +T.eq(missing, false, "no library and no ffi.C symbol means no conversion") +T.check(missingErr and missingErr:find("ffi.C has no librashader_translate_preset", 1, true) ~= nil, + "libError says ffi.C was probed too (got " .. tostring(missingErr) .. ")") +T.check(missingErr and missingErr:find("looked in", 1, true) ~= nil, + "libError still lists the paths that were tried") + +local iosMissing, iosErr = withFfi(baseFfi(), "iOS") +T.eq(iosMissing, false, "iOS with no static symbol cannot convert") +T.check(iosErr and iosErr:find("ffi.C has no librashader_translate_preset", 1, true) ~= nil, + "iOS libError names the ffi.C probe") +T.check(iosErr and iosErr:find("looked in", 1, true) == nil, + "iOS lists no file candidates (got " .. tostring(iosErr) .. ")") + +local iosCan = withFfi(staticFfi, "iOS") +T.eq(iosCan, true, "iOS resolves the bridge through ffi.C alone") + +local freed = false +local translateFfi = baseFfi() +translateFfi.C = { + librashader_translate_preset = function(path, es) + return ('{"pass_count":1,"passes":[{"name":"%s","es":%d}]}'):format(path, es) + end, + librashader_free_string = function() freed = true end, +} +withFfi(translateFfi, "iOS", function(ShaderFX) + local preset, terr = ShaderFX.translate("/presets/a.slangp", true) + T.check(preset ~= nil, "translate works with lib == ffi.C (" .. tostring(terr) .. ")") + T.eq(preset and preset.passes[1].name, "/presets/a.slangp", + "the ffi.C symbol receives the preset path") + T.eq(preset and preset.passes[1].es, 1, "the es flag reaches the ffi.C symbol") + T.eq(freed, true, "the returned string is freed through ffi.C") +end) + +T.finish("shaderfx ffi.C fallback") diff --git a/tools/shaderfx-bridge/Cargo.toml b/tools/shaderfx-bridge/Cargo.toml index d9671c35..f43f56b9 100644 --- a/tools/shaderfx-bridge/Cargo.toml +++ b/tools/shaderfx-bridge/Cargo.toml @@ -5,7 +5,7 @@ edition = "2024" [lib] name = "librashader_bridge" -crate-type = ["cdylib", "rlib"] +crate-type = ["cdylib", "staticlib", "rlib"] [dependencies] librashader-preprocess = "0.12.0" From b35fab845a4cc92d85e8800e9334811637c9c8d2 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Tue, 25 Aug 2026 09:21:33 -0400 Subject: [PATCH 15/16] fix deez tests --- tests/engine/launcher_gold_touch_rows.lua | 25 +++++++++-------------- tests/love_stub.lua | 1 + 2 files changed, 11 insertions(+), 15 deletions(-) diff --git a/tests/engine/launcher_gold_touch_rows.lua b/tests/engine/launcher_gold_touch_rows.lua index de78bc71..da01a37a 100644 --- a/tests/engine/launcher_gold_touch_rows.lua +++ b/tests/engine/launcher_gold_touch_rows.lua @@ -64,22 +64,17 @@ for _, version in ipairs({ "gold", "silver", "crystal" }) do voidFill.step(-1) eq(model.opts.gold.voidFill, "fade", version .. " left restores fade") - -- Every write has to land in the gen2 block: the flat keys beside it are - -- Red's, and no Gen 2 boot reads them (src/core/gen2/Save.lua:299). - -- loadOptions seeds the flat Gen 1 defaults, so the check is that the Gen 2 - -- rows leave them exactly as they found them. - local flatPad = model.opts.touchControls - local flatBuzz = model.opts.haptics - + -- The pad and the buzz are one device-wide setting: Gen 2 reads them from + -- the flat keys, not its block (src/core/gen2/Save.lua SHARED_KEYS), so the + -- rows write the flat keys and must never grow a copy inside `gold`. local pad = findRow(model, "TOUCH PAD") local before = pad.value() pad.step(1) check(pad.value() ~= before, version .. " stepping TOUCH PAD flips it") - check(type(model.opts.gold) == "table", version .. " into the gold block") - eq(model.opts.gold.touchControls.enabled, false, - version .. " which now carries enabled") - eq(model.opts.touchControls, flatPad, - version .. " leaving the flat Gen 1 key alone") + eq(model.opts.touchControls.enabled, false, + version .. " on the flat shared key") + eq(model.opts.gold.touchControls, nil, + version .. " without a copy in the gold block") eq(model.opts.silver, nil, version .. " and inventing no second Gen 2 block beside it") eq(model.opts.crystal, nil, version .. " nor a third") @@ -88,10 +83,10 @@ for _, version in ipairs({ "gold", "silver", "crystal" }) do local buzzBefore = buzz.value() buzz.step(1) check(buzz.value() ~= buzzBefore, version .. " stepping VIBRATION moves the level") - eq(model.opts.gold.haptics, - TouchControls.normalizeHaptics(model.opts.gold.haptics), + eq(model.opts.haptics, + TouchControls.normalizeHaptics(model.opts.haptics), version .. " VIBRATION stores a level the shared module knows") - eq(model.opts.haptics, flatBuzz, version .. " also without touching Red's") + eq(model.opts.gold.haptics, nil, version .. " on the flat key only") edited = 0 findRow(model, "TOUCH CONTROLS").action() diff --git a/tests/love_stub.lua b/tests/love_stub.lua index 28ad9244..5e9379aa 100644 --- a/tests/love_stub.lua +++ b/tests/love_stub.lua @@ -139,6 +139,7 @@ stub.graphics = { end, translate = noop, scale = noop, rotate = noop, origin = noop, setScissor = noop, + getScissor = function() return nil end, getDimensions = function() return 640, 576 end, -- dpi=1 desktop default; issue #87 tests override these for Android density getPixelDimensions = function() return 640, 576 end, From bbe0f0d9ae30c34766e9a12198bfe7f14a5fd820 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Tue, 25 Aug 2026 09:30:37 -0400 Subject: [PATCH 16/16] crystal intro fix --- src/core/Sound.lua | 7 ++++++ src/ui/gen2/CrystalIntro.lua | 41 ++++++++++++++++++++++++------------ 2 files changed, 34 insertions(+), 14 deletions(-) diff --git a/src/core/Sound.lua b/src/core/Sound.lua index 01a82299..efb59f2f 100644 --- a/src/core/Sound.lua +++ b/src/core/Sound.lua @@ -371,6 +371,13 @@ function Sound.waitSfxDone() curSfx = nil end +-- SFXChannelsOff (home/audio.asm:545) +function Sound.sfxChannelsOff() + if not curSfx then return end + pcall(curSfx.src.stop, curSfx.src) + curSfx = nil +end + local function startSfx(data, name, def) local src = playPath(data, name, def) if not src then return end diff --git a/src/ui/gen2/CrystalIntro.lua b/src/ui/gen2/CrystalIntro.lua index 50afead6..9611403b 100644 --- a/src/ui/gen2/CrystalIntro.lua +++ b/src/ui/gen2/CrystalIntro.lua @@ -441,15 +441,25 @@ end -- Intro_ClearBGPals blacks all 16 palettes and burns two frames -- (engine/movie/intro.asm:1554-1571); IntroScene26 clears to white instead --- (engine/movie/intro.asm:1058, home/tilemap.asm:168-196). -local function setup(self, white, fn) +-- (engine/movie/intro.asm:1058, home/tilemap.asm:1-9,168-196). +-- Request2bpp (home/gfx.asm:1,190-260): TILES_PER_CYCLE tiles per frame, +-- then one more frame for the final short (or empty) request. +local function requestFrames(tiles) + local frames = 0 + for _, count in ipairs(tiles) do + frames = frames + math.floor(count / 8) + 1 + end + return frames +end + +local function setup(self, white, tiles, fn) if self.phase == 0 then self.phase = 1 local color = white and WHITE or BLACK self.bgPals = flatPals(color) self.obPals = flatPals(color) markDirty(self) - self.hold = 1 + self.hold = (white and 4 or 2) + requestFrames(tiles) - 1 return end self.phase = 0 @@ -556,7 +566,7 @@ local Scenes = {} -- IntroScene1 (engine/movie/intro.asm:96-146). Scenes[1] = function(self) - setup(self, false, function() + setup(self, false, { 64, 128, 128, 64 }, function() loadAct(self, "unownA") clearAnims(self) self.scx, self.scy = 0, 0 @@ -582,7 +592,7 @@ end -- IntroScene3 (engine/movie/intro.asm:172-218). Scenes[3] = function(self) - setup(self, false, function() + setup(self, false, { 64, 128, 64 }, function() loadAct(self, "background") resetLYOverrides(self) self.scx, self.scy = 0, 0 @@ -602,7 +612,7 @@ end -- IntroScene5 (engine/movie/intro.asm:234-285). Scenes[5] = function(self) - setup(self, false, function() + setup(self, false, { 64, 128, 128, 64 }, function() loadAct(self, "unownHI") self.lyActive = false clearAnims(self) @@ -641,7 +651,7 @@ end -- IntroScene7 (engine/movie/intro.asm:332-401). Scenes[7] = function(self) - setup(self, false, function() + setup(self, false, { 64, 128, 255, 128, 64 }, function() loadAct(self, "background") resetLYOverrides(self) clearAnims(self) @@ -708,7 +718,7 @@ end -- IntroScene11 (engine/movie/intro.asm:502-550). Scenes[11] = function(self) - setup(self, false, function() + setup(self, false, { 64, 128, 64 }, function() loadAct(self, "unowns") self.lyActive = false clearAnims(self) @@ -729,7 +739,10 @@ local UNOWN_SOUNDS = { Scenes[12] = function(self) local a = self.counter local sound = UNOWN_SOUNDS[a] - if sound then self:playSfx(sound) end + if sound then + Sound.sfxChannelsOff() + self:playSfx(sound) + end self.counter = (a + 1) % 256 if a >= 0xc0 then self.scene = 13 @@ -746,7 +759,7 @@ end -- IntroScene13 (engine/movie/intro.asm:626-683). Scenes[13] = function(self) - setup(self, false, function() + setup(self, false, { 64, 255, 128, 64 }, function() loadAct(self, "background") clearAnims(self) self.anims:init("INTRO_SUICUNE", 11 * 8, 13 * 8 + 4) @@ -783,7 +796,7 @@ end -- IntroScene15 (engine/movie/intro.asm:730-792). Scenes[15] = function(self) - setup(self, false, function() + setup(self, false, { 64, 128, 128, 1, 64 }, function() loadAct(self, "suicuneJump") clearAnims(self) self.anims:init("INTRO_UNOWN_F", 5 * 8, 8 * 8) @@ -809,7 +822,7 @@ end -- IntroScene17 (engine/movie/intro.asm:812-859). Scenes[17] = function(self) - setup(self, false, function() + setup(self, false, { 64, 255, 64 }, function() loadAct(self, "suicuneClose") clearAnims(self) self.scx, self.scy = 0, 0 @@ -832,7 +845,7 @@ end -- IntroScene19 (engine/movie/intro.asm:878-941). Scenes[19] = function(self) - setup(self, false, function() + setup(self, false, { 64, 128, 128, 1, 64 }, function() loadAct(self, "suicuneBack") clearAnims(self) self.anims:init("INTRO_SUICUNE_AWAY", 0, 12 * 8) @@ -918,7 +931,7 @@ end -- IntroScene26 (engine/movie/intro.asm:1056-1103). Scenes[26] = function(self) - setup(self, true, function() + setup(self, true, { 64, 128, 64 }, function() loadAct(self, "crystalUnowns") clearAnims(self) self.scx, self.scy = 0, 0